From 769ee07d69d09d59b2bf228f9cee18b0cf7554c5 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 22 Apr 2025 18:55:30 -0400 Subject: [PATCH] Storyteller Rust API client --- Cargo.lock | 33 +++ Cargo.toml | 5 +- .../api_clients/storyteller_client/Cargo.toml | 42 ++++ .../storyteller_client/src/api_error.rs | 81 +++++++ .../api_clients/storyteller_client/src/lib.rs | 4 + .../src/media_files/get_media_file.rs | 216 ++++++++++++++++++ .../storyteller_client/src/media_files/mod.rs | 1 + .../cover_image_links.rs | 15 ++ .../media_file_cover_image_details.rs | 40 ++++ .../src/shared_response_types/media_links.rs | 34 +++ .../src/shared_response_types/mod.rs | 5 + .../simple_entity_stats.rs | 10 + .../user_details_light.rs | 33 +++ .../storyteller_client/src/utils/api_host.rs | 17 ++ .../src/utils/filter_bad_response.rs | 33 +++ .../storyteller_client/src/utils/mod.rs | 3 + .../src/utils/status_codes.rs | 23 ++ crates/service/web/storyteller_web/Cargo.toml | 2 +- 18 files changed, 595 insertions(+), 2 deletions(-) create mode 100644 crates/lib/api_clients/storyteller_client/Cargo.toml create mode 100644 crates/lib/api_clients/storyteller_client/src/api_error.rs create mode 100644 crates/lib/api_clients/storyteller_client/src/lib.rs create mode 100644 crates/lib/api_clients/storyteller_client/src/media_files/get_media_file.rs create mode 100644 crates/lib/api_clients/storyteller_client/src/media_files/mod.rs create mode 100644 crates/lib/api_clients/storyteller_client/src/shared_response_types/cover_image_links.rs create mode 100644 crates/lib/api_clients/storyteller_client/src/shared_response_types/media_file_cover_image_details.rs create mode 100644 crates/lib/api_clients/storyteller_client/src/shared_response_types/media_links.rs create mode 100644 crates/lib/api_clients/storyteller_client/src/shared_response_types/mod.rs create mode 100644 crates/lib/api_clients/storyteller_client/src/shared_response_types/simple_entity_stats.rs create mode 100644 crates/lib/api_clients/storyteller_client/src/shared_response_types/user_details_light.rs create mode 100644 crates/lib/api_clients/storyteller_client/src/utils/api_host.rs create mode 100644 crates/lib/api_clients/storyteller_client/src/utils/filter_bad_response.rs create mode 100644 crates/lib/api_clients/storyteller_client/src/utils/mod.rs create mode 100644 crates/lib/api_clients/storyteller_client/src/utils/status_codes.rs diff --git a/Cargo.lock b/Cargo.lock index 8fb9384f34..35709fefaa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1079,6 +1079,19 @@ dependencies = [ "tower-service", ] +[[package]] +name = "backoff" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b62ddb9cb1ec0a098ad4bbf9344d0713fa193ae1a80af55febcff2627b6a00c1" +dependencies = [ + "futures-core", + "getrandom 0.2.14", + "instant", + "pin-project-lite", + "rand 0.8.5", +] + [[package]] name = "backtrace" version = "0.3.69" @@ -11232,6 +11245,26 @@ dependencies = [ "zip 0.6.6", ] +[[package]] +name = "storyteller_client" +version = "0.0.1" +dependencies = [ + "anyhow", + "backoff", + "chrono", + "enums", + "enums_public", + "errors", + "log", + "reqwest 0.12.15", + "serde", + "serde_derive", + "serde_json", + "tokens", + "url", + "workspace-build-acceleration", +] + [[package]] name = "storyteller_root" version = "0.0.1" diff --git a/Cargo.toml b/Cargo.toml index 1f8864af57..2637881c35 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,7 @@ members = [ "crates/lib/api_clients/google_drive_common", "crates/lib/api_clients/google_sign_in", "crates/lib/api_clients/openai_sora_client", + "crates/lib/api_clients/storyteller_client", "crates/lib/api_clients/thumbnail_generator", "crates/lib/bootstrap", "crates/lib/bucket_paths", @@ -167,10 +168,12 @@ serde_yaml = "0.9.1" sha3 = "0.10.8" thiserror = "2" toml = "0.7.4" -url = "2.5.4" +url = { version = "2.5.4", features = ["serde"] } # Internal libs crockford = { path = "crates/lib/data/crockford" } +enums = { path = "crates/_types/enums" } +enums_public = { path = "crates/_types/enums_public" } errors = { path = "crates/lib/errors" } filesys = { path = "crates/lib/files/filesys" } memory_caching = { path = "crates/lib/caching/memory_caching" } diff --git a/crates/lib/api_clients/storyteller_client/Cargo.toml b/crates/lib/api_clients/storyteller_client/Cargo.toml new file mode 100644 index 0000000000..cdd0d6d81f --- /dev/null +++ b/crates/lib/api_clients/storyteller_client/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "storyteller_client" +edition = "2021" +version = "0.0.1" +authors = [ + "Brandon Thomas ", + "Brandon Thomas ", +] +publish = false + +[lib] +name = "storyteller_client" +path = "src/lib.rs" + +[dependencies] +# Workspace hack (faster compile times) +workspace-build-acceleration = { workspace = true } + +# Internal +enums = { workspace = true } +enums_public = { workspace = true } +errors = { workspace = true } +tokens = { workspace = true } + +# External +anyhow = { workspace = true } +chrono = { version = "0.4.22", features = ["serde"] } +log = { workspace = true } +reqwest = { workspace = true, features = ["multipart", "gzip"] } +url = { workspace = true } + +# Serialization +serde = { workspace = true } +serde_derive = { workspace = true } +serde_json = { workspace = true } + +# HTTP +backoff = { version = "0.4.0", features = ["futures"] } # Exponential backoff retry +#reqwest = { version = "0.11", features = ["json", "cookies"] } # HTTP Client + +[dev-dependencies] +# None yet diff --git a/crates/lib/api_clients/storyteller_client/src/api_error.rs b/crates/lib/api_clients/storyteller_client/src/api_error.rs new file mode 100644 index 0000000000..8ab750c426 --- /dev/null +++ b/crates/lib/api_clients/storyteller_client/src/api_error.rs @@ -0,0 +1,81 @@ +use std::error::Error; +use std::fmt::{Display, Formatter}; +use errors::AnyhowError; + +#[derive(Debug)] +pub enum ApiError { + /// 400. The request was invalid. + InvalidRequest(String), + + /// 401. The request was not authorized. + Unauthorized(String), + + /// 403. The request was forbidden. + Forbidden(String), + + /// 404. The requested resource was not found. + NotFound(String), + + /// 429. Too many requests. + TooManyRequests(String), + + /// 500. An internal server error occurred. + InternalServerError(String), + + /// A deserialization error with the response. + DeserializationError(serde_json::Error), + + /// The request timed out. + Timeout(String), + + /// A network error occurred. + NetworkError(String), + + /// Uncategorized reqwest error. + OtherReqwestError(reqwest::Error), + + /// Another type of error. + Other(AnyhowError), +} + +impl Error for ApiError {} + +impl Display for ApiError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + // Server response code errors + ApiError::InvalidRequest(msg) => write!(f, "Invalid request: {}", msg), + ApiError::Unauthorized(msg) => write!(f, "Unauthorized: {}", msg), + ApiError::Forbidden(msg) => write!(f, "Forbidden: {}", msg), + ApiError::NotFound(msg) => write!(f, "Not found: {}", msg), + ApiError::TooManyRequests(msg) => write!(f, "Too many requests: {}", msg), + ApiError::InternalServerError(msg) => write!(f, "Internal server error: {}", msg), + // Server response handling errors + ApiError::DeserializationError(error) => write!(f, "Deserialization error: {}", error), + // Network errors + ApiError::Timeout(msg) => write!(f, "Timeout: {}", msg), + ApiError::NetworkError(msg) => write!(f, "Network error: {}", msg), + // Other + ApiError::OtherReqwestError(error) => write!(f, "Reqwest error: {}", error), + ApiError::Other(error) => write!(f, "Other error: {}", error), + } + } +} + +impl From for ApiError { + fn from(error: reqwest::Error) -> Self { + if error.is_timeout() { + ApiError::Timeout(error.to_string()) + } else if error.is_connect() { + ApiError::NetworkError(error.to_string()) + } else { + ApiError::OtherReqwestError(error) + } + } +} + +impl From for ApiError { + fn from(error: serde_json::Error) -> Self { + ApiError::DeserializationError(error) + } +} diff --git a/crates/lib/api_clients/storyteller_client/src/lib.rs b/crates/lib/api_clients/storyteller_client/src/lib.rs new file mode 100644 index 0000000000..9e97c1c209 --- /dev/null +++ b/crates/lib/api_clients/storyteller_client/src/lib.rs @@ -0,0 +1,4 @@ +pub mod media_files; +pub mod shared_response_types; +pub mod utils; +mod api_error; \ No newline at end of file diff --git a/crates/lib/api_clients/storyteller_client/src/media_files/get_media_file.rs b/crates/lib/api_clients/storyteller_client/src/media_files/get_media_file.rs new file mode 100644 index 0000000000..19c61e25c4 --- /dev/null +++ b/crates/lib/api_clients/storyteller_client/src/media_files/get_media_file.rs @@ -0,0 +1,216 @@ +use std::num::{NonZero, NonZeroU16}; +use chrono::{DateTime, Utc}; +use log::{debug, error}; +use reqwest::{Client, StatusCode}; +use serde_derive::{Deserialize}; +use enums::by_table::media_files::media_file_animation_type::MediaFileAnimationType; +use enums::by_table::media_files::media_file_class::MediaFileClass; +use enums::by_table::media_files::media_file_engine_category::MediaFileEngineCategory; +use enums::by_table::media_files::media_file_type::MediaFileType; +use enums::by_table::model_weights::weights_category::WeightsCategory; +use enums::common::visibility::Visibility; +use enums::no_table::style_transfer::style_transfer_name::StyleTransferName; +use enums_public::by_table::model_weights::public_weights_types::PublicWeightsType; +use tokens::tokens::batch_generations::BatchGenerationToken; +use tokens::tokens::media_files::MediaFileToken; +use tokens::tokens::model_weights::ModelWeightToken; +use tokens::tokens::prompts::PromptToken; +use crate::api_error::ApiError; +use crate::shared_response_types::media_file_cover_image_details::MediaFileCoverImageDetails; +use crate::shared_response_types::media_links::MediaLinks; +use crate::shared_response_types::simple_entity_stats::SimpleEntityStats; +use crate::shared_response_types::user_details_light::UserDetailsLight; +use crate::utils::api_host::ApiHost; +use crate::utils::filter_bad_response::filter_bad_response; +use crate::utils::status_codes::*; + +/// Get details about a media file from our backend +pub async fn get_media_file(api_host: &ApiHost, media_file_token: &MediaFileToken) -> Result { + let client = Client::builder() + .gzip(true) + .build()?; + + let url = get_media_file_token_route(api_host, media_file_token); + + debug!("Requesting {:?}", &url); + + let response = client.get(url) + .header("User-Agent", "storyteller-client/1.0") + .header("Accept", "*/*") + .header("Accept-Encoding", "gzip, deflate, br") + .send() + .await?; + + let response = filter_bad_response(response).await?; + let response_body = &response.text().await?; + let media_file = serde_json::from_str(&response_body)?; + + Ok(media_file) +} + +fn get_media_file_token_route(api_host: &ApiHost, media_file_token: &MediaFileToken) -> String { + let api_hostname = api_host.to_api_hostname(); + let media_file_token = media_file_token.as_str(); + format!("https://{}/v1/media_files/file/{}", api_hostname, media_file_token) +} + +// TODO(bt,2025-04-22): Share API definitions between client and server in common crate. + +#[derive(Deserialize)] +pub struct GetMediaFileSuccessResponse { + pub success: bool, + pub media_file: MediaFileInfo, +} + +#[derive(Deserialize)] +pub struct MediaFileInfo { + pub token: MediaFileToken, + + /// The coarse-grained class of media file: image, video, etc. + pub media_class: MediaFileClass, + + /// Type of media will dictate which fields are populated and what + /// the frontend should display (eg. video player vs audio player). + /// This is closer in meaning to a "mime type". + pub media_type: MediaFileType, + + /// If this is an engine/3D asset, this is the broad category (scene, + /// animation, etc.) of that object. + /// This can also be used for filtering in list/batch endpoints. + pub maybe_engine_category: Option, + + /// If this is an engine/3D asset for an animation or a rig that can + /// be animated with either (or both) skeletal or blend shape animations, + /// this describes the animation regime used or supported. + pub maybe_animation_type: Option, + + // /// If the media file has a subtype, we'll report it. + // /// This is mostly used for Bevy engine files. + // #[deprecated(note="This was for the Bevy engine. Do not use.")] + // pub maybe_media_subtype: Option, + + // /// Extension for the engine to load over remote:// URLs. + // #[deprecated(note="This was for the Bevy engine. Do not use.")] + // pub maybe_engine_extension: Option, + + /// If the file was generated as part of a batch, this is the token for the batch. + pub maybe_batch_token: Option, + + /// If this media file was generated by a scene from the engine, this is a + /// link to that file. + pub maybe_scene_source_media_file_token: Option, + + // /// (DEPRECATED) URL path to the media file + // #[deprecated(note="This field doesn't point to the full URL. Use media_links instead to leverage the CDN.")] + // pub public_bucket_path: String, + + // /// (DEPRECATED) Full URL to the media file + // #[deprecated(note="This points to the bucket. Use media_links instead to leverage the CDN.")] + // pub public_bucket_url: Url, + + /// Rich CDN links to the media, including thumbnails, previews, and more. + pub media_links: MediaLinks, + + /// Information about the cover image. Many media files do not require a cover image, + /// e.g. image files, video files with thumbnails, audio files, etc. + /// 3D files require them. + pub cover_image: MediaFileCoverImageDetails, + + //// Provenance Data + //pub origin_product: MediaFileOriginProductCategory, + //pub origin_category: MediaFileOriginCategory, + //pub maybe_origin_model_type: Option, + //pub maybe_origin_model_token: Option, + //pub maybe_origin_filename: Option, + + /// Possible model info (if generated by inference) + pub maybe_model_weight_info: Option, + + /// User info + pub maybe_creator_user: Option, + + pub creator_set_visibility: Visibility, + + /// The file was uploaded by the user. + /// This does not include files generated on the client side, like studio renders. + pub is_user_upload: bool, + + /// The file was created by the system. + /// This includes files generated on the client side, like studio renders. + pub is_intermediate_system_file: bool, + + /// The name or title of the media file (optional) + pub maybe_title: Option, + + /// Text transcripts for TTS, etc. + pub maybe_text_transcript: Option, + + // /// OPTIONAL. Details on live portrait generated media files, + // /// if this is a live portrait generated file. + // pub maybe_live_portrait_details: Option, + + /// For Comfy / Video Style Transfer jobs, this might include + /// the name of the selected style. + pub maybe_style_name: Option, + + /// For Comfy / Video Style Transfer jobs, this indicates use of face detailer. + pub used_face_detailer: bool, + + /// For Comfy / Video Style Transfer jobs, this indicates use of upscaling. + pub used_upscaler: bool, + + /// The foreign key to the prompt used to generate the media, if applicable. + pub maybe_prompt_token: Option, + + /// The original filename for uploaded files, if they were provided. + /// In the future we'll provide our own internal optional filenames. + pub maybe_original_filename: Option, + + /// Duration for audio and video files, if available. + /// Measured in milliseconds. + pub maybe_duration_millis: Option, + + /// We can simulate media files for "tts_results" records. + /// If this is set as true, this informs the frontend and API callers not to treat + /// the token as a media file and improperly assume it can be used with the rest of + /// the media file APIs. + pub is_emulated_media_file: bool, + + /// Whether the media file is featured by staff (the featured_items API) or not. + pub is_featured: bool, + + /// Statistics about the media file + pub stats: SimpleEntityStats, + + pub created_at: DateTime, + pub updated_at: DateTime, + + pub maybe_moderator_fields: Option, +} + +#[derive(Deserialize)] +pub struct GetMediaFileModeratorFields { + /// This may become public in the future. + pub maybe_style_transfer_source_media_file_token: Option, + //pub creator_ip_address: String, + //pub creator_is_banned_if_user: bool, + //pub maybe_creator_deleted_at: Option>, + //pub maybe_mod_deleted_at: Option>, +} + +#[derive(Deserialize)] +pub struct GetMediaFileModelInfo { + pub weight_token: ModelWeightToken, + pub weight_type: PublicWeightsType, + pub weight_category: WeightsCategory, + pub title: String, + + /// Cover images are small descriptive images that can be set for any model. + /// If a cover image is set, this is the path to the asset. + pub maybe_cover_image_public_bucket_path: Option, + + /// NB: Technically this shouldn't be a nullable field, but since there are so many + /// joins we'll relax that this must exist (for now). + pub maybe_weight_creator: Option, +} + diff --git a/crates/lib/api_clients/storyteller_client/src/media_files/mod.rs b/crates/lib/api_clients/storyteller_client/src/media_files/mod.rs new file mode 100644 index 0000000000..ac7f34f9f9 --- /dev/null +++ b/crates/lib/api_clients/storyteller_client/src/media_files/mod.rs @@ -0,0 +1 @@ +pub mod get_media_file; \ No newline at end of file diff --git a/crates/lib/api_clients/storyteller_client/src/shared_response_types/cover_image_links.rs b/crates/lib/api_clients/storyteller_client/src/shared_response_types/cover_image_links.rs new file mode 100644 index 0000000000..975b461392 --- /dev/null +++ b/crates/lib/api_clients/storyteller_client/src/shared_response_types/cover_image_links.rs @@ -0,0 +1,15 @@ +use serde_derive::Deserialize; +use url::Url; + +/// Cover image links can only be jpg, png, etc. No videos. +#[derive(Deserialize)] +pub struct CoverImageLinks { + /// Primary link to the cover image via the CDN. + pub cdn_url: Url, + + /// Template to construct thumbnail URLs. + /// Replace the string `{WIDTH}` with the desired width. + /// Only relevant for image media files. (Video media files instead have + /// video previews, which, in turn, have their own thumbnail templates.) + pub thumbnail_template: String, +} diff --git a/crates/lib/api_clients/storyteller_client/src/shared_response_types/media_file_cover_image_details.rs b/crates/lib/api_clients/storyteller_client/src/shared_response_types/media_file_cover_image_details.rs new file mode 100644 index 0000000000..b87401a7f1 --- /dev/null +++ b/crates/lib/api_clients/storyteller_client/src/shared_response_types/media_file_cover_image_details.rs @@ -0,0 +1,40 @@ +use serde_derive::{Deserialize}; +use url::Url; +use crate::shared_response_types::cover_image_links::CoverImageLinks; + +/// Everything we need to create a cover image. +/// Cover images are small descriptive images that can be set for any media file. +/// If a cover image is set, this is the path to the asset. +#[derive(Deserialize)] +pub struct MediaFileCoverImageDetails { + // /// (DEPRECATED) URL path to the media file + // #[deprecated(note="This field doesn't point to the full URL. Use media_links instead to leverage the CDN.")] + // pub maybe_cover_image_public_bucket_path: Option, + + // /// (DEPRECATED) Full URL to the media file + // #[deprecated(note="This points to the bucket. Use media_links instead to leverage the CDN.")] + // pub maybe_cover_image_public_bucket_url: Option, + + // NB(bt,2024-09-19): I accidentally rolled this field out to production. + // I don't think this field is in use, but maybe ... + // /// (DEPRECATED) Use maybe_links instead. + // #[deprecated(note="Use `maybe_links` instead.")] + // pub maybe_media_links: Option, + + /// Links to the cover image (CDN direct link, thumbnail template) + /// If a cover image is set, this is the path to the asset. + /// If a cover image is not set, use the information in `default_cover` instead. + /// Rich CDN links to the media, including thumbnails, previews, and more. + pub maybe_links: Option, + + /// For items without a cover image, we can use one of our own. + pub default_cover: MediaFileDefaultCover, +} + +/// The default cover is composed of an image and color pair that are +/// predefined by the frontend. +#[derive(Deserialize)] +pub struct MediaFileDefaultCover { + pub image_index: u8, + pub color_index: u8, +} diff --git a/crates/lib/api_clients/storyteller_client/src/shared_response_types/media_links.rs b/crates/lib/api_clients/storyteller_client/src/shared_response_types/media_links.rs new file mode 100644 index 0000000000..1d575b1927 --- /dev/null +++ b/crates/lib/api_clients/storyteller_client/src/shared_response_types/media_links.rs @@ -0,0 +1,34 @@ +use serde_derive::Deserialize; +use url::Url; + +/// Links to media file locations (bucket, CDN, etc.) +#[derive(Deserialize)] +pub struct MediaLinks { + /// Primary link to the asset via the CDN. + pub cdn_url: Url, + + /// Template to construct thumbnail URLs. + /// Replace the string `{WIDTH}` with the desired width. + /// Only relevant for image media files. (Video media files instead have + /// video previews, which, in turn, have their own thumbnail templates.) + pub maybe_thumbnail_template: Option, + + /// Video preview images (still and animated gif) for mp4 video files. + /// These are only set for video media files. + /// These are not set for "cover images" that are gif or webms, but rather video files. + pub maybe_video_previews: Option, +} + +#[derive(Deserialize)] +pub struct VideoPreviews { + /// A static single frame preview image of the video. + pub still: Url, + /// An animated gif preview of the video. + pub animated: Url, + /// A template used to construct the still thumbnail URL. + /// Replace the string `{WIDTH}` with the desired width. + pub still_thumbnail_template: String, + /// A template used to construct the animated thumbnail URL. + /// Replace the string `{WIDTH}` with the desired width. + pub animated_thumbnail_template: String, +} diff --git a/crates/lib/api_clients/storyteller_client/src/shared_response_types/mod.rs b/crates/lib/api_clients/storyteller_client/src/shared_response_types/mod.rs new file mode 100644 index 0000000000..d6dc6ece8f --- /dev/null +++ b/crates/lib/api_clients/storyteller_client/src/shared_response_types/mod.rs @@ -0,0 +1,5 @@ +pub mod cover_image_links; +pub mod media_file_cover_image_details; +pub mod media_links; +pub mod simple_entity_stats; +pub mod user_details_light; \ No newline at end of file diff --git a/crates/lib/api_clients/storyteller_client/src/shared_response_types/simple_entity_stats.rs b/crates/lib/api_clients/storyteller_client/src/shared_response_types/simple_entity_stats.rs new file mode 100644 index 0000000000..393ac85e55 --- /dev/null +++ b/crates/lib/api_clients/storyteller_client/src/shared_response_types/simple_entity_stats.rs @@ -0,0 +1,10 @@ +use serde_derive::Deserialize; + +/// Simple stats that can be attached to any entity +#[derive(Deserialize)] +pub struct SimpleEntityStats { + /// Number of positive ratings (or "likes") for this item + pub positive_rating_count: u32, + /// Number of bookmarks for this item + pub bookmark_count: u32, +} diff --git a/crates/lib/api_clients/storyteller_client/src/shared_response_types/user_details_light.rs b/crates/lib/api_clients/storyteller_client/src/shared_response_types/user_details_light.rs new file mode 100644 index 0000000000..7106e8a6b8 --- /dev/null +++ b/crates/lib/api_clients/storyteller_client/src/shared_response_types/user_details_light.rs @@ -0,0 +1,33 @@ +use serde_derive::Deserialize; +use tokens::tokens::users::UserToken; + +/// Everything we need to refer to a user on the public web interface. +#[derive(Deserialize)] +pub struct UserDetailsLight { + /// The token for the user + pub user_token: UserToken, + + /// The unique username someone logs in with + /// As of 2023-08-23, this is always lowercase + pub username: String, + + /// As of 2023-08-23, this is the username with capitalization + /// (In the future, a display name can be customized by the user.) + pub display_name: String, + + /// Email hash for Gravatar + /// Always set for now since login is email/username+password. + /// In the future this will need to become an optional *OR* be filled with a bogus hash. + pub gravatar_hash: String, + + /// For users without a gravatar, we show one of our own avatars. + pub default_avatar: UserDefaultAvatarInfo, + + // In the future, we'll also support user-uploaded avatars that we store on our servers. +} + +#[derive(Deserialize)] +pub struct UserDefaultAvatarInfo { + pub image_index: u8, + pub color_index: u8, +} diff --git a/crates/lib/api_clients/storyteller_client/src/utils/api_host.rs b/crates/lib/api_clients/storyteller_client/src/utils/api_host.rs new file mode 100644 index 0000000000..cc75a97538 --- /dev/null +++ b/crates/lib/api_clients/storyteller_client/src/utils/api_host.rs @@ -0,0 +1,17 @@ + +#[derive(Clone)] +pub enum ApiHost { + Storyteller, + FakeYou, + Localhost { port: u32 }, +} + +impl ApiHost { + pub fn to_api_hostname(&self) -> String { + match self { + ApiHost::Storyteller => "api.storyteller.ai".to_string(), + ApiHost::FakeYou => "api.fakeyou.com".to_string(), + ApiHost::Localhost { port } => format!("localhost:{}", port), + } + } +} diff --git a/crates/lib/api_clients/storyteller_client/src/utils/filter_bad_response.rs b/crates/lib/api_clients/storyteller_client/src/utils/filter_bad_response.rs new file mode 100644 index 0000000000..3bd27d1b17 --- /dev/null +++ b/crates/lib/api_clients/storyteller_client/src/utils/filter_bad_response.rs @@ -0,0 +1,33 @@ +use anyhow::anyhow; +use crate::api_error::ApiError; +use crate::utils::status_codes::*; +use reqwest::StatusCode; + +/// Pass request errors to this method for standard error handling behavior. +/// This function needs to take temporary ownership, but will return it to the caller. +/// +/// Example use: +/// let response = client.get().await?; +/// let response = filter_bad_response(response).await?; +/// ... 200 or other valid response +pub async fn filter_bad_response(response: reqwest::Response) -> Result { + let status = response.status(); + + if !status.is_success() { + let response_body = match response.text().await { + Ok(text) => text.to_string(), + Err(err) => format!("Could not read response body: {:?}", err), + }; + + return match status.as_u16() { + STATUS_401_UNAUTHORIZED => Err(ApiError::Unauthorized(response_body)), + STATUS_403_FORBIDDEN => Err(ApiError::Forbidden(response_body)), + STATUS_404_NOT_FOUND => Err(ApiError::NotFound(response_body)), + STATUS_429_TOO_MANY_REQUESTS => Err(ApiError::TooManyRequests(response_body)), + STATUS_500_INTERNAL_SERVER_ERROR => Err(ApiError::InternalServerError(response_body)), + _ => Err(ApiError::Other(anyhow!("Bad status code: {}; message: {:?}", status, response_body))), + } + } + + Ok(response) +} \ No newline at end of file diff --git a/crates/lib/api_clients/storyteller_client/src/utils/mod.rs b/crates/lib/api_clients/storyteller_client/src/utils/mod.rs new file mode 100644 index 0000000000..f2974a5378 --- /dev/null +++ b/crates/lib/api_clients/storyteller_client/src/utils/mod.rs @@ -0,0 +1,3 @@ +pub mod api_host; +pub mod filter_bad_response; +pub mod status_codes; \ No newline at end of file diff --git a/crates/lib/api_clients/storyteller_client/src/utils/status_codes.rs b/crates/lib/api_clients/storyteller_client/src/utils/status_codes.rs new file mode 100644 index 0000000000..c15847e129 --- /dev/null +++ b/crates/lib/api_clients/storyteller_client/src/utils/status_codes.rs @@ -0,0 +1,23 @@ +pub const STATUS_401_UNAUTHORIZED : u16 = 401u16; + +pub const STATUS_403_FORBIDDEN : u16 = 403u16; + +pub const STATUS_404_NOT_FOUND : u16 = 404u16; + +pub const STATUS_429_TOO_MANY_REQUESTS : u16 = 429u16; + +pub const STATUS_500_INTERNAL_SERVER_ERROR : u16 = 500u16; + +#[cfg(test)] +mod tests { + use crate::utils::status_codes::*; + + #[test] + pub fn test_codes() { + assert_eq!(STATUS_401_UNAUTHORIZED, 401); + assert_eq!(STATUS_403_FORBIDDEN, 403); + assert_eq!(STATUS_404_NOT_FOUND, 404); + assert_eq!(STATUS_429_TOO_MANY_REQUESTS, 429); + assert_eq!(STATUS_500_INTERNAL_SERVER_ERROR, 500); + } +} diff --git a/crates/service/web/storyteller_web/Cargo.toml b/crates/service/web/storyteller_web/Cargo.toml index d013aec500..c925dcc9d8 100644 --- a/crates/service/web/storyteller_web/Cargo.toml +++ b/crates/service/web/storyteller_web/Cargo.toml @@ -98,7 +98,7 @@ strum_macros = "0.26.4" symphonia = { git = "https://github.com/pdeljanov/Symphonia.git", rev = "e1a70095c4409478c1ded499fb8dd4afa540fb90", features = ["aac", "alac", "flac", "isomp4", "mkv", "mp3", "ogg", "opus", "vorbis", "wav"] } time = "0.3.30" uuid = { version = "1.8.0", features = ["v4"] } -url = { version = "2.5.2", features = ["serde"] } +url = { workspace = true } # Language tag parsing # TODO: Move to language support crate