From e1048169861f706225bf4c5ed08ccc111c8abbe7 Mon Sep 17 00:00:00 2001 From: Aleksandr Nikolaev Date: Tue, 4 Aug 2026 12:13:47 +0500 Subject: [PATCH 01/19] UCCORE-1790: icrease support client version --- src/lib.rs | 2 +- src/ucare/mod.rs | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index cf1059a..9a7da3c 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(); diff --git a/src/ucare/mod.rs b/src/ucare/mod.rs index dd62749..e257601 100644 --- a/src/ucare/mod.rs +++ b/src/ucare/mod.rs @@ -14,7 +14,9 @@ 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. +pub(crate) const CLIENT_VERSION: &str = env!("CARGO_PKG_VERSION"); /// Holds per project API credentials. /// You can find your credentials on the uploadcare dashboard. From d0416681d7e1edcaf9233eb978c14242d63e0e0a Mon Sep 17 00:00:00 2001 From: Aleksandr Nikolaev Date: Tue, 4 Aug 2026 12:20:08 +0500 Subject: [PATCH 02/19] UCCORE-1790: impove error handling --- src/ucare/error.rs | 9 +++ src/ucare/rest/mod.rs | 155 ++++++++++++++++++++++++++++++++++++++---- 2 files changed, 150 insertions(+), 14 deletions(-) diff --git a/src/ucare/error.rs b/src/ucare/error.rs index a2429e3..e4f1294 100644 --- a/src/ucare/error.rs +++ b/src/ucare/error.rs @@ -99,12 +99,17 @@ 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), /// 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,6 +133,7 @@ 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::PayloadTooLarge(ref msg) => write!(f, "{}: {}", prefix, msg), ErrValue::TooManyRequests(ref retry_after) => write!( @@ -135,6 +141,9 @@ impl fmt::Display for ErrValue { "{}: 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/rest/mod.rs b/src/ucare/rest/mod.rs index e0e04ee..2c0ffa9 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,24 @@ 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; /// 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"), } } } @@ -157,15 +160,32 @@ 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::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] @@ -175,10 +195,117 @@ impl Client { .unwrap(); Err(Error::with_value(ErrValue::TooManyRequests(retry_after))) } - StatusCode::OK | _ => { + 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() => { let resp_data = res.json()?; Ok(resp_data) } + // 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)")); + } +} From 7014b9ee40153edd21dc7f57c9295bf8eec8ef0b Mon Sep 17 00:00:00 2001 From: Aleksandr Nikolaev Date: Tue, 4 Aug 2026 12:55:21 +0500 Subject: [PATCH 03/19] UCCORE-1790: upgrade file API --- src/file.rs | 482 ++++++++++++++++++++++++++++++++++++++------------ src/lib.rs | 5 +- src/types.rs | 74 ++++++++ src/upload.rs | 49 ++++- 4 files changed, 495 insertions(+), 115 deletions(-) create mode 100644 src/types.rs diff --git a/src/file.rs b/src/file.rs index f91e575..10711be 100644 --- a/src/file.rs +++ b/src/file.rs @@ -13,6 +13,7 @@ use reqwest::{Method, Url}; use serde::{self, Deserialize, Serialize}; use serde_json; +use crate::types::ImageInfo; use crate::ucare::{encode_json, rest::Client, IntoUrlQuery, Result}; /// Service is used to make calls to file API. @@ -42,9 +43,12 @@ impl Service<'_> { /// # use ucare::file; /// /// let params = file::ListParams{ + /// removed: Some(false), + /// stored: None, /// 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; @@ -119,17 +123,6 @@ impl Service<'_> { ) } - /// 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/"), - None, - Some(json), - ) - } - /// 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 @@ -178,143 +171,159 @@ 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>, -} - -/// ImageInfo holds image-specific information -#[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>, + /// 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`. + #[serde(default)] + pub metadata: HashMap, + /// File tags. + /// + /// Three states to tell apart: `None` means the feature is disabled for the + /// project, `Some([])` means the file has no tags, and a non empty vector holds + /// the tags themselves. The order is significant and must not be changed: it is + /// the order of the first occurrence, not a sorted set. + 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 + /// [`ListParams::include`], otherwise `None`. + pub appdata: Option>, } -/// Image geo location +/// Recognized information about the file content. +/// +/// 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` +/// (the MIME type declared on upload is always available as `Info::mime_type`). #[derive(Debug, Deserialize)] -pub struct ImageInfoGeoLocation { - /// Location latitude. - pub latitude: Option, - /// Location longitude. - pub longitude: Option, +pub struct ContentInfo { + /// Detected MIME type. + pub mime: Option, + /// Image metadata. + pub image: Option, + /// Video metadata. + pub video: 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, +/// 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` and from +/// [`crate::upload::VideoInfo`], which still uses the old shape: `video` and `audio` +/// are lists of streams here, `duration` and `bitrate` are nullable, and audio +/// channels are a number rather than a string. #[derive(Debug, PartialEq, Deserialize)] pub struct VideoInfo { - /// Video duration in milliseconds. - pub duration: Option, /// Video format (MP4 for example). pub format: Option, + /// Video duration in milliseconds. + pub duration: Option, /// Video bitrate. - pub bitrate: Option, - /// Audio information - pub audio: Option, - /// Video stream info - pub video: Option, + 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, } -/// 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 { +/// A single video stream of a video file +#[derive(Debug, PartialEq, Eq, Deserialize)] +pub struct VideoStream { /// Video stream image height. - pub height: Option, + pub height: Option, /// Video stream image width. - pub width: Option, - /// Video stream frame rate. - pub frame_rate: Option, + pub width: Option, + /// Video stream frame rate, already rounded by the API. + pub frame_rate: Option, /// Video stream bitrate. - pub bitrate: Option, + 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. + pub channels: Option, + /// Audio stream bitrate. + pub bitrate: Option, + /// Audio stream codec. + pub codec: Option, + /// Audio stream sample rate. + pub sample_rate: Option, + /// Audio stream profile. + pub profile: Option, +} + +/// Result produced by a single application for a file +#[derive(Debug, Deserialize)] +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, @@ -333,19 +342,23 @@ 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, } /// 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. +#[non_exhaustive] pub enum Ordering { /// "datetime_uploaded" DatetimeUploaded, /// "-datetime_uploaded" DatetimeUploadedNeg, - /// "size" - Size, - /// "-size" - SizeNeg, } impl Display for Ordering { @@ -353,8 +366,28 @@ 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. +#[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) @@ -382,7 +415,7 @@ impl IntoUrlQuery for ListParams { if let Some(val) = self.limit { q.push_str(val.to_string().as_str()); } else { - q.push_str("1000"); + q.push_str("100"); } q.push('&'); @@ -399,6 +432,12 @@ impl IntoUrlQuery for ListParams { q.push_str(val.as_str()); } + if let Some(val) = self.include { + q.push('&'); + q.push_str("include="); + q.push_str(val.to_string().as_str()); + } + q } } @@ -523,3 +562,220 @@ pub struct BatchInfo { /// Results describes successfully operated files pub result: Option>, } + +#[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); + // integer in v0.7, was a float in the v0.6 video_info + assert_eq!(video.video[0].frame_rate, Some(30)); + // 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_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, + }; + + assert_eq!( + params.into_query(), + "removed=false&limit=100&ordering=datetime_uploaded", + ); + } + + #[test] + fn list_params_query_full() { + let params = ListParams { + removed: Some(true), + stored: Some(true), + limit: Some(10), + ordering: Some(Ordering::DatetimeUploadedNeg), + from: Some("2026-08-04T10:00:00Z".to_string()), + include: Some(Include::Appdata), + }; + + assert_eq!( + params.into_query(), + "removed=true&stored=true&limit=10&ordering=-datetime_uploaded\ + &from=2026-08-04T10:00:00Z&include=appdata", + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index 9a7da3c..b78002b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -35,8 +35,9 @@ //! removed: Some(true), //! stored: Some(true), //! 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(); //! @@ -81,4 +82,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/types.rs b/src/types.rs new file mode 100644 index 0000000..e91ad97 --- /dev/null +++ b/src/types.rs @@ -0,0 +1,74 @@ +//! 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; + +/// ImageInfo holds image-specific information. +/// +/// REST APIv0.7 returns it as `content_info.image` (see [`crate::file::ContentInfo`]), +/// the Upload API — as `image_info` (see [`crate::upload::FileInfo`]). The set of +/// fields is the same in both. +#[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, +} diff --git a/src/upload.rs b/src/upload.rs index 157ca93..5090d03 100644 --- a/src/upload.rs +++ b/src/upload.rs @@ -21,7 +21,7 @@ use std::fmt::{self, Debug, Display}; use reqwest::{blocking::multipart::Form, Method, Url}; use serde::Deserialize; -use crate::file::{ImageInfo, VideoInfo}; +use crate::types::ImageInfo; use crate::ucare::{upload::Client, upload::Fields, upload::Payload, Result}; /// Service is used to make calls to file API. @@ -339,6 +339,53 @@ pub struct FileInfo { pub default_effects: Option, } +/// 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. + 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, +} + /// Group information #[derive(Debug, Deserialize, Default)] pub struct GroupInfo { From 59464fa322b9e4cc9ad67afc7659194f195a65fa Mon Sep 17 00:00:00 2001 From: Aleksandr Nikolaev Date: Tue, 4 Aug 2026 13:30:11 +0500 Subject: [PATCH 04/19] UCCORE-1790: file schemas, query params --- src/file.rs | 117 +++++++++++++++++++++++++++++++++++++++++++++------- src/lib.rs | 9 ++-- 2 files changed, 109 insertions(+), 17 deletions(-) diff --git a/src/file.rs b/src/file.rs index 10711be..94b97e2 100644 --- a/src/file.rs +++ b/src/file.rs @@ -28,11 +28,16 @@ pub fn new_svc(client: &Client) -> Service { 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, ) } @@ -43,8 +48,8 @@ impl Service<'_> { /// # use ucare::file; /// /// let params = file::ListParams{ - /// removed: Some(false), - /// stored: None, + /// removed: Some(file::Filter::False), + /// stored: Some(file::Filter::All), /// limit: Some(10), /// ordering: Some(file::Ordering::DatetimeUploaded), /// from: None, @@ -326,13 +331,14 @@ pub struct AppDataEntry { /// 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, + /// `Filter::False` to only include existing ones and `Filter::All` to include + /// both. Defaults to `Filter::False`. + 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, @@ -346,6 +352,35 @@ pub struct ListParams { pub include: Option, } +/// A three valued filter for the list method. +/// +/// `All` was added in APIv0.7, before that the parameters were plain booleans. +/// Note that `removed: All` combined with `stored: All` is a valid request, while +/// `removed: True` combined with `stored: True` returns an empty result — that is +/// expected, not an error. +#[derive(Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +#[non_exhaustive] +pub enum Filter { + /// "true" + True, + /// "false" + False, + /// "all" + All, +} + +impl Display for Filter { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + let val = match *self { + Filter::True => "true", + Filter::False => "false", + Filter::All => "all", + }; + + write!(f, "{}", val) + } +} + /// Specifies the way files are sorted in a returned list. /// By default is set to datetime_uploaded. /// @@ -401,7 +436,7 @@ impl IntoUrlQuery for ListParams { if let Some(val) = self.removed { q.push_str(val.to_string().as_str()); } else { - q.push_str("false"); + q.push_str(Filter::False.to_string().as_str()); } q.push('&'); @@ -454,10 +489,24 @@ 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, } +/// 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, +} + /// MUST be either true or false #[derive(Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize)] pub enum ToStore { @@ -764,8 +813,8 @@ mod tests { #[test] fn list_params_query_full() { let params = ListParams { - removed: Some(true), - stored: Some(true), + removed: Some(Filter::True), + stored: Some(Filter::True), limit: Some(10), ordering: Some(Ordering::DatetimeUploadedNeg), from: Some("2026-08-04T10:00:00Z".to_string()), @@ -778,4 +827,44 @@ mod tests { &from=2026-08-04T10:00:00Z&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, + }; + + assert_eq!( + params.into_query(), + "removed=all&stored=all&limit=100&ordering=datetime_uploaded", + ); + } + + #[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/lib.rs b/src/lib.rs index b78002b..8c7a191 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -32,8 +32,8 @@ //! //! // 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::DatetimeUploaded), //! from: None, @@ -43,7 +43,10 @@ //! //! // 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(); From 5fd6e8e4d09fd59391a51340dc96ff388fc541c6 Mon Sep 17 00:00:00 2001 From: Aleksandr Nikolaev Date: Tue, 4 Aug 2026 13:45:51 +0500 Subject: [PATCH 05/19] UCCORE-1790: file search --- src/file.rs | 509 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 508 insertions(+), 1 deletion(-) diff --git a/src/file.rs b/src/file.rs index 94b97e2..ccdafb7 100644 --- a/src/file.rs +++ b/src/file.rs @@ -10,7 +10,7 @@ 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::types::ImageInfo; @@ -84,6 +84,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(file::IsImage::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::( @@ -388,6 +431,7 @@ impl Display for Filter { /// 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" @@ -410,6 +454,7 @@ impl Display for Ordering { /// 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. @@ -507,6 +552,308 @@ pub struct Totals { 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. + #[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>, +} + +/// Value of the `is_image` search criterion. +/// +/// Mirrors the three states of [`Info::is_image`]. Serialized as a real json +/// boolean or `null`: the API rejects the strings `"true"` and `"false"` with +/// a `400`. +#[derive(Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub enum IsImage { + /// A recognized image. + True, + /// Definitely not an image. + False, + /// Recognition has not finished yet. + Unknown, +} + +impl Serialize for IsImage { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: Serializer, + { + match *self { + IsImage::True => serializer.serialize_bool(true), + IsImage::False => serializer.serialize_bool(false), + IsImage::Unknown => serializer.serialize_none(), + } + } +} + +/// 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 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, +} + +/// 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. + pub next: Option, + /// Previous page URL, `None` when the offset is 0. + 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 ToStore { @@ -845,6 +1192,166 @@ mod tests { ); } + #[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 as_value = |val: IsImage| { + serde_json::to_value(SearchQuery { + is_image: Some(val), + ..Default::default() + }) + .unwrap() + }; + + assert_eq!( + as_value(IsImage::True), + serde_json::json!({"is_image": true}) + ); + assert_eq!( + as_value(IsImage::False), + serde_json::json!({"is_image": false}), + ); + assert_eq!( + as_value(IsImage::Unknown), + serde_json::json!({"is_image": null}), + ); + } + + #[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#"{ From acc0d350437c3b4595dff0f7365f034e63fe1723 Mon Sep 17 00:00:00 2001 From: Aleksandr Nikolaev Date: Tue, 4 Aug 2026 13:51:14 +0500 Subject: [PATCH 06/19] UCCORE-1790: rm datetime_stored --- src/group.rs | 79 +++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 69 insertions(+), 10 deletions(-) diff --git a/src/group.rs b/src/group.rs index 02a3509..728e498 100644 --- a/src/group.rs +++ b/src/group.rs @@ -81,14 +81,24 @@ 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. + pub fn delete(&self, group_id: &str) -> Result<()> { + let res = self.client.call::( + Method::DELETE, + format!("/groups/{}/", group_id), None, None, - ) + ); + + // a successful delete answers with an empty body, which the client reports + // as a deserialization error; same normalization as in `webhook::delete` + if let Err(err) = res { + if !err.to_string().contains("EOF") { + return Err(err); + } + } + + Ok(()) } } @@ -99,8 +109,6 @@ pub struct Info { 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, /// number of files in a group pub files_count: i32, /// public CDN URL for a group @@ -179,7 +187,58 @@ 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, + Some("2026-08-04T10:00:00Z".to_string()), + ); + } + + #[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, + }; + + assert_eq!(params.into_query(), "limit=100&ordering=datetime_created",); + } } From e73591c61e8b318d73b4cdda37bfe3218ff144cf Mon Sep 17 00:00:00 2001 From: Aleksandr Nikolaev Date: Tue, 4 Aug 2026 14:23:19 +0500 Subject: [PATCH 07/19] UCCORE-1790: conversion --- src/conversion.rs | 156 ++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 130 insertions(+), 26 deletions(-) diff --git a/src/conversion.rs b/src/conversion.rs index 38ab85a..5b1ad64 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; @@ -43,6 +46,21 @@ 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)?; @@ -55,10 +73,10 @@ impl Service<'_> { } /// 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, ) @@ -84,15 +102,25 @@ 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. pub store: Option, @@ -132,21 +160,6 @@ pub struct JobInfo { pub token: Option, } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn job_info_token_deserializes_as_i64() { - let json = r#"{ - "uuid": "a18983d0-b0d7-4c8d-968b-2e6d2e1c3ea1", - "token": 3000000000 - }"#; - 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 { @@ -162,3 +175,94 @@ pub struct StatusResult { /// Result repeats the contents of your processing output pub result: JobInfo, } + +/// 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, +} + +/// 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. + #[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::*; + + #[test] + fn job_info_token_deserializes_as_i64() { + let json = r#"{ + "uuid": "a18983d0-b0d7-4c8d-968b-2e6d2e1c3ea1", + "token": 3000000000 + }"#; + let info: JobInfo = serde_json::from_str(json).unwrap(); + assert_eq!(info.token, Some(3_000_000_000_i64)); + } + + #[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); + + 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())); + // converted_groups is nested inside format, not at the top level + assert_eq!(format.converted_groups.len(), 2); + assert_eq!( + format.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); + + let format = info.format.unwrap(); + assert!(format.conversion_formats.is_empty()); + assert!(format.converted_groups.is_empty()); + } +} From ce2541515138fc54c8a45475d1dcaf051e286c55 Mon Sep 17 00:00:00 2001 From: Aleksandr Nikolaev Date: Tue, 4 Aug 2026 14:38:39 +0500 Subject: [PATCH 08/19] UCCORE-1790: addons --- src/addons.rs | 584 ++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 2 + src/ucare/error.rs | 4 + src/ucare/rest/mod.rs | 3 + 4 files changed, 593 insertions(+) create mode 100644 src/addons.rs diff --git a/src/addons.rs b/src/addons.rs new file mode 100644 index 0000000..7221730 --- /dev/null +++ b/src/addons.rs @@ -0,0 +1,584 @@ +//! 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, rest::Client, 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); + +/// 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 stays locked for up to 24 + /// hours. + /// - `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, 10 to 600 per minute + /// depending on the project plan. Polling is not affected by it. + /// + /// 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, + format!("/addons/{}/execute/", 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/", application_id), + Some(format!("request_id={}", 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), + /// } + /// ``` + 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; + + 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)); + + let info = self.status(application_id, request_id)?; + 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 => { + return Ok(Outcome::Unknown { + request_id: request_id.to_string(), + }) + } + Status::InProgress => (), + } + + 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. + /// + /// Present only for [`Status::Error`] and only when the failure has one, so + /// `None` here is not an error either. + 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 + /// is older than the 24 hours it is kept for. 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`]. + 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, + }, +} + +/// 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, + /// Background color as hex without the leading hash: 3, 4, 6 or 8 characters. + /// The 4 and 8 character forms carry transparency. + #[serde(skip_serializing_if = "Option::is_none")] + pub bg_color: 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), + bg_color: Some("81d4fa".to_string()), + ..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", "bg_color": "81d4fa"}, + }), + ); + } + + #[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/lib.rs b/src/lib.rs index 8c7a191..8f0db44 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -71,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")] diff --git a/src/ucare/error.rs b/src/ucare/error.rs index e4f1294..d03da70 100644 --- a/src/ucare/error.rs +++ b/src/ucare/error.rs @@ -103,6 +103,9 @@ pub enum ErrValue { 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 @@ -135,6 +138,7 @@ impl fmt::Display for ErrValue { 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, diff --git a/src/ucare/rest/mod.rs b/src/ucare/rest/mod.rs index 2c0ffa9..482e8c1 100644 --- a/src/ucare/rest/mod.rs +++ b/src/ucare/rest/mod.rs @@ -184,6 +184,9 @@ impl Client { StatusCode::NOT_ACCEPTABLE => Err(Error::with_value(ErrValue::NotAcceptable( 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"), ))), From a3751572ff574a83e997df378f7bbcd67ae7e184 Mon Sep 17 00:00:00 2001 From: Aleksandr Nikolaev Date: Tue, 4 Aug 2026 14:49:59 +0500 Subject: [PATCH 09/19] UCCORE-1790: webhooks --- src/webhook.rs | 331 +++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 322 insertions(+), 9 deletions(-) diff --git a/src/webhook.rs b/src/webhook.rs index 5489c34..cb19731 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}; @@ -19,16 +34,47 @@ pub fn new_svc(client: &Client) -> Service { 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) } + /// Returns a single webhook by its id + 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>( @@ -40,6 +86,15 @@ impl Service<'_> { } /// 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,10 +106,20 @@ 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)?; + // 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` let res = self.client.call::, String>( Method::DELETE, format!("/webhooks/unsubscribe/"), @@ -75,24 +140,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 @@ -108,14 +210,65 @@ pub struct CreateParams { pub signing_secret: Option, /// Marks a subscription as either active or not, defaults to true, otherwise false. 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 @@ -142,6 +295,166 @@ 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()); + assert_eq!( + value, + serde_json::json!({"id": 1387, "signing_secret": null, "is_active": true}), + ); + } +} From 7e540b2c978e296e8ee396bb19a8acc0bbda79ea Mon Sep 17 00:00:00 2001 From: Aleksandr Nikolaev Date: Tue, 4 Aug 2026 14:57:26 +0500 Subject: [PATCH 10/19] UCCORE-1790: tests --- tests/rest.rs | 134 +++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 105 insertions(+), 29 deletions(-) diff --git a/tests/rest.rs b/tests/rest.rs index 3602879..b4d3d2a 100644 --- a/tests/rest.rs +++ b/tests/rest.rs @@ -7,36 +7,29 @@ use ucare::{self, 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); @@ -77,7 +70,7 @@ 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, @@ -85,7 +78,7 @@ fn file() { 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 +88,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,16 +172,17 @@ 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(); @@ -168,7 +208,7 @@ 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 @@ -177,20 +217,44 @@ fn webhook() { 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 let mut rng = rand::thread_rng(); let suff: u8 = rng.gen(); - let target_url = format!("https://localhost:8080/test_endpoint{}", suff); + 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_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"); + + // 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,9 +266,21 @@ 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 + 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); - // 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, ()); @@ -212,7 +288,7 @@ fn webhook() { #[test] fn project() { - let client = rest_client_v06(); + let client = rest_client(); let project_svc = project::new_svc(&client); // info From 7ddfd859982d7ed30e807aa10a56de95c6c30a41 Mon Sep 17 00:00:00 2001 From: Aleksandr Nikolaev Date: Wed, 5 Aug 2026 13:33:40 +0500 Subject: [PATCH 11/19] UCCORE-1790: meta/tags on upload --- src/ucare/rest/auth.rs | 4 +- src/upload.rs | 195 +++++++++++++++++++++++++++++++++-------- tests/upload.rs | 11 +++ 3 files changed, 170 insertions(+), 40 deletions(-) diff --git a/src/ucare/rest/auth.rs b/src/ucare/rest/auth.rs index cbb1a78..1014cc4 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()); diff --git a/src/upload.rs b/src/upload.rs index 5090d03..2ba0010 100644 --- a/src/upload.rs +++ b/src/upload.rs @@ -38,17 +38,11 @@ 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.to_string(), params.path.to_string())?; + 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::>( @@ -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,6 +68,8 @@ impl Service<'_> { if let Some(val) = params.save_url_duplicates { form = form.text("save_URL_duplicates", val.to_string()); } + // this endpoint takes metadata but no tags + form = add_metadata_tags(form, params.metadata, None); form = add_signature_expire(&(*self.client.auth_fields)(), form); self.client.call::( @@ -162,17 +153,15 @@ 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::( @@ -218,15 +207,30 @@ 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 and values to non empty strings of up to + /// 512, same as the file metadata of the REST API. Values are strings only: + /// 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. + /// + /// 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 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,6 +241,11 @@ 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`]. + /// + /// Unlike the direct and the multipart upload, this endpoint takes no tags. + pub metadata: HashMap, } /// Holds data returned by `from_url` @@ -279,9 +288,9 @@ pub enum FromUrlStatusData { #[serde(rename = "progress")] Progress { /// Currently uploaded file size in bytes - done: u32, + done: u64, /// Total file size in bytes - total: u32, + total: u64, }, /// File upload error #[serde(rename = "error")] @@ -309,13 +318,13 @@ 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 @@ -412,11 +421,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 @@ -457,6 +480,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, @@ -503,6 +531,40 @@ 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>, +) -> Form { + 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); + } + + 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. +fn encode_tags(tags: Option>) -> Option { + match tags { + None => None, + Some(tags) if tags.is_empty() => None, + Some(tags) => Some(tags.join(",")), + } +} + fn add_signature_expire(auth_fields: &Fields, form: Form) -> Form { let form = form .text("UPLOADCARE_PUB_KEY", auth_fields.pub_key.to_string()) @@ -516,3 +578,60 @@ fn add_signature_expire(auth_fields: &Fields, form: Form) -> Form { ) .text("expire", auth_fields.expire.as_ref().unwrap().to_string()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn metadata_field_names() { + assert_eq!(metadata_field("subsystem"), "metadata[subsystem]"); + // any unicode letter is a valid key character, the brackets are all we add + assert_eq!(metadata_field("отдел"), "metadata[отдел]"); + } + + #[test] + fn tags_are_comma_separated() { + assert_eq!( + encode_tags(Some(vec!["invoice".to_string(), "2026".to_string()])), + Some("invoice,2026".to_string()), + ); + assert_eq!( + encode_tags(Some(vec!["invoice".to_string()])), + Some("invoice".to_string()), + ); + } + + #[test] + fn tags_send_no_field_when_there_is_nothing_to_send() { + assert_eq!(encode_tags(None), None); + assert_eq!(encode_tags(Some(vec![])), 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()])), + Some("Invoice,invoice".to_string()), + ); + } + + #[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 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/tests/upload.rs b/tests/upload.rs index de2f9be..f2c5232 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,6 +67,7 @@ fn from_url() { filename: Some("Great_London".to_string()), check_url_duplicates: None, save_url_duplicates: None, + metadata: HashMap::new(), }; let data = upload_svc.from_url(params).unwrap(); match data { @@ -94,6 +101,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(); From 3b0a814c072cbc981c0a467155fcc19b52a8e090 Mon Sep 17 00:00:00 2001 From: Aleksandr Nikolaev Date: Thu, 6 Aug 2026 01:05:20 +0500 Subject: [PATCH 12/19] UCCORE-1790: docs --- .github/workflows/test.yml | 4 +- CHANGELOG.md | 94 ++++++++++++++++++++++++++++++++++++++ README.md | 19 +++++++- 3 files changed, 114 insertions(+), 3 deletions(-) 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..db68cdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,97 @@ +## Unreleased + +### Upload API: request parameters + +BREAKING CHANGES: + +* **`to_store` left as `None` no longer sends `0`.** All three upload methods used to + substitute `ToStore::False` for a missing value, which made every upload temporary + regardless of the project settings. The field is now omitted from the request and + the API applies its own default — `auto` for projects registered after + February 12, 2024 and `0` for the older ones. Code that relied on the implicit + "temporary unless asked otherwise" has to pass `Some(ToStore::False)` explicitly. +* **Byte counters widened from `u32` to `u64`.** `u32` caps at 4 GiB, which multipart + upload exists to exceed. Affects `MultipartParams::size`, `FileInfo::size`, + `FileInfo::total`, `FileInfo::done` and the `done` / `total` of + `FromUrlStatusData::Progress`. +* `upload::FileParams` has two new fields, `metadata: HashMap` and + `tags: Option>`. Both are `Default`, so `..Default::default()` covers + them, but an exhaustive struct literal has to be updated. +* `upload::MultipartParams` has three new fields: `part_size: Option`, plus the + same `metadata` and `tags`. +* `upload::FromUrlParams` has a new `metadata` field. This endpoint takes metadata but + no tags. + +FEATURES: + +* `POST /base/`, `POST /multipart/start/` and `POST /from_url/` now send file metadata + as `metadata[key]` form fields. The first two also send tags, as a single comma + separated `tags` field. +* `POST /multipart/start/` accepts `part_size`. Left to the API default of 5 MiB when + `None`; worth raising for files over a gigabyte, otherwise the number of presigned + part urls in the response grows into the thousands. Note that whatever is passed + here decides how the caller has to slice the file — `upload_part` expects every + part but the last to be exactly that size. + +IMPROVEMENTS: + +* Tags are passed through as given rather than normalized locally: the API lowercases, + trims and deduplicates them, so what comes back may differ from what was sent. + An empty tag vector sends no field at all, same as `None`. + +### Webhooks: REST API v0.7 + +BREAKING CHANGES: + +* **Subscriptions are now created with version `0.7` instead of `0.6`.** This is the + consequence of raising the `Accept` header, and it changes behaviour for existing + users even though their code does not change: a `0.7` subscription delivers a + **different payload format** to `target_url` than a `0.6` one did. 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. +* `CreateParams` has a new required field, `version: Option`. Leave it `None` + to get `Version::V07`; it is always sent explicitly so that the created subscription + does not silently depend on which API version the crate happens to speak. Note that + creating a `0.6` subscription is no longer possible at all: APIv0.7 answers + `400 Invalid version`, and this crate only speaks v0.7. +* `Info.signing_secret` changed from `String` to `Option`. The API documents + the field as nullable, so the old type failed to deserialize a subscription without + a secret. +* `Info.event` stays a `String` rather than becoming the `Event` enum, deliberately: + reading existing subscriptions can turn up an event a given release does not know + about, and that must not break deserialization. +* `Event` and the new `Version` enum are `#[non_exhaustive]`. + +FEATURES: + +* Four new events, all of which require a `0.7` subscription: `Event::FileInfected`, + `Event::FileStored`, `Event::FileDeleted`, `Event::FileInfoUpdated`. +* `Info.version` exposes the subscription version. It is fixed at creation and can + never be changed — passing a version to `update` is rejected by the API, which is + why `UpdateParams` has no such field. Changing a version means deleting the + subscription and creating it again. +* `webhook::Service::get(id)` reads a single subscription, `GET /webhooks/{id}/`. + +IMPROVEMENTS: + +* `Info` no longer derives `Debug`; it implements it manually with `signing_secret` + masked, so the secret does not reach logs through debug output. Code reading the + field directly still has to mask it itself. +* Documented the nuances that are easy to get wrong: + * `delete` removes **every** subscription pointing at the given `target_url`, for + all events at once. It is not a way to unsubscribe from one event. + * `target_url` must resolve to a non private address, so local endpoints cannot be + used for debugging — the integration test now uses a public host for that reason. + * `403` depends on the particular `target_url`, not only on the project, so it must + not be cached as a per project "webhooks unavailable" flag. + * `file.info_updated` is not published when nothing actually changed, so it is not a + confirmation that a write happened; and it also fires for background processing + that was never requested by the caller. + * the delivery payload format is defined by the delivery layer, not by the schemas + of this API — do not reuse `file::Info` for it. In particular `metadata` may be + `null` in a delivery, while REST API v0.7 always returns an object. + ## 0.3.1 (Apr 16, 2026) IMPROVEMENTS: diff --git a/README.md b/README.md index 727b4dc..38a93d7 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(); @@ -88,6 +88,23 @@ println!("uploaded: {:?}", file.id); In examples we’re going to use `ucarecdn.com` domain. Check your project's subdomain in the [Dashboard](https://app.uploadcare.com/projects/-/settings/#delivery). +## Demo + +[`demo/`](./demo) is a CLI harness with one subcommand per library method, for +trying the contracts out against a real project: + +```sh +cd demo +export UCARE_SECRET_KEY=... UCARE_PUBLIC_KEY=... + +cargo run -- help +cargo run -- file list --limit 3 --stored all +cargo run -- smoke # every contract in one run, with a summary table +``` + +It prints the typed value each call returned, and `--verbose` adds the http +request and response behind it. See [demo/README.md](./demo/README.md). + ## Useful links [Rust API client documentation](https://docs.rs/uploadcare/) From cb2a23ba283991b59338e507d33657131cd97641 Mon Sep 17 00:00:00 2001 From: Aleksandr Nikolaev Date: Thu, 6 Aug 2026 01:59:50 +0500 Subject: [PATCH 13/19] UCCORE-1790: self-review fixes --- CHANGELOG.md | 140 +++++++++++ README.md | 17 -- src/addons.rs | 73 ++++-- src/conversion.rs | 121 +++++++++- src/file.rs | 517 ++++++++++++++++++++++------------------ src/group.rs | 119 +++++---- src/project.rs | 11 +- src/types.rs | 90 ++++++- src/ucare/error.rs | 3 - src/ucare/mod.rs | 17 +- src/ucare/rest/auth.rs | 8 +- src/ucare/rest/mod.rs | 34 ++- src/ucare/upload/mod.rs | 41 +++- src/upload.rs | 207 +++++++++++----- src/webhook.rs | 67 ++++-- tests/rest.rs | 124 ++++++++-- tests/testenv/mod.rs | 2 - tests/upload.rs | 6 +- 18 files changed, 1136 insertions(+), 461 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index db68cdf..34362cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,145 @@ ## Unreleased +### REST API v0.7: client core + +BREAKING CHANGES: + +* **`RestApiVersion::V05` and `V06` are gone**, the client speaks v0.7 only. The enum + is `#[non_exhaustive]` from now on. +* Error handling reworked: `4xx`/`5xx` responses map to `ErrValue` variants + (new: `MethodNotAllowed`, `Conflict`, `ServerError`) instead of surfacing serde + errors; non-JSON and empty error bodies are passed through as text. A missing or + malformed `Retry-After` header no longer panics. + +IMPROVEMENTS: + +* Empty success bodies (`204` on the delete endpoints) are handled by the client + itself; the `"EOF"` substring matching is gone from `webhook::delete` and + `group::delete`. +* User supplied query values (`from` cursors, add-on `request_id`) are + percent-encoded; unset list parameters are no longer sent, the documented API + defaults apply. +* `Warning` response headers (e.g. dropped metadata keys on `local_copy`) are logged. +* The `Date` auth header is formatted with `%Y` instead of ISO week based `%G`, + which produced invalid signatures around New Year. +* The version in `X-UC-User-Agent` is taken from the crate manifest. + +### Files: REST API v0.7 + +BREAKING CHANGES: + +* `file::Service::info` takes an `include: Option` argument (`appdata`). +* `file::Info`: `datetime_stored`/`datetime_removed` semantics per v0.7; new + `content_info`, `metadata`, `tags`, `appdata` fields; `size` is `i64`; + the v0.6-only `source` field is gone. `content_info` types (shared with the + Upload API) live in `ucare::types` and are re-exported from `file`. +* `ListParams` uses the `Filter` enum for `removed`/`stored` and `Ordering` lost + sorting by size (not supported by v0.7). `Filter::All` sends no parameter at all: + `all` is not a documented value. +* `CopyParams`: `make_public` is a plain `Option` (the documented boolean), + new `metadata` field (local copy), and `local_copy`/`remote_copy` no longer + inject implicit `store`/`make_public` defaults — unset fields are not sent. + `Pattern::AutoFilename` serializes to the documented `${auto_filename}`. +* `VideoStream::frame_rate` is `f64`: NTSC-style fractional rates (`29.97`) are + common and used to fail deserialization of the whole response. + +FEATURES: + +* `POST /files/search/` with typed criteria (`SearchQuery`), pagination and + highlights. +* File tags endpoints: `tags`, `set_tags`, `update_tags` + (`GET`/`PUT`/`PATCH /files/{uuid}/tags/`). +* File metadata endpoints: `metadata`, `metadata_value`, `set_metadata_value`, + `delete_metadata_value` (`GET /files/{uuid}/metadata/`, + `GET`/`PUT`/`DELETE /files/{uuid}/metadata/{key}/`). Keys are validated client + side against the documented charset before they reach the URL. +* `BatchInfo` exposes the response `status`. + +### Conversion: REST API v0.7 + +BREAKING CHANGES: + +* `JobInfo.thumbnails_group_id` renamed to `thumbnails_group_uuid` — the old field + name never matched the API and always deserialized to `None`. +* `StatusResult.result` is `Option`: a `failed` job carries no result and + used to make the whole status call fail to parse. +* `JobParams` has a new `save_in_group` field (document conversion only); `store` + and `save_in_group` are omitted from the request when unset. + +FEATURES: + +* `document_info` (`GET /convert/document/{uuid}/`): source format, possible + conversions and already converted groups. The docs contradict themselves on + where `converted_groups` lives (top level vs nested in `format`), so both + placements are accepted; `DocumentInfo::any_converted_groups` picks whichever + is present. + +FIXES: + +* `POST /convert/video/` uses the trailing slash — without it the API redirects, + and a redirected POST loses its body. +* `video_status` uses `GET` and the correct path; conversion job tokens are `i64`. + +### Add-Ons: new module (REST API v0.7) + +* `addons::Service`: `execute`, `status`, `execute_and_wait`/`wait` for + `uc_clamav_virus_scan`, `aws_rekognition_detect_labels`, + `aws_rekognition_detect_moderation_labels` and `remove_bg`, with typed + per-application params. A transient status poll failure does not lose the + `request_id` of a running job: it is reported as `Outcome::PollFailed` after + several consecutive failures. + +### Groups: REST API v0.7 + +BREAKING CHANGES: + +* `group::Service::store` is gone: v0.7 removed `PUT /groups/{uuid}/storage/` + together with the group `datetime_stored` field. +* `group::Info::datetime_created` is a plain `String` (documented as required). + +FEATURES: + +* `group::Service::delete` (`DELETE /groups/{uuid}/`), new in v0.7. +* `group::Info` carries `files` (with `None` placeholders for removed files) and + `url` — previously the primary payload of the info endpoint was dropped. + +### Project + +* `project::Info` exposes the documented `autostore_enabled` field. + +### Webhooks: partial update fixes + +* `UpdateParams.signing_secret` is only sent when set. It used to be serialized as + `null` on every update, which contradicted the documented partial-update + semantics and risked clearing the stored secret. +* `UpdateParams.id` is no longer serialized into the request body (it is a path + parameter). +* `CreateParams` no longer sends `signing_secret: null`/`is_active: null` for + unset fields and no longer forces `is_active: true` client side — the API + default (active) applies. + +### Upload API: response schemas + +BREAKING CHANGES: + +* **`FromUrlData` is now tagged by the response `type` field.** The previous + `untagged` representation could never produce the `FileInfo` variant — a + `check_URL_duplicates` hit was silently mis-parsed as a token-less `Token`. + `FileToken.token` is a plain `String` and the `data_type` field is gone (it + duplicated the tag). +* `FromUrlStatusData::Progress.total` is `Option` (documented as nullable) and + `FromUrlStatusData::Error` carries the documented `error_code`. +* `VideoInfo`/`VideoInfoAudio`/`VideoInfoVideo` numeric fields are integers per the + documented schema (`frame_rate` stays fractional); **`channels` is `Option`** + — it was typed as a string and broke deserialization of any video with sound. +* `GroupInfo.files` is `Option>>`: the array holds `null` for + removed files. + +FEATURES: + +* `upload::FileInfo` exposes `content_info` and `metadata`, so what is sent on + upload can also be read back from upload responses. + ### Upload API: request parameters BREAKING CHANGES: diff --git a/README.md b/README.md index 38a93d7..1b32fde 100644 --- a/README.md +++ b/README.md @@ -88,23 +88,6 @@ println!("uploaded: {:?}", file.id); In examples we’re going to use `ucarecdn.com` domain. Check your project's subdomain in the [Dashboard](https://app.uploadcare.com/projects/-/settings/#delivery). -## Demo - -[`demo/`](./demo) is a CLI harness with one subcommand per library method, for -trying the contracts out against a real project: - -```sh -cd demo -export UCARE_SECRET_KEY=... UCARE_PUBLIC_KEY=... - -cargo run -- help -cargo run -- file list --limit 3 --stored all -cargo run -- smoke # every contract in one run, with a summary table -``` - -It prints the typed value each call returned, and `--verbose` adds the http -request and response behind it. See [demo/README.md](./demo/README.md). - ## Useful links [Rust API client documentation](https://docs.rs/uploadcare/) diff --git a/src/addons.rs b/src/addons.rs index 7221730..fb5cc65 100644 --- a/src/addons.rs +++ b/src/addons.rs @@ -25,12 +25,14 @@ use std::time::{Duration, Instant}; use reqwest::Method; use serde::{Deserialize, Serialize}; -use crate::ucare::{encode_json, rest::Client, Result}; +use crate::ucare::{encode_json, 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; /// Service is used to make calls to the Add-Ons API. pub struct Service<'a> { @@ -38,7 +40,7 @@ pub struct Service<'a> { } /// creates an instance of the addons service -pub fn new_svc(client: &Client) -> Service { +pub fn new_svc(client: &Client) -> Service<'_> { Service { client } } @@ -54,14 +56,14 @@ impl Service<'_> { /// /// - `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 stays locked for up to 24 - /// hours. + /// 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, 10 to 600 per minute - /// depending on the project plan. Polling is not affected by it. + /// - `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 @@ -86,7 +88,9 @@ impl Service<'_> { self.client.call::( Method::GET, format!("/addons/{}/execute/status/", application_id), - Some(format!("request_id={}", request_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, ) } @@ -121,6 +125,9 @@ impl Service<'_> { /// 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( @@ -144,6 +151,7 @@ impl Service<'_> { ) -> Result { let started = Instant::now(); let mut delay = FIRST_POLL_DELAY; + let mut poll_failures = 0; loop { // sleeping before the first poll on purpose: the job has just been @@ -156,7 +164,26 @@ impl Service<'_> { } thread::sleep(min(delay, left)); - let info = self.status(application_id, request_id)?; + // 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 { @@ -243,8 +270,8 @@ pub struct StatusInfo { pub result: Option, /// Machine readable failure description. /// - /// Present only for [`Status::Error`] and only when the failure has one, so - /// `None` here is not an error either. + /// Not part of the documented status responses, but observed alongside + /// [`Status::Error`] for some applications. `None` is the norm. pub details: Option
, } @@ -261,10 +288,11 @@ pub enum Status { /// Finished unsuccessfully. #[serde(rename = "error")] Error, - /// The API knows nothing about this `request_id`: it never existed, or the state - /// is older than the 24 hours it is kept for. The two are indistinguishable, so - /// after a previously seen [`Status::InProgress`] this almost certainly means - /// expiry rather than a bad `request_id`. + /// 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, } @@ -307,6 +335,16 @@ pub enum Outcome { /// 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 @@ -343,10 +381,6 @@ pub struct RemoveBgParams { /// Scale of the subject relative to the total image size, `50%` for example. #[serde(skip_serializing_if = "Option::is_none")] pub scale: Option, - /// Background color as hex without the leading hash: 3, 4, 6 or 8 characters. - /// The 4 and 8 character forms carry transparency. - #[serde(skip_serializing_if = "Option::is_none")] - pub bg_color: Option, /// Whether to add an artificial shadow. Not supported for every subject type. #[serde(skip_serializing_if = "Option::is_none")] pub add_shadow: Option, @@ -443,7 +477,6 @@ mod tests { &RemoveBgParams { crop: Some(true), foreground_type: Some(ForegroundType::Person), - bg_color: Some("81d4fa".to_string()), ..Default::default() }, ) @@ -453,7 +486,7 @@ mod tests { serde_json::to_value(¶ms).unwrap(), serde_json::json!({ "target": "1bac376c-aa7e-4356-861b-dd2657b5bfd1", - "params": {"crop": true, "type": "person", "bg_color": "81d4fa"}, + "params": {"crop": true, "type": "person"}, }), ); } diff --git a/src/conversion.rs b/src/conversion.rs index 5b1ad64..c10d9bb 100644 --- a/src/conversion.rs +++ b/src/conversion.rs @@ -20,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 } } @@ -30,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), ) @@ -66,7 +66,9 @@ impl Service<'_> { 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), ) @@ -93,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. @@ -123,7 +125,15 @@ pub struct JobParams { /// 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 @@ -153,7 +163,7 @@ 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 @@ -172,8 +182,11 @@ pub struct StatusResult { 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, + /// 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 @@ -184,6 +197,29 @@ pub struct DocumentInfo { /// 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 @@ -194,8 +230,9 @@ pub struct DocumentFormat { /// 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. + /// 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, } @@ -242,14 +279,34 @@ mod tests { 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())); - // converted_groups is nested inside format, not at the top level - assert_eq!(format.converted_groups.len(), 2); + } + + #[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!( - format.converted_groups.get("pdf"), + info.any_converted_groups().get("pdf"), Some(&"badfc9f7-f88f-4921-9cc0-22e2c08aa2da~1".to_string()), ); } @@ -260,9 +317,49 @@ mod tests { 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 ccdafb7..80aff48 100644 --- a/src/file.rs +++ b/src/file.rs @@ -13,8 +13,10 @@ use reqwest::{Method, Url}; use serde::{self, ser::SerializeMap, Deserialize, Serialize, Serializer}; use serde_json; -use crate::types::ImageInfo; -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, rest::Client, ErrValue, Error, IntoUrlQuery, Result, +}; /// Service is used to make calls to file API. pub struct Service<'a> { @@ -22,7 +24,7 @@ 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 } } @@ -72,7 +74,7 @@ impl Service<'_> { pub fn list(&self, params: ListParams) -> Result { self.client.call::( Method::GET, - format!("/files/"), + "/files/".to_string(), Some(params), None, ) @@ -98,7 +100,7 @@ impl Service<'_> { /// let params = file::SearchParams { /// query: file::SearchQuery { /// query: Some("invoice".to_string()), - /// is_image: Some(file::IsImage::False), + /// is_image: Some(false), /// ..Default::default() /// }, /// limit: Some(50), @@ -143,7 +145,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), ) @@ -165,7 +167,7 @@ impl Service<'_> { let json = encode_json(&file_ids)?; self.client.call::, BatchInfo>( Method::DELETE, - format!("/files/storage/"), + "/files/storage/".to_string(), None, Some(json), ) @@ -174,19 +176,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), ) @@ -195,20 +194,132 @@ 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 16 tags per file, up to 64 characters each. The API lowercases, + /// trims and deduplicates the values, so [`TagsUpdate::tags`] in the response + /// may differ from what was sent. + 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. +fn validate_metadata_key(key: &str) -> Result<()> { + let valid = !key.is_empty() + && key.len() <= 64 + && 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, `_-.:`", + key, + )))) + } } /// Info holds file specific information @@ -247,9 +358,6 @@ pub struct Info { /// Dictionary of other files that has been created using this file as source. Used for video, /// document and etc. conversion. pub variations: Option, - /// File upload source. This field contains information about from where file was uploaded, for - /// example: facebook, gdrive, gphotos, etc. - pub source: Option, /// Recognized content information: mime type, image and video metadata. /// /// Replaces `image_info` and `video_info` of APIv0.6. Is `None` for files whose @@ -261,100 +369,21 @@ pub struct Info { /// hence not an `Option`. #[serde(default)] pub metadata: HashMap, - /// File tags. + /// File tags, ordered by their first occurrence; an empty vector when the file + /// has no tags. /// - /// Three states to tell apart: `None` means the feature is disabled for the - /// project, `Some([])` means the file has no tags, and a non empty vector holds - /// the tags themselves. The order is significant and must not be changed: it is - /// the order of the first occurrence, not a sorted set. + /// `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 - /// [`ListParams::include`], otherwise `None`. + /// Only present when `appdata` was asked for through the `include` argument + /// of [`Service::info`], [`ListParams::include`] or [`SearchParams::include`], + /// otherwise `None`. pub appdata: Option>, } -/// Recognized information about the file content. -/// -/// 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` -/// (the MIME type declared on upload is always available as `Info::mime_type`). -#[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` and from -/// [`crate::upload::VideoInfo`], which still uses the old shape: `video` and `audio` -/// are lists of streams here, `duration` and `bitrate` are nullable, and audio -/// channels are a number rather than a string. -#[derive(Debug, PartialEq, Deserialize)] -pub struct VideoInfo { - /// Video format (MP4 for example). - pub format: Option, - /// Video duration in milliseconds. - pub duration: Option, - /// Video bitrate. - 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, Eq, Deserialize)] -pub struct VideoStream { - /// Video stream image height. - pub height: Option, - /// Video stream image width. - pub width: Option, - /// Video stream frame rate, already rounded by the API. - pub frame_rate: Option, - /// Video stream bitrate. - 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. - pub channels: Option, - /// Audio stream bitrate. - pub bitrate: Option, - /// Audio stream codec. - pub codec: Option, - /// Audio stream sample rate. - pub sample_rate: Option, - /// Audio stream profile. - pub profile: Option, -} - /// Result produced by a single application for a file #[derive(Debug, Deserialize)] pub struct AppDataEntry { @@ -397,10 +426,12 @@ pub struct ListParams { /// A three valued filter for the list method. /// -/// `All` was added in APIv0.7, before that the parameters were plain booleans. -/// Note that `removed: All` combined with `stored: All` is a valid request, while -/// `removed: True` combined with `stored: True` returns an empty result — that is -/// expected, not an error. +/// 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 { @@ -408,19 +439,18 @@ pub enum Filter { True, /// "false" False, - /// "all" + /// The parameter is not sent, the API default applies. All, } -impl Display for Filter { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - let val = match *self { - Filter::True => "true", - Filter::False => "false", - Filter::All => "all", - }; - - write!(f, "{}", val) +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, + } } } @@ -476,49 +506,30 @@ impl Display for Include { 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(Filter::False.to_string().as_str()); + // 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("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::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 { - q.push('&'); - q.push_str("include="); - q.push_str(val.to_string().as_str()); + parts.push(format!("include={}", val)); } - q + parts.join("&") } } @@ -614,8 +625,11 @@ pub struct SearchQuery { #[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, + pub is_image: Option, /// File tags to match. #[serde(skip_serializing_if = "Option::is_none")] pub tags: Option, @@ -748,34 +762,6 @@ pub struct TagsFilter { pub none: Option>, } -/// Value of the `is_image` search criterion. -/// -/// Mirrors the three states of [`Info::is_image`]. Serialized as a real json -/// boolean or `null`: the API rejects the strings `"true"` and `"false"` with -/// a `400`. -#[derive(Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] -pub enum IsImage { - /// A recognized image. - True, - /// Definitely not an image. - False, - /// Recognition has not finished yet. - Unknown, -} - -impl Serialize for IsImage { - fn serialize(&self, serializer: S) -> std::result::Result - where - S: Serializer, - { - match *self { - IsImage::True => serializer.serialize_bool(true), - IsImage::False => serializer.serialize_bool(false), - IsImage::Unknown => serializer.serialize_none(), - } - } -} - /// Specifies the way found files are sorted. /// /// Sorting by size is available here, unlike in [`Ordering`] for the file list: @@ -816,8 +802,14 @@ 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. + /// Previous page URL, `None` when the offset is 0. Informational only, + /// see `next`. pub previous: Option, /// A total number of matched files. /// @@ -865,18 +857,6 @@ pub enum ToStore { False, } -/// MUST be either true or false. true to make copied files available via public links, -/// false to reverse the behavior. -#[derive(Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize)] -pub enum MakePublic { - /// True - #[serde(rename = "true")] - True, - /// False - #[serde(rename = "false")] - False, -} - /// The parameter is used to specify file names Uploadcare passes to a custom storage. /// In case the parameter is omitted, we use pattern of your custom storage. /// Use any combination of allowed values. @@ -886,7 +866,7 @@ pub enum Pattern { #[serde(rename = "${default}")] Default, /// AutoFilename - #[serde(rename = "${filename} ${effects} ${ext}")] + #[serde(rename = "${auto_filename}")] AutoFilename, /// Effects #[serde(rename = "${effects}")] @@ -909,15 +889,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 make_public: Option, + 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, /// 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 @@ -953,12 +940,41 @@ 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::*; @@ -1065,12 +1081,30 @@ mod tests { let video = content_info.video.unwrap(); assert_eq!(video.duration, Some(10000)); assert_eq!(video.video.len(), 1); - // integer in v0.7, was a float in the v0.6 video_info - assert_eq!(video.video[0].frame_rate, Some(30)); + 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 @@ -1151,10 +1185,8 @@ mod tests { include: None, }; - assert_eq!( - params.into_query(), - "removed=false&limit=100&ordering=datetime_uploaded", - ); + // nothing is sent, the documented server side defaults apply + assert_eq!(params.into_query(), ""); } #[test] @@ -1164,14 +1196,16 @@ mod tests { stored: Some(Filter::True), limit: Some(10), ordering: Some(Ordering::DatetimeUploadedNeg), - from: Some("2026-08-04T10:00:00Z".to_string()), + 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:00:00Z&include=appdata", + &from=2026-08-04T10%3A00%3A00%2B03%3A00&include=appdata", ); } @@ -1186,10 +1220,34 @@ mod tests { include: None, }; - assert_eq!( - params.into_query(), - "removed=all&stored=all&limit=100&ordering=datetime_uploaded", - ); + // `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 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] @@ -1209,25 +1267,14 @@ mod tests { #[test] fn search_query_is_image_serializes_as_json_boolean() { // strings "true"/"false" are rejected by the API with a 400 - let as_value = |val: IsImage| { - serde_json::to_value(SearchQuery { - is_image: Some(val), - ..Default::default() - }) - .unwrap() + let query = SearchQuery { + is_image: Some(true), + ..Default::default() }; assert_eq!( - as_value(IsImage::True), - serde_json::json!({"is_image": true}) - ); - assert_eq!( - as_value(IsImage::False), - serde_json::json!({"is_image": false}), - ); - assert_eq!( - as_value(IsImage::Unknown), - serde_json::json!({"is_image": null}), + serde_json::to_value(&query).unwrap(), + serde_json::json!({"is_image": true}), ); } diff --git a/src/group.rs b/src/group.rs index 728e498..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, ) @@ -82,23 +83,16 @@ impl Service<'_> { } /// 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<()> { - let res = self.client.call::( + self.client.call::( Method::DELETE, format!("/groups/{}/", group_id), None, None, - ); - - // a successful delete answers with an empty body, which the client reports - // as a deserialization error; same normalization as in `webhook::delete` - if let Err(err) = res { - if !err.to_string().contains("EOF") { - return Err(err); - } - } - - Ok(()) + ) } } @@ -108,11 +102,19 @@ pub struct Info { /// group identifier pub id: String, /// date and time when a group was created - pub datetime_created: 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 @@ -150,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("&") } } @@ -210,9 +201,42 @@ mod tests { 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!( - info.datetime_created, - Some("2026-08-04T10:00:00Z".to_string()), + files[1].as_ref().unwrap().uuid, + "1f067f79-cbc8-4b61-9c7b-1c1e0ea6b4b6", ); } @@ -239,6 +263,21 @@ mod tests { from: None, }; - assert_eq!(params.into_query(), "limit=100&ordering=datetime_created",); + // 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/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 index e91ad97..b3a572b 100644 --- a/src/types.rs +++ b/src/types.rs @@ -6,11 +6,95 @@ 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, +/// `duration` and `bitrate` are nullable, and audio channels are a number +/// rather than a string. +#[derive(Debug, PartialEq, Deserialize)] +pub struct VideoInfo { + /// Video format (MP4 for example). + pub format: Option, + /// Video duration in milliseconds. + pub duration: Option, + /// Video bitrate. + 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. + 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. + pub channels: Option, + /// Audio stream bitrate. + 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 [`crate::file::ContentInfo`]), -/// the Upload API — as `image_info` (see [`crate::upload::FileInfo`]). The set of -/// fields is the same in both. +/// 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. diff --git a/src/ucare/error.rs b/src/ucare/error.rs index d03da70..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; diff --git a/src/ucare/mod.rs b/src/ucare/mod.rs index e257601..afe7c57 100644 --- a/src/ucare/mod.rs +++ b/src/ucare/mod.rs @@ -16,6 +16,7 @@ pub mod upload; /// 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. @@ -41,6 +42,7 @@ where } } +#[cfg(feature = "rest")] pub(crate) fn encode_json(params: &T) -> Result, Error> where T: ?Sized + Serialize, @@ -52,13 +54,26 @@ 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() +} + 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())?; diff --git a/src/ucare/rest/auth.rs b/src/ucare/rest/auth.rs index 1014cc4..efcd15d 100644 --- a/src/ucare/rest/auth.rs +++ b/src/ucare/rest/auth.rs @@ -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 482e8c1..85d8d36 100644 --- a/src/ucare/rest/mod.rs +++ b/src/ucare/rest/mod.rs @@ -73,7 +73,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(), ); @@ -83,7 +83,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(), ) @@ -140,10 +140,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, @@ -191,11 +188,14 @@ impl Client { 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 + // is not worth a panic + let retry_after = res + .headers() + .get(header::RETRY_AFTER) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.parse::().ok()) + .unwrap_or(0); Err(Error::with_value(ErrValue::TooManyRequests(retry_after))) } status if status.is_server_error() => Err(Error::with_value(ErrValue::ServerError( @@ -203,8 +203,16 @@ impl Client { error_detail(res, status.canonical_reason().unwrap_or("server error")), ))), status if status.is_success() => { - let resp_data = res.json()?; - Ok(resp_data) + // 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 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 2ba0010..7b75084 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}; @@ -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,7 +38,7 @@ 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())?; + 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()); } @@ -47,7 +47,7 @@ impl Service<'_> { self.client.call::>( Method::POST, - format!("/base/"), + "/base/".to_string(), None, Some(Payload::Form(form)), ) @@ -74,7 +74,7 @@ impl Service<'_> { self.client.call::( Method::POST, - format!("/from_url/"), + "/from_url/".to_string(), None, Some(Payload::Form(form)), ) @@ -121,7 +121,7 @@ impl Service<'_> { self.client.call::( Method::POST, - format!("/group/"), + "/group/".to_string(), None, Some(Payload::Form(form)), ) @@ -166,16 +166,16 @@ impl Service<'_> { 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))) @@ -188,7 +188,7 @@ impl Service<'_> { self.client.call::( Method::POST, - format!("/multipart/complete/"), + "/multipart/complete/".to_string(), None, Some(Payload::Form(form)), ) @@ -211,9 +211,11 @@ pub struct FileParams { pub to_store: Option, /// Arbitrary metadata to attach to the file, sent as `metadata[key]` fields. /// - /// Keys are limited to 64 characters and values to non empty strings of up to - /// 512, same as the file metadata of the REST API. Values are strings only: - /// numbers, booleans and nested objects cannot be stored. + /// 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. /// @@ -227,6 +229,7 @@ pub struct FileParams { } /// 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, @@ -249,13 +252,20 @@ pub struct FromUrlParams { } /// 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), } @@ -269,16 +279,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 @@ -289,16 +296,19 @@ pub enum FromUrlStatusData { Progress { /// Currently uploaded file size in bytes done: u64, - /// Total file size in bytes - total: 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 @@ -306,12 +316,6 @@ 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 { @@ -346,6 +350,11 @@ 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. + #[serde(default)] + pub metadata: HashMap, } /// Video related information as returned by the Upload API. @@ -356,11 +365,11 @@ pub struct FileInfo { #[derive(Debug, PartialEq, Deserialize)] pub struct VideoInfo { /// Video duration in milliseconds. - pub duration: Option, + pub duration: Option, /// Video format (MP4 for example). pub format: Option, /// Video bitrate. - pub bitrate: Option, + pub bitrate: Option, /// Audio information pub audio: Option, /// Video stream info @@ -371,26 +380,26 @@ pub struct VideoInfo { #[derive(Debug, PartialEq, Deserialize)] pub struct VideoInfoAudio { /// Audio stream metadata. - pub bitrate: Option, + 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, + pub sample_rate: Option, + /// Audio stream number of channels, an integer per the documented schema. + pub channels: Option, } /// Video stream info #[derive(Debug, PartialEq, Deserialize)] pub struct VideoInfoVideo { /// Video stream image height. - pub height: Option, + pub height: Option, /// Video stream image width. - pub width: Option, - /// Video stream frame rate. - pub frame_rate: Option, + pub width: Option, + /// Video stream frame rate. May be fractional (NTSC's `29.97`). + pub frame_rate: Option, /// Video stream bitrate. - pub bitrate: Option, + pub bitrate: Option, /// Video stream codec. pub codec: Option, } @@ -407,8 +416,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 @@ -566,17 +576,19 @@ fn encode_tags(tags: Option>) -> Option { } 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; + + 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, } - form.text( - "signature", - auth_fields.signature.as_ref().unwrap().to_string(), - ) - .text("expire", auth_fields.expire.as_ref().unwrap().to_string()) } #[cfg(test)] @@ -585,9 +597,94 @@ mod tests { #[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]"); - // any unicode letter is a valid key character, the brackets are all we add - assert_eq!(metadata_field("отдел"), "metadata[отдел]"); + 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_audio_channels_is_a_number() { + 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] diff --git a/src/webhook.rs b/src/webhook.rs index cb19731..53fbe41 100644 --- a/src/webhook.rs +++ b/src/webhook.rs @@ -28,7 +28,7 @@ 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 } } @@ -44,10 +44,15 @@ impl Service<'_> { /// 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, @@ -69,9 +74,6 @@ impl Service<'_> { /// 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); } @@ -79,7 +81,7 @@ impl Service<'_> { self.client.call::, Info>( Method::POST, - format!("/webhooks/"), + "/webhooks/".to_string(), None, Some(json), ) @@ -120,19 +122,12 @@ impl Service<'_> { // 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` - let res = self.client.call::, String>( + 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(()) + ) } } @@ -206,9 +201,12 @@ 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. /// @@ -274,7 +272,9 @@ pub enum Event { /// 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")] @@ -285,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")] @@ -452,9 +454,30 @@ mod tests { 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!( - value, - serde_json::json!({"id": 1387, "signing_secret": null, "is_active": true}), + 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 b4d3d2a..48d1637 100644 --- a/tests/rest.rs +++ b/tests/rest.rs @@ -3,7 +3,7 @@ use rand::Rng; -use ucare::{self, conversion, file, group, project, webhook}; +use ucare::{self, addons, conversion, file, group, project, webhook}; mod testenv; @@ -62,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(); @@ -74,7 +109,8 @@ fn file() { 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, }; @@ -187,9 +223,21 @@ fn conversion() { 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 { @@ -203,6 +251,50 @@ 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"; @@ -211,17 +303,14 @@ fn webhook() { 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 + // 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 suff: u32 = rng.gen(); let target_url = format!("https://example.com/test_endpoint{}", suff); let create_params = webhook::CreateParams { event: webhook::Event::FileInfoUpdated, @@ -232,12 +321,16 @@ fn webhook() { }; let hook = webhook_svc.create(create_params).unwrap(); assert!(hook.is_active); - assert!(hook.created.len() > 0); - assert!(hook.updated.len() > 0); + 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); @@ -268,7 +361,8 @@ fn webhook() { assert!(!hook.is_active); assert_eq!(hook.signing_secret, Some(new_sign_secret.to_string())); - // re-enabling a disabled subscription + // 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, @@ -279,11 +373,11 @@ fn webhook() { }) .unwrap(); assert!(hook.is_active); + assert_eq!(hook.signing_secret, Some(new_sign_secret.to_string())); // 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] 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 f2c5232..b81a8f9 100644 --- a/tests/upload.rs +++ b/tests/upload.rs @@ -72,12 +72,10 @@ fn from_url() { 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) => { From a60102b698a47bdbbf973d61cdd2b4213d0a67d7 Mon Sep 17 00:00:00 2001 From: Aleksandr Nikolaev Date: Fri, 7 Aug 2026 14:42:19 +0500 Subject: [PATCH 14/19] UCCORE-1790: parse channels as str/int --- src/types.rs | 56 +++++++++++++++++++++++++++++++++++++++++++++--- src/ucare/mod.rs | 37 +++++++++++++++++++++++++++++++- src/upload.rs | 43 +++++++++++++++++++++++++++++++++++-- 3 files changed, 130 insertions(+), 6 deletions(-) diff --git a/src/types.rs b/src/types.rs index b3a572b..f29bdac 100644 --- a/src/types.rs +++ b/src/types.rs @@ -38,9 +38,8 @@ pub struct MimeInfo { /// 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, -/// `duration` and `bitrate` are nullable, and audio channels are a number -/// rather than a string. +/// 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). @@ -79,6 +78,11 @@ pub struct VideoStream { #[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_int_or_string")] pub channels: Option, /// Audio stream bitrate. pub bitrate: Option, @@ -156,3 +160,49 @@ pub enum ColorMode { /// 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/mod.rs b/src/ucare/mod.rs index afe7c57..458607c 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}; @@ -64,6 +64,41 @@ pub(crate) fn encode_query_value(value: &str) -> String { url::form_urlencoded::byte_serialize(value.as_bytes()).collect() } +/// Deserializes an optional integer that the API may send either as a json +/// number or as a string holding one. +/// +/// The media metadata is where this happens: the documented schema types the +/// audio channel count as an integer, the service answers with `"2"`. Accepting +/// both is the only option that does not make a real response fail to parse — +/// the alternative was a `String` field, which then broke for every response +/// that does follow the schema. +/// +/// `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_int_or_string<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + #[derive(serde::Deserialize)] + #[serde(untagged)] + enum IntOrString { + Int(i64), + Str(String), + } + + match Option::::deserialize(deserializer)? { + None => Ok(None), + Some(IntOrString::Int(value)) => Ok(Some(value)), + Some(IntOrString::Str(raw)) => raw.parse::().map(Some).map_err(|_| { + serde::de::Error::invalid_value( + serde::de::Unexpected::Str(&raw), + &"an integer, or a string holding one", + ) + }), + } +} + pub(crate) fn encode_url(base: &str, path: &str, params: Option) -> Result where T: IntoUrlQuery, diff --git a/src/upload.rs b/src/upload.rs index 7b75084..d4c8ddf 100644 --- a/src/upload.rs +++ b/src/upload.rs @@ -385,7 +385,8 @@ pub struct VideoInfoAudio { pub codec: Option, /// Audio stream sample rate. pub sample_rate: Option, - /// Audio stream number of channels, an integer per the documented schema. + /// Audio stream number of channels. + #[serde(default, deserialize_with = "crate::ucare::de_int_or_string")] pub channels: Option, } @@ -671,7 +672,7 @@ mod tests { } #[test] - fn video_info_audio_channels_is_a_number() { + fn video_info_parses_the_documented_shape() { let info: VideoInfo = serde_json::from_str( r#"{ "duration": 10000, @@ -687,6 +688,44 @@ mod tests { 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!( From c5c9c1a02c5d43b25a552fbc6f710cd138b69fb8 Mon Sep 17 00:00:00 2001 From: Aleksandr Nikolaev Date: Mon, 10 Aug 2026 12:13:11 +0500 Subject: [PATCH 15/19] UCCORE-1790: minor fix --- CHANGELOG.md | 2 +- src/file.rs | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34362cc..1ef0a25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -## Unreleased +## 0.4.0 (Aug 11, 2026) ### REST API v0.7: client core diff --git a/src/file.rs b/src/file.rs index 80aff48..3fd0aaf 100644 --- a/src/file.rs +++ b/src/file.rs @@ -217,9 +217,11 @@ impl Service<'_> { /// Replaces the whole set of file tags: `PUT /files/{uuid}/tags/`. /// - /// Up to 16 tags per file, up to 64 characters each. The API lowercases, - /// trims and deduplicates the values, so [`TagsUpdate::tags`] in the response - /// may differ from what was sent. + /// 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 }))?; From 724a2999ad53bf4d798c740b84be2bb1179c7793 Mon Sep 17 00:00:00 2001 From: Aleksandr Nikolaev Date: Mon, 10 Aug 2026 13:28:56 +0500 Subject: [PATCH 16/19] UCCORE-1790: self-review fixes --- CHANGELOG.md | 49 +++++++++-- src/addons.rs | 38 +++++++-- src/file.rs | 72 ++++++++++++++-- src/types.rs | 16 +++- src/ucare/mod.rs | 191 ++++++++++++++++++++++++++++++++++++++---- src/ucare/rest/mod.rs | 11 ++- src/upload.rs | 113 ++++++++++++++++++++----- 7 files changed, 424 insertions(+), 66 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ef0a25..22844dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,16 +9,20 @@ BREAKING CHANGES: * Error handling reworked: `4xx`/`5xx` responses map to `ErrValue` variants (new: `MethodNotAllowed`, `Conflict`, `ServerError`) instead of surfacing serde errors; non-JSON and empty error bodies are passed through as text. A missing or - malformed `Retry-After` header no longer panics. + malformed `Retry-After` header on a `429` no longer panics: it is reported as + `ErrValue::TooManyRequests(30)`, same as the Upload API client. A `0` there would + have told a caller sleeping for that long to retry immediately. IMPROVEMENTS: * Empty success bodies (`204` on the delete endpoints) are handled by the client itself; the `"EOF"` substring matching is gone from `webhook::delete` and `group::delete`. -* User supplied query values (`from` cursors, add-on `request_id`) are - percent-encoded; unset list parameters are no longer sent, the documented API - defaults apply. +* User supplied query values (`from` cursors, add-on `request_id`) and path + segments (add-on `application_id`) are percent-encoded, and `.`/`..` are rejected + outright — the url parser normalizes them, so such a value would silently change + the endpoint being called. Unset list parameters are no longer sent, the + documented API defaults apply. * `Warning` response headers (e.g. dropped metadata keys on `local_copy`) are logged. * The `Date` auth header is formatted with `%Y` instead of ISO week based `%G`, which produced invalid signatures around New Year. @@ -35,7 +39,15 @@ BREAKING CHANGES: Upload API) live in `ucare::types` and are re-exported from `file`. * `ListParams` uses the `Filter` enum for `removed`/`stored` and `Ordering` lost sorting by size (not supported by v0.7). `Filter::All` sends no parameter at all: - `all` is not a documented value. + `all` is not a documented value. For `stored` that is the same as the API default + (any storage state); for `removed` it is **not** a way to list removed and + existing files together — the API default `removed=false` applies and only + existing files come back. List them separately. +* **`limit` left as `None` no longer sends `1000`.** The parameter is omitted and + the documented API default of 100 applies, so a page holds 100 files instead of + 1000. Callers that read `list.results` without following `next` now see 10x fewer + files, and paginating callers make 10x more requests. Pass `limit: Some(1000)` to + keep the old page size. * `CopyParams`: `make_public` is a plain `Option` (the documented boolean), new `metadata` field (local copy), and `local_copy`/`remote_copy` no longer inject implicit `store`/`make_public` defaults — unset fields are not sent. @@ -52,9 +64,21 @@ FEATURES: * File metadata endpoints: `metadata`, `metadata_value`, `set_metadata_value`, `delete_metadata_value` (`GET /files/{uuid}/metadata/`, `GET`/`PUT`/`DELETE /files/{uuid}/metadata/{key}/`). Keys are validated client - side against the documented charset before they reach the URL. + side against the documented charset before they reach the URL, `.` and `..` + included: they pass the charset, but the url parser resolves + `/files/{uuid}/metadata/../` into `/files/{uuid}/`, which would turn a metadata + delete into a delete of the file. * `BatchInfo` exposes the response `status`. +IMPROVEMENTS: + +* `Info.metadata` accepts an explicit `null` as an empty map. REST v0.7 always + answers with an object, but a webhook delivery does not, and the struct is close + enough to a delivery payload to be pointed at one. +* `content_info` durations and bitrates are documented as integers but derived from + ffprobe: a fractional value is now rounded instead of failing deserialization of + the whole file object, the same leniency `channels` already had. + ### Conversion: REST API v0.7 BREAKING CHANGES: @@ -87,7 +111,10 @@ FIXES: `aws_rekognition_detect_moderation_labels` and `remove_bg`, with typed per-application params. A transient status poll failure does not lose the `request_id` of a running job: it is reported as `Outcome::PollFailed` after - several consecutive failures. + several consecutive failures. `Outcome::Unknown` is likewise reported only after + several consecutive `unknown` statuses — the status of a just accepted job is + eventually consistent, and a re-run is not idempotent (for `remove_bg` it means + another billable file). ### Groups: REST API v0.7 @@ -177,7 +204,13 @@ IMPROVEMENTS: * Tags are passed through as given rather than normalized locally: the API lowercases, trims and deduplicates them, so what comes back may differ from what was sent. - An empty tag vector sends no field at all, same as `None`. + An empty tag vector sends no field at all, same as `None`. A tag holding a `,` is + rejected with `ErrValue::BadRequest`: the upload form has no escape for the + separator, so such a value would silently arrive as two tags while the same one + sent through `file::Service::set_tags` stays a single tag. +* `FileInfo.metadata` accepts an explicit `null` as an empty map, and the video + durations and bitrates accept a fractional value (rounded) as well as the + documented integer. ### Webhooks: REST API v0.7 diff --git a/src/addons.rs b/src/addons.rs index fb5cc65..e8072e8 100644 --- a/src/addons.rs +++ b/src/addons.rs @@ -25,7 +25,9 @@ use std::time::{Duration, Instant}; use reqwest::Method; use serde::{Deserialize, Serialize}; -use crate::ucare::{encode_json, encode_query_value, rest::Client, Error, Result}; +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); @@ -33,6 +35,13 @@ const FIRST_POLL_DELAY: Duration = Duration::from_secs(1); 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> { @@ -73,7 +82,10 @@ impl Service<'_> { self.client.call::, Execution>( Method::POST, - format!("/addons/{}/execute/", application_id), + // 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), ) @@ -87,7 +99,10 @@ impl Service<'_> { pub fn status(&self, application_id: &str, request_id: &str) -> Result { self.client.call::( Method::GET, - format!("/addons/{}/execute/status/", application_id), + 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))), @@ -152,6 +167,7 @@ impl Service<'_> { 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 @@ -198,11 +214,15 @@ impl Service<'_> { }) } Status::Unknown => { - return Ok(Outcome::Unknown { - request_id: request_id.to_string(), - }) + // 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 => (), + Status::InProgress => unknown_polls = 0, } delay = min(delay * 3 / 2, MAX_POLL_DELAY); @@ -325,6 +345,10 @@ pub enum Outcome { 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, diff --git a/src/file.rs b/src/file.rs index 3fd0aaf..47bfb8b 100644 --- a/src/file.rs +++ b/src/file.rs @@ -15,7 +15,8 @@ use serde_json; pub use crate::types::{AudioStream, ContentInfo, MimeInfo, VideoInfo, VideoStream}; use crate::ucare::{ - encode_json, encode_query_value, rest::Client, ErrValue, Error, IntoUrlQuery, Result, + encode_json, encode_query_value, is_dot_segment, rest::Client, ErrValue, Error, IntoUrlQuery, + Result, }; /// Service is used to make calls to file API. @@ -307,9 +308,15 @@ impl Service<'_> { /// 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, '_' | '-' | '.' | ':')); @@ -318,7 +325,8 @@ fn validate_metadata_key(key: &str) -> Result<()> { Ok(()) } else { Err(Error::with_value(ErrValue::BadRequest(format!( - "invalid metadata key {:?}: up to 64 characters of a-z, A-Z, 0-9, `_-.:`", + "invalid metadata key {:?}: up to 64 characters of a-z, A-Z, 0-9, `_-.:`, \ + and neither `.` nor `..`", key, )))) } @@ -368,8 +376,10 @@ pub struct Info { /// 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`. - #[serde(default)] + /// 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. @@ -405,9 +415,13 @@ pub struct AppDataEntry { /// Holds all possible params for for the list method pub struct ListParams { - /// Set to `Filter::True` to only include removed files in the response, - /// `Filter::False` to only include existing ones and `Filter::All` to include - /// both. Defaults to `Filter::False`. + /// 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 @@ -1240,6 +1254,50 @@ mod tests { 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( diff --git a/src/types.rs b/src/types.rs index f29bdac..3204484 100644 --- a/src/types.rs +++ b/src/types.rs @@ -45,8 +45,14 @@ 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. + /// 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)] @@ -68,7 +74,8 @@ pub struct VideoStream { /// 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. + /// 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, @@ -82,9 +89,10 @@ pub struct AudioStream { /// 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_int_or_string")] + #[serde(default, deserialize_with = "crate::ucare::de_lenient_int")] pub channels: Option, - /// Audio stream bitrate. + /// 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, diff --git a/src/ucare/mod.rs b/src/ucare/mod.rs index 458607c..eed2ab8 100644 --- a/src/ucare/mod.rs +++ b/src/ucare/mod.rs @@ -64,41 +64,114 @@ pub(crate) fn encode_query_value(value: &str) -> String { url::form_urlencoded::byte_serialize(value.as_bytes()).collect() } -/// Deserializes an optional integer that the API may send either as a json -/// number or as a string holding one. +/// 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 media metadata is where this happens: the documented schema types the -/// audio channel count as an integer, the service answers with `"2"`. Accepting -/// both is the only option that does not make a real response fail to parse — -/// the alternative was a `String` field, which then broke for every response -/// that does follow the schema. +/// 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_int_or_string<'de, D>(deserializer: D) -> Result, D::Error> +pub(crate) fn de_lenient_int<'de, D>(deserializer: D) -> Result, D::Error> where D: serde::Deserializer<'de>, { #[derive(serde::Deserialize)] #[serde(untagged)] - enum IntOrString { + enum LenientInt { Int(i64), + Float(f64), Str(String), } - match Option::::deserialize(deserializer)? { + const EXPECTED: &str = "an integer, a number, or a string holding one"; + + match Option::::deserialize(deserializer)? { None => Ok(None), - Some(IntOrString::Int(value)) => Ok(Some(value)), - Some(IntOrString::Str(raw)) => raw.parse::().map(Some).map_err(|_| { - serde::de::Error::invalid_value( - serde::de::Unexpected::Str(&raw), - &"an integer, or a string holding one", - ) + 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, @@ -114,3 +187,89 @@ where 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/mod.rs b/src/ucare/rest/mod.rs index 85d8d36..3e56254 100644 --- a/src/ucare/rest/mod.rs +++ b/src/ucare/rest/mod.rs @@ -18,6 +18,10 @@ 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. /// @@ -189,13 +193,16 @@ impl Client { ))), StatusCode::TOO_MANY_REQUESTS => { // the header is expected here, but a missing or malformed one - // is not worth a panic + // (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()) - .unwrap_or(0); + .filter(|secs| *secs > 0) + .unwrap_or(DEFAULT_RETRY_AFTER_SECS); Err(Error::with_value(ErrValue::TooManyRequests(retry_after))) } status if status.is_server_error() => Err(Error::with_value(ErrValue::ServerError( diff --git a/src/upload.rs b/src/upload.rs index d4c8ddf..25fb7fd 100644 --- a/src/upload.rs +++ b/src/upload.rs @@ -22,7 +22,7 @@ use reqwest::{blocking::multipart::Form, Method, Url}; use serde::Deserialize; use crate::types::ImageInfo; -use crate::ucare::{upload::Client, upload::Fields, upload::Payload, Result}; +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> { @@ -42,7 +42,7 @@ impl Service<'_> { 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_metadata_tags(form, params.metadata, params.tags)?; form = add_signature_expire(&(*self.client.auth_fields)(), form); self.client.call::>( @@ -69,7 +69,7 @@ impl Service<'_> { form = form.text("save_URL_duplicates", val.to_string()); } // this endpoint takes metadata but no tags - form = add_metadata_tags(form, params.metadata, None); + form = add_metadata_tags(form, params.metadata, None)?; form = add_signature_expire(&(*self.client.auth_fields)(), form); self.client.call::( @@ -161,7 +161,7 @@ impl Service<'_> { 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_metadata_tags(form, params.metadata, params.tags)?; form = add_signature_expire(&(*self.client.auth_fields)(), form); self.client.call::( @@ -353,7 +353,11 @@ pub struct FileInfo { /// 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. - #[serde(default)] + /// + /// 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, } @@ -365,10 +369,16 @@ pub struct FileInfo { #[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. + /// Video bitrate. Same leniency as `duration`. + #[serde(default, deserialize_with = "crate::ucare::de_lenient_int")] pub bitrate: Option, /// Audio information pub audio: Option, @@ -379,14 +389,15 @@ pub struct VideoInfo { /// Information about the audio in video #[derive(Debug, PartialEq, Deserialize)] pub struct VideoInfoAudio { - /// Audio stream metadata. + /// 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_int_or_string")] + #[serde(default, deserialize_with = "crate::ucare::de_lenient_int")] pub channels: Option, } @@ -399,7 +410,8 @@ pub struct VideoInfoVideo { pub width: Option, /// Video stream frame rate. May be fractional (NTSC's `29.97`). pub frame_rate: Option, - /// Video stream bitrate. + /// 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, @@ -550,15 +562,15 @@ fn add_metadata_tags( mut form: Form, metadata: HashMap, tags: Option>, -) -> Form { +) -> Result
{ for (key, value) in metadata { form = form.text(metadata_field(key.as_str()), value); } - if let Some(value) = encode_tags(tags) { + if let Some(value) = encode_tags(tags)? { form = form.text("tags", value); } - form + Ok(form) } /// Builds the form field name for a metadata key. @@ -568,12 +580,27 @@ fn metadata_field(key: &str) -> String { /// Encodes tags the way the API expects them: one comma separated field rather than /// a repeated one. `None` when there is nothing to send. -fn encode_tags(tags: Option>) -> Option { - match tags { - None => None, - Some(tags) if tags.is_empty() => None, - Some(tags) => Some(tags.join(",")), +/// +/// 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 { @@ -729,19 +756,19 @@ mod tests { #[test] fn tags_are_comma_separated() { assert_eq!( - encode_tags(Some(vec!["invoice".to_string(), "2026".to_string()])), + 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()])), + 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), None); - assert_eq!(encode_tags(Some(vec![])), None); + assert_eq!(encode_tags(None).unwrap(), None); + assert_eq!(encode_tags(Some(vec![])).unwrap(), None); } #[test] @@ -749,11 +776,53 @@ mod tests { // 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()])), + 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(); From 626756c6d1d30b3102f0840064e74e34d4e42aa4 Mon Sep 17 00:00:00 2001 From: Aleksandr Nikolaev Date: Mon, 10 Aug 2026 13:38:59 +0500 Subject: [PATCH 17/19] UCCORE-1790: support tags on from-url upload --- CHANGELOG.md | 10 ++++++---- src/upload.rs | 23 +++++++++++++++++++---- tests/upload.rs | 1 + 3 files changed, 26 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 22844dc..cebb022 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -186,14 +186,16 @@ BREAKING CHANGES: them, but an exhaustive struct literal has to be updated. * `upload::MultipartParams` has three new fields: `part_size: Option`, plus the same `metadata` and `tags`. -* `upload::FromUrlParams` has a new `metadata` field. This endpoint takes metadata but - no tags. +* `upload::FromUrlParams` has two new fields, `metadata: HashMap` and + `tags: Option>`. FEATURES: * `POST /base/`, `POST /multipart/start/` and `POST /from_url/` now send file metadata - as `metadata[key]` form fields. The first two also send tags, as a single comma - separated `tags` field. + as `metadata[key]` form fields and tags as a single comma separated `tags` field. + Tags passed to `from_url` land on the file the fetch produces, so they are readable + once the upload has finished — through `from_url_status`, or right away in the + `FromUrlData::FileInfo` answer of a `check_URL_duplicates` hit. * `POST /multipart/start/` accepts `part_size`. Left to the API default of 5 MiB when `None`; worth raising for files over a gigabyte, otherwise the number of presigned part urls in the response grows into the thousands. Note that whatever is passed diff --git a/src/upload.rs b/src/upload.rs index 25fb7fd..72e4612 100644 --- a/src/upload.rs +++ b/src/upload.rs @@ -68,8 +68,7 @@ impl Service<'_> { if let Some(val) = params.save_url_duplicates { form = form.text("save_URL_duplicates", val.to_string()); } - // this endpoint takes metadata but no tags - form = add_metadata_tags(form, params.metadata, None)?; + form = add_metadata_tags(form, params.metadata, params.tags)?; form = add_signature_expire(&(*self.client.auth_fields)(), form); self.client.call::( @@ -224,6 +223,10 @@ pub struct FileParams { /// 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>, } @@ -246,9 +249,13 @@ pub struct FromUrlParams { pub save_url_duplicates: Option, /// Arbitrary metadata to attach to the file, sent as `metadata[key]` fields. /// See [`FileParams::metadata`]. - /// - /// Unlike the direct and the multipart upload, this endpoint takes no tags. 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` @@ -831,6 +838,14 @@ mod tests { 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(); diff --git a/tests/upload.rs b/tests/upload.rs index b81a8f9..d9bcb83 100644 --- a/tests/upload.rs +++ b/tests/upload.rs @@ -68,6 +68,7 @@ fn from_url() { 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 { From 4f8a391470e33a8e3ebd5365977be6ca802a692a Mon Sep 17 00:00:00 2001 From: Aleksandr Nikolaev Date: Wed, 12 Aug 2026 10:12:10 +0500 Subject: [PATCH 18/19] UCCORE-1790: update version --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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" From 8658e011fdd9f458814f605d97435e737cb65d7a Mon Sep 17 00:00:00 2001 From: Aleksandr Nikolaev Date: Mon, 17 Aug 2026 10:31:28 +0500 Subject: [PATCH 19/19] UCCORE-1790: brief changelog --- CHANGELOG.md | 265 ++------------------------------------------------- 1 file changed, 6 insertions(+), 259 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cebb022..15bb106 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,271 +1,18 @@ ## 0.4.0 (Aug 11, 2026) -### REST API v0.7: client core - -BREAKING CHANGES: - -* **`RestApiVersion::V05` and `V06` are gone**, the client speaks v0.7 only. The enum - is `#[non_exhaustive]` from now on. -* Error handling reworked: `4xx`/`5xx` responses map to `ErrValue` variants - (new: `MethodNotAllowed`, `Conflict`, `ServerError`) instead of surfacing serde - errors; non-JSON and empty error bodies are passed through as text. A missing or - malformed `Retry-After` header on a `429` no longer panics: it is reported as - `ErrValue::TooManyRequests(30)`, same as the Upload API client. A `0` there would - have told a caller sleeping for that long to retry immediately. - -IMPROVEMENTS: - -* Empty success bodies (`204` on the delete endpoints) are handled by the client - itself; the `"EOF"` substring matching is gone from `webhook::delete` and - `group::delete`. -* User supplied query values (`from` cursors, add-on `request_id`) and path - segments (add-on `application_id`) are percent-encoded, and `.`/`..` are rejected - outright — the url parser normalizes them, so such a value would silently change - the endpoint being called. Unset list parameters are no longer sent, the - documented API defaults apply. -* `Warning` response headers (e.g. dropped metadata keys on `local_copy`) are logged. -* The `Date` auth header is formatted with `%Y` instead of ISO week based `%G`, - which produced invalid signatures around New Year. -* The version in `X-UC-User-Agent` is taken from the crate manifest. - -### Files: REST API v0.7 - -BREAKING CHANGES: - -* `file::Service::info` takes an `include: Option` argument (`appdata`). -* `file::Info`: `datetime_stored`/`datetime_removed` semantics per v0.7; new - `content_info`, `metadata`, `tags`, `appdata` fields; `size` is `i64`; - the v0.6-only `source` field is gone. `content_info` types (shared with the - Upload API) live in `ucare::types` and are re-exported from `file`. -* `ListParams` uses the `Filter` enum for `removed`/`stored` and `Ordering` lost - sorting by size (not supported by v0.7). `Filter::All` sends no parameter at all: - `all` is not a documented value. For `stored` that is the same as the API default - (any storage state); for `removed` it is **not** a way to list removed and - existing files together — the API default `removed=false` applies and only - existing files come back. List them separately. -* **`limit` left as `None` no longer sends `1000`.** The parameter is omitted and - the documented API default of 100 applies, so a page holds 100 files instead of - 1000. Callers that read `list.results` without following `next` now see 10x fewer - files, and paginating callers make 10x more requests. Pass `limit: Some(1000)` to - keep the old page size. -* `CopyParams`: `make_public` is a plain `Option` (the documented boolean), - new `metadata` field (local copy), and `local_copy`/`remote_copy` no longer - inject implicit `store`/`make_public` defaults — unset fields are not sent. - `Pattern::AutoFilename` serializes to the documented `${auto_filename}`. -* `VideoStream::frame_rate` is `f64`: NTSC-style fractional rates (`29.97`) are - common and used to fail deserialization of the whole response. - -FEATURES: - -* `POST /files/search/` with typed criteria (`SearchQuery`), pagination and - highlights. -* File tags endpoints: `tags`, `set_tags`, `update_tags` - (`GET`/`PUT`/`PATCH /files/{uuid}/tags/`). -* File metadata endpoints: `metadata`, `metadata_value`, `set_metadata_value`, - `delete_metadata_value` (`GET /files/{uuid}/metadata/`, - `GET`/`PUT`/`DELETE /files/{uuid}/metadata/{key}/`). Keys are validated client - side against the documented charset before they reach the URL, `.` and `..` - included: they pass the charset, but the url parser resolves - `/files/{uuid}/metadata/../` into `/files/{uuid}/`, which would turn a metadata - delete into a delete of the file. -* `BatchInfo` exposes the response `status`. - -IMPROVEMENTS: - -* `Info.metadata` accepts an explicit `null` as an empty map. REST v0.7 always - answers with an object, but a webhook delivery does not, and the struct is close - enough to a delivery payload to be pointed at one. -* `content_info` durations and bitrates are documented as integers but derived from - ffprobe: a fractional value is now rounded instead of failing deserialization of - the whole file object, the same leniency `channels` already had. - -### Conversion: REST API v0.7 - -BREAKING CHANGES: - -* `JobInfo.thumbnails_group_id` renamed to `thumbnails_group_uuid` — the old field - name never matched the API and always deserialized to `None`. -* `StatusResult.result` is `Option`: a `failed` job carries no result and - used to make the whole status call fail to parse. -* `JobParams` has a new `save_in_group` field (document conversion only); `store` - and `save_in_group` are omitted from the request when unset. - -FEATURES: - -* `document_info` (`GET /convert/document/{uuid}/`): source format, possible - conversions and already converted groups. The docs contradict themselves on - where `converted_groups` lives (top level vs nested in `format`), so both - placements are accepted; `DocumentInfo::any_converted_groups` picks whichever - is present. - -FIXES: - -* `POST /convert/video/` uses the trailing slash — without it the API redirects, - and a redirected POST loses its body. -* `video_status` uses `GET` and the correct path; conversion job tokens are `i64`. - -### Add-Ons: new module (REST API v0.7) - -* `addons::Service`: `execute`, `status`, `execute_and_wait`/`wait` for - `uc_clamav_virus_scan`, `aws_rekognition_detect_labels`, - `aws_rekognition_detect_moderation_labels` and `remove_bg`, with typed - per-application params. A transient status poll failure does not lose the - `request_id` of a running job: it is reported as `Outcome::PollFailed` after - several consecutive failures. `Outcome::Unknown` is likewise reported only after - several consecutive `unknown` statuses — the status of a just accepted job is - eventually consistent, and a re-run is not idempotent (for `remove_bg` it means - another billable file). - -### Groups: REST API v0.7 - -BREAKING CHANGES: - -* `group::Service::store` is gone: v0.7 removed `PUT /groups/{uuid}/storage/` - together with the group `datetime_stored` field. -* `group::Info::datetime_created` is a plain `String` (documented as required). - -FEATURES: - -* `group::Service::delete` (`DELETE /groups/{uuid}/`), new in v0.7. -* `group::Info` carries `files` (with `None` placeholders for removed files) and - `url` — previously the primary payload of the info endpoint was dropped. - -### Project - -* `project::Info` exposes the documented `autostore_enabled` field. - -### Webhooks: partial update fixes - -* `UpdateParams.signing_secret` is only sent when set. It used to be serialized as - `null` on every update, which contradicted the documented partial-update - semantics and risked clearing the stored secret. -* `UpdateParams.id` is no longer serialized into the request body (it is a path - parameter). -* `CreateParams` no longer sends `signing_secret: null`/`is_active: null` for - unset fields and no longer forces `is_active: true` client side — the API - default (active) applies. - -### Upload API: response schemas - -BREAKING CHANGES: - -* **`FromUrlData` is now tagged by the response `type` field.** The previous - `untagged` representation could never produce the `FileInfo` variant — a - `check_URL_duplicates` hit was silently mis-parsed as a token-less `Token`. - `FileToken.token` is a plain `String` and the `data_type` field is gone (it - duplicated the tag). -* `FromUrlStatusData::Progress.total` is `Option` (documented as nullable) and - `FromUrlStatusData::Error` carries the documented `error_code`. -* `VideoInfo`/`VideoInfoAudio`/`VideoInfoVideo` numeric fields are integers per the - documented schema (`frame_rate` stays fractional); **`channels` is `Option`** - — it was typed as a string and broke deserialization of any video with sound. -* `GroupInfo.files` is `Option>>`: the array holds `null` for - removed files. - -FEATURES: - -* `upload::FileInfo` exposes `content_info` and `metadata`, so what is sent on - upload can also be read back from upload responses. - -### Upload API: request parameters - -BREAKING CHANGES: - -* **`to_store` left as `None` no longer sends `0`.** All three upload methods used to - substitute `ToStore::False` for a missing value, which made every upload temporary - regardless of the project settings. The field is now omitted from the request and - the API applies its own default — `auto` for projects registered after - February 12, 2024 and `0` for the older ones. Code that relied on the implicit - "temporary unless asked otherwise" has to pass `Some(ToStore::False)` explicitly. -* **Byte counters widened from `u32` to `u64`.** `u32` caps at 4 GiB, which multipart - upload exists to exceed. Affects `MultipartParams::size`, `FileInfo::size`, - `FileInfo::total`, `FileInfo::done` and the `done` / `total` of - `FromUrlStatusData::Progress`. -* `upload::FileParams` has two new fields, `metadata: HashMap` and - `tags: Option>`. Both are `Default`, so `..Default::default()` covers - them, but an exhaustive struct literal has to be updated. -* `upload::MultipartParams` has three new fields: `part_size: Option`, plus the - same `metadata` and `tags`. -* `upload::FromUrlParams` has two new fields, `metadata: HashMap` and - `tags: Option>`. - -FEATURES: - -* `POST /base/`, `POST /multipart/start/` and `POST /from_url/` now send file metadata - as `metadata[key]` form fields and tags as a single comma separated `tags` field. - Tags passed to `from_url` land on the file the fetch produces, so they are readable - once the upload has finished — through `from_url_status`, or right away in the - `FromUrlData::FileInfo` answer of a `check_URL_duplicates` hit. -* `POST /multipart/start/` accepts `part_size`. Left to the API default of 5 MiB when - `None`; worth raising for files over a gigabyte, otherwise the number of presigned - part urls in the response grows into the thousands. Note that whatever is passed - here decides how the caller has to slice the file — `upload_part` expects every - part but the last to be exactly that size. - IMPROVEMENTS: - -* Tags are passed through as given rather than normalized locally: the API lowercases, - trims and deduplicates them, so what comes back may differ from what was sent. - An empty tag vector sends no field at all, same as `None`. A tag holding a `,` is - rejected with `ErrValue::BadRequest`: the upload form has no escape for the - separator, so such a value would silently arrive as two tags while the same one - sent through `file::Service::set_tags` stays a single tag. -* `FileInfo.metadata` accepts an explicit `null` as an empty map, and the video - durations and bitrates accept a fractional value (rounded) as well as the - documented integer. - -### Webhooks: REST API v0.7 +* 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: -* **Subscriptions are now created with version `0.7` instead of `0.6`.** This is the - consequence of raising the `Accept` header, and it changes behaviour for existing - users even though their code does not change: a `0.7` subscription delivers a - **different payload format** to `target_url` than a `0.6` one did. Receivers written +* `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. -* `CreateParams` has a new required field, `version: Option`. Leave it `None` - to get `Version::V07`; it is always sent explicitly so that the created subscription - does not silently depend on which API version the crate happens to speak. Note that - creating a `0.6` subscription is no longer possible at all: APIv0.7 answers - `400 Invalid version`, and this crate only speaks v0.7. -* `Info.signing_secret` changed from `String` to `Option`. The API documents - the field as nullable, so the old type failed to deserialize a subscription without - a secret. -* `Info.event` stays a `String` rather than becoming the `Event` enum, deliberately: - reading existing subscriptions can turn up an event a given release does not know - about, and that must not break deserialization. -* `Event` and the new `Version` enum are `#[non_exhaustive]`. - -FEATURES: - -* Four new events, all of which require a `0.7` subscription: `Event::FileInfected`, - `Event::FileStored`, `Event::FileDeleted`, `Event::FileInfoUpdated`. -* `Info.version` exposes the subscription version. It is fixed at creation and can - never be changed — passing a version to `update` is rejected by the API, which is - why `UpdateParams` has no such field. Changing a version means deleting the - subscription and creating it again. -* `webhook::Service::get(id)` reads a single subscription, `GET /webhooks/{id}/`. - -IMPROVEMENTS: - -* `Info` no longer derives `Debug`; it implements it manually with `signing_secret` - masked, so the secret does not reach logs through debug output. Code reading the - field directly still has to mask it itself. -* Documented the nuances that are easy to get wrong: - * `delete` removes **every** subscription pointing at the given `target_url`, for - all events at once. It is not a way to unsubscribe from one event. - * `target_url` must resolve to a non private address, so local endpoints cannot be - used for debugging — the integration test now uses a public host for that reason. - * `403` depends on the particular `target_url`, not only on the project, so it must - not be cached as a per project "webhooks unavailable" flag. - * `file.info_updated` is not published when nothing actually changed, so it is not a - confirmation that a write happened; and it also fires for background processing - that was never requested by the caller. - * the delivery payload format is defined by the delivery layer, not by the schemas - of this API — do not reuse `file::Info` for it. In particular `metadata` may be - `null` in a delivery, while REST API v0.7 always returns an object. +* Schema changes (review old contracts on upgrade) ## 0.3.1 (Apr 16, 2026)