diff --git a/.config/nextest.toml b/.config/nextest.toml deleted file mode 100644 index 6612844..0000000 --- a/.config/nextest.toml +++ /dev/null @@ -1,122 +0,0 @@ -# This is the default config used by nextest. It is embedded in the binary at -# build time. It may be used as a template for .config/nextest.toml. -# Reference: https://nexte.st/book/configuration.html - -[store] -# The directory under the workspace root at which nextest-related files are -# written. Profile-specific storage is currently written to dir/. -dir = "target/nextest" - -# This section defines the default nextest profile. Custom profiles are layered -# on top of the default profile. -[profile.default] -# "retries" defines the number of times a test should be retried. If set to a -# non-zero value, tests that succeed on a subsequent attempt will be marked as -# non-flaky. Can be overridden through the `--retries` option. -# Examples -# * retries = 3 -# * retries = { backoff = "fixed", count = 2, delay = "1s" } -# * retries = { backoff = "exponential", count = 10, delay = "1s", jitter = true, max-delay = "10s" } -retries = 0 - -# The number of threads to run tests with. Supported values are either an integer or -# the string "num-cpus". Can be overridden through the `--test-threads` option. -test-threads = "num-cpus" - -# The number of threads required for each test. This is generally used in overrides to -# mark certain tests as heavier than others. However, it can also be set as a global parameter. -threads-required = 1 - -# Show these test statuses in the output. -# -# The possible values this can take are: -# * none: no output -# * fail: show failed (including exec-failed) tests -# * retry: show flaky and retried tests -# * slow: show slow tests -# * pass: show passed tests -# * skip: show skipped tests (most useful for CI) -# * all: all of the above -# -# Each value includes all the values above it; for example, "slow" includes -# failed and retried tests. -# -# Can be overridden through the `--status-level` flag. -status-level = "pass" - -# Similar to status-level, show these test statuses at the end of the run. -final-status-level = "flaky" - -# "failure-output" defines when standard output and standard error for failing tests are produced. -# Accepted values are -# * "immediate": output failures as soon as they happen -# * "final": output failures at the end of the test run -# * "immediate-final": output failures as soon as they happen and at the end of -# the test run; combination of "immediate" and "final" -# * "never": don't output failures at all -# -# For large test suites and CI it is generally useful to use "immediate-final". -# -# Can be overridden through the `--failure-output` option. -failure-output = "immediate" - -# "success-output" controls production of standard output and standard error on success. This should -# generally be set to "never". -success-output = "never" - -# Cancel the test run on the first failure. For CI runs, consider setting this -# to false. -fail-fast = true - -# Treat a test that takes longer than the configured 'period' as slow, and print a message. -# See for more information. -# -# Optional: specify the parameter 'terminate-after' with a non-zero integer, -# which will cause slow tests to be terminated after the specified number of -# periods have passed. -# Example: slow-timeout = { period = "60s", terminate-after = 2 } -slow-timeout = { period = "90s" } - -# Treat a test as leaky if after the process is shut down, standard output and standard error -# aren't closed within this duration. -# -# This usually happens in case of a test that creates a child process and lets it inherit those -# handles, but doesn't clean the child process up (especially when it fails). -# -# See for more information. -leak-timeout = "100ms" - -[profile.default.junit] -# Output a JUnit report into the given file inside 'store.dir/'. -# If unspecified, JUnit is not written out. - -# path = "junit.xml" - -# The name of the top-level "report" element in JUnit report. If aggregating -# reports across different test runs, it may be useful to provide separate names -# for each report. -report-name = "nextest-run" - -# Whether standard output and standard error for passing tests should be stored in the JUnit report. -# Output is stored in the and elements of the element. -store-success-output = false - -# Whether standard output and standard error for failing tests should be stored in the JUnit report. -# Output is stored in the and elements of the element. -# -# Note that if a description can be extracted from the output, it is always stored in the -# element. -store-failure-output = true - -[test-groups] -serial-integration = { max-threads = 1 } - -# Serial tests filter -[[profile.default.overrides]] -filter = 'test(serial::)' -test-group = 'serial-integration' - -# Long running tests filter -[[profile.default.overrides]] -filter = 'test(long_running::)' -slow-timeout = { period = "360s", terminate-after = 3 } \ No newline at end of file diff --git a/.env.mapping b/.env.mapping index 1071d9e..fb5d064 100644 --- a/.env.mapping +++ b/.env.mapping @@ -4,6 +4,9 @@ HOST PORT CDN_SERVER_URL +RPC_SERVER_URL + +RPC_SECRET_TOKEN REDIS_HOST REDIS_PORT diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..1c9c014 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,50 @@ +name: Continuous integration + +on: [pull_request, push] + +env: + CARGO_TERM_COLOR: always + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install latest stable + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + override: true + components: rustfmt, clippy + + - name: Run cargo clippy + uses: actions-rs/cargo@v1 + with: + command: clippy + args: --all-targets --all-features -- -D warnings + + test: + name: Run tests + runs-on: ubuntu-latest + needs: lint + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + # By default actions/checkout checks out a merge commit. Check out the PR head instead. + # https://github.com/actions/checkout#checkout-pull-request-head-commit-instead-of-merge-commit + ref: ${{ github.event.pull_request.head.sha }} + + - name: Install latest stable release + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + override: true + + - name: Run tests + uses: actions-rs/cargo@v1 + with: + command: test \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index 2f70e84..d5c3a92 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,24 +6,45 @@ license = "MIT" authors = ["zignis "] repository = "https://github.com/storiny/og.git" +[lib] +doctest = false + [dependencies] +abbrev-num = "0.1.0" actix-cors = "0.7.0" actix-extensible-rate-limit = { version = "0.2.1", features = ["redis"] } actix-http = "3.4.0" actix-web = { version = "4.4.0", features = ["__compress"] } -async-trait = "0.1.73" dotenv = "0.15.0" envy = "0.4.2" +lazy_static = "1.4.0" mime = "0.3.17" +png = "0.17.13" +prost = "0.12.4" redis = { version = "0.25.3", features = ["tokio-comp", "aio", "connection-manager"] } -reqwest = { version = "0.12.2", features = ["json"] } +reqwest = { version = "0.12.3", features = ["blocking"] } +resvg = "0.41.0" +rust_decimal = "1.35.0" +rusttype = "0.9.3" +sailfish = "0.8.3" sentry = { version = "0.32.0", features = ["tracing"] } serde = { version = "1.0.188", features = ["derive"] } -serde_json = "1.0.115" strum = { version = "0.26.2", features = ["derive"] } +tonic = { version = "0.11.0", features = ["gzip", "tls", "transport"] } +textwrap = { version = "0.16.1", features = ["smawk"] } thiserror = "1.0.48" +tiny-skia = { version = "0.11.4", features = ["png-format"] } tokio = { version = "1.32.0", features = ["macros"] } tracing = { version = "0.1.40", features = ["attributes"] } tracing-actix-web = "0.7.9" tracing-bunyan-formatter = "0.3.9" tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } +unicode-segmentation = "1.11.0" +unidecode = "0.3.0" +usvg = "0.41.0" +pbjson = "0.6.0" +async-trait = "0.1.79" + +[dev-dependencies] +image = { version = "0.25.1", default-features = false, features = ["jpeg", "png", "rayon"] } +image-compare = "0.4.1" diff --git a/fixtures/story.png b/fixtures/story.png new file mode 100644 index 0000000..c9856fe Binary files /dev/null and b/fixtures/story.png differ diff --git a/fixtures/story_with_external_images.png b/fixtures/story_with_external_images.png new file mode 100644 index 0000000..b1f4e5d Binary files /dev/null and b/fixtures/story_with_external_images.png differ diff --git a/fixtures/tag.png b/fixtures/tag.png new file mode 100644 index 0000000..58beecd Binary files /dev/null and b/fixtures/tag.png differ diff --git a/fonts/CabinetGrotesk/CabinetGrotesk-Bold.ttf b/fonts/CabinetGrotesk/CabinetGrotesk-Bold.ttf new file mode 100644 index 0000000..fa91620 Binary files /dev/null and b/fonts/CabinetGrotesk/CabinetGrotesk-Bold.ttf differ diff --git a/fonts/CabinetGrotesk/CabinetGrotesk-Extrabold.ttf b/fonts/CabinetGrotesk/CabinetGrotesk-Extrabold.ttf new file mode 100644 index 0000000..626741d Binary files /dev/null and b/fonts/CabinetGrotesk/CabinetGrotesk-Extrabold.ttf differ diff --git a/fonts/CabinetGrotesk/CabinetGrotesk-Medium.ttf b/fonts/CabinetGrotesk/CabinetGrotesk-Medium.ttf new file mode 100644 index 0000000..2f1e2f1 Binary files /dev/null and b/fonts/CabinetGrotesk/CabinetGrotesk-Medium.ttf differ diff --git a/fonts/CabinetGrotesk/CabinetGrotesk-Regular.ttf b/fonts/CabinetGrotesk/CabinetGrotesk-Regular.ttf new file mode 100644 index 0000000..69a1a7c Binary files /dev/null and b/fonts/CabinetGrotesk/CabinetGrotesk-Regular.ttf differ diff --git a/fonts/Satoshi/Satoshi-Bold.ttf b/fonts/Satoshi/Satoshi-Bold.ttf new file mode 100644 index 0000000..00bc985 Binary files /dev/null and b/fonts/Satoshi/Satoshi-Bold.ttf differ diff --git a/fonts/Satoshi/Satoshi-Medium.ttf b/fonts/Satoshi/Satoshi-Medium.ttf new file mode 100644 index 0000000..ab149b7 Binary files /dev/null and b/fonts/Satoshi/Satoshi-Medium.ttf differ diff --git a/fonts/Satoshi/Satoshi-Regular.ttf b/fonts/Satoshi/Satoshi-Regular.ttf new file mode 100644 index 0000000..fe85cd6 Binary files /dev/null and b/fonts/Satoshi/Satoshi-Regular.ttf differ diff --git a/justfile b/justfile index ad12102..6bd7524 100644 --- a/justfile +++ b/justfile @@ -13,14 +13,5 @@ build_img: fmt: cargo +nightly fmt -test: - cargo nextest run --workspace - -test_ci: - cargo nextest run --no-fail-fast --workspace - -test_verbose: - cargo nextest run --no-capture --no-fail-fast --workspace - udeps: cargo +nightly udeps --all-targets \ No newline at end of file diff --git a/src/config.rs b/src/config.rs index b58afef..35268c0 100644 --- a/src/config.rs +++ b/src/config.rs @@ -12,6 +12,10 @@ pub struct Config { pub port: String, /// Public URL of the CDN server pub cdn_server_url: String, + /// Private URL of the RPC server + pub rpc_server_url: String, + /// Private RPC authentication token + pub rpc_secret_token: String, /// Redis host pub redis_host: String, /// Redis port diff --git a/src/constants/dimensions.rs b/src/constants/dimensions.rs new file mode 100644 index 0000000..1916fb2 --- /dev/null +++ b/src/constants/dimensions.rs @@ -0,0 +1,2 @@ +pub const IMG_WIDTH: u32 = 1200; +pub const IMG_HEIGHT: u32 = 630; diff --git a/src/constants/fonts.rs b/src/constants/fonts.rs new file mode 100644 index 0000000..2966ebd --- /dev/null +++ b/src/constants/fonts.rs @@ -0,0 +1,40 @@ +use lazy_static::lazy_static; +use rusttype::Font; + +lazy_static! { + // Cabinet Grotesk + pub static ref CABINET_GROTESK_BOLD: Font<'static> = + #[allow(clippy::expect_used)] + Font::try_from_bytes(include_bytes!("../../fonts/CabinetGrotesk/CabinetGrotesk-Bold.ttf") as &[u8]) + .expect("error loading the `CabinetGrotesk-Bold.ttf` font file"); + // + pub static ref CABINET_GROTESK_EXTRABOLD: Font<'static> = + #[allow(clippy::expect_used)] + Font::try_from_bytes(include_bytes!("../../fonts/CabinetGrotesk/CabinetGrotesk-Extrabold.ttf") as &[u8]) + .expect("error loading the `CabinetGrotesk-Extrabold.ttf` font file"); + // + pub static ref CABINET_GROTESK_MEDIUM: Font<'static> = + #[allow(clippy::expect_used)] + Font::try_from_bytes(include_bytes!("../../fonts/CabinetGrotesk/CabinetGrotesk-Medium.ttf") as &[u8]) + .expect("error loading the `CabinetGrotesk-Medium.ttf` font file"); + // + pub static ref CABINET_GROTESK_REGULAR: Font<'static> = + #[allow(clippy::expect_used)] + Font::try_from_bytes(include_bytes!("../../fonts/CabinetGrotesk/CabinetGrotesk-Regular.ttf") as &[u8]) + .expect("error loading the `CabinetGrotesk-Regular.ttf` font file"); + // Satoshi + pub static ref SATOSHI_BOLD: Font<'static> = + #[allow(clippy::expect_used)] + Font::try_from_bytes(include_bytes!("../../fonts/Satoshi/Satoshi-Bold.ttf") as &[u8]) + .expect("error loading the `Satoshi-Bold.ttf` font file"); + // + pub static ref SATOSHI_MEDIUM: Font<'static> = + #[allow(clippy::expect_used)] + Font::try_from_bytes(include_bytes!("../../fonts/Satoshi/Satoshi-Medium.ttf") as &[u8]) + .expect("error loading the `Satoshi-Medium.ttf` font file"); + // + pub static ref SATOSHI_REGULAR: Font<'static> = + #[allow(clippy::expect_used)] + Font::try_from_bytes(include_bytes!("../../fonts/Satoshi/Satoshi-Regular.ttf") as &[u8]) + .expect("error loading the `Satoshi-Regular.ttf` font file"); +} diff --git a/src/constants/icons.rs b/src/constants/icons.rs new file mode 100644 index 0000000..240ff74 --- /dev/null +++ b/src/constants/icons.rs @@ -0,0 +1 @@ +pub const DEFAULT_AVATAR: &str = r#"data:image/png;base64,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"#; diff --git a/src/constants/mod.rs b/src/constants/mod.rs index 339bd7a..c8f76a7 100644 --- a/src/constants/mod.rs +++ b/src/constants/mod.rs @@ -1 +1,4 @@ +pub mod dimensions; +pub mod fonts; +pub mod icons; pub mod redis_namespaces; diff --git a/src/error.rs b/src/error.rs index 6e6317c..d3435c2 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,14 +1,23 @@ use actix_http::StatusCode; -use actix_web::{HttpResponse, ResponseError}; -use std::fmt::{Display, Formatter}; +use actix_web::{ + HttpResponse, + ResponseError, +}; +use std::fmt::{ + Display, + Formatter, +}; +use tonic::Code; /// The application error type. #[derive(Debug)] pub enum AppError { - /// The [serde_json::Error] variant. - SerdeError(serde_json::Error), - /// The [reqwest::Error] variant. - ReqwestError(reqwest::Error), + /// The [png::EncodingError] variant. + PngEncodingError(png::EncodingError), + /// The [sailfish::RenderError] variant. + RenderTemplateError(sailfish::RenderError), + /// The [tonic::Status] variant. + TonicError(tonic::Status), /// Internal server error. The string value of this variant is not sent to the client. InternalError(String), /// The error raised due to bad data sent by the client. The first element of the tuple is the @@ -22,15 +31,21 @@ pub enum AppError { ClientError(StatusCode, String), } -impl From for AppError { - fn from(error: serde_json::Error) -> Self { - AppError::SerdeError(error) +impl From for AppError { + fn from(error: png::EncodingError) -> Self { + AppError::PngEncodingError(error) } } -impl From for AppError { - fn from(error: reqwest::Error) -> Self { - AppError::ReqwestError(error) +impl From for AppError { + fn from(error: sailfish::RenderError) -> Self { + AppError::RenderTemplateError(error) + } +} + +impl From for AppError { + fn from(error: tonic::Status) -> Self { + AppError::TonicError(error) } } @@ -44,9 +59,13 @@ impl ResponseError for AppError { /// Returns the HTTP [StatusCode] for the error. fn status_code(&self) -> StatusCode { match self { - AppError::InternalError(_) | AppError::SerdeError(_) | AppError::ReqwestError(_) => { - StatusCode::INTERNAL_SERVER_ERROR - } + AppError::InternalError(_) + | AppError::PngEncodingError(_) + | AppError::RenderTemplateError(_) => StatusCode::INTERNAL_SERVER_ERROR, + AppError::TonicError(status) => match status.code() { + Code::NotFound => StatusCode::NOT_FOUND, + _ => StatusCode::INTERNAL_SERVER_ERROR, + }, AppError::ClientError(status_code, _) => *status_code, } } @@ -56,9 +75,13 @@ impl ResponseError for AppError { let mut response_builder = HttpResponse::build(self.status_code()); match self { - AppError::InternalError(_) | AppError::SerdeError(_) | AppError::ReqwestError(_) => { - response_builder.body("Internal server error") - } + AppError::InternalError(_) + | AppError::PngEncodingError(_) + | AppError::RenderTemplateError(_) => response_builder.body("Internal server error"), + AppError::TonicError(status) => match status.code() { + Code::NotFound => response_builder.body("Entity not found"), + _ => response_builder.body("Internal gateway error"), + }, AppError::ClientError(_, message) => response_builder.body(message.to_string()), } } diff --git a/src/grpc/defs.rs b/src/grpc/defs.rs new file mode 100644 index 0000000..0ecb4f2 --- /dev/null +++ b/src/grpc/defs.rs @@ -0,0 +1,14 @@ +#[allow(clippy::unwrap_used)] +pub mod grpc_service { + pub mod v1 { + include!("../proto/api_service.v1.rs"); + include!("../proto/api_service.v1.serde.rs"); + } +} + +pub mod open_graph_def { + pub mod v1 { + include!("../proto/open_graph_def.v1.rs"); + include!("../proto/open_graph_def.v1.serde.rs"); + } +} diff --git a/src/grpc/mod.rs b/src/grpc/mod.rs new file mode 100644 index 0000000..fb3c845 --- /dev/null +++ b/src/grpc/mod.rs @@ -0,0 +1 @@ +pub mod defs; diff --git a/src/lib.rs b/src/lib.rs index e0fe98e..d986309 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,9 +1,69 @@ #![forbid(unsafe_code)] #![allow(clippy::module_inception)] #![deny(clippy::expect_used, clippy::unwrap_used)] -// + +use crate::grpc::defs::grpc_service::v1::api_service_client::ApiServiceClient; +use sailfish::TemplateOnce; +use tokio::sync::Mutex; +use tonic::{ + codegen::InterceptedService, + transport::Channel, + Request, + Status, +}; + pub mod config; pub mod constants; pub mod error; +pub mod grpc; pub mod routes; pub mod telemetry; +pub mod utils; + +pub type AuthInterceptor = Box) -> Result, Status> + Send>; +pub type GrpcClient = ApiServiceClient>; + +/// The application state. +pub struct AppState { + /// The environment configuration. + pub config: config::Config, + /// The gRPC service client. + pub grpc_client: Mutex, +} + +// Story open graph image template. +#[derive(TemplateOnce)] +#[template(path = "story.stpl")] +pub struct StoryTemplate { + /// The title of the story. + title: String, + /// The optional description of story. + description: Option, + /// The optional splash image URL for the story. + splash_url: Option, + /// The name of author of the story. + user_name: String, + /// The avatar image URL of author of the story. + user_avatar_url: String, + /// The read count value for the story. + read_count: String, + /// The like count value for the story. + like_count: String, + /// The comment count value for the story. + comment_count: String, + /// The vertical translation parameter for the persona and description. It depends on whether + /// the title is a single line or multiline. + title_offset: String, +} + +// Tag open graph image template. +#[derive(TemplateOnce)] +#[template(path = "tag.stpl")] +pub struct TagTemplate { + /// The name of the tag without `#` prefix. + name: String, + /// The follower count value for the tag. + follower_count: String, + /// The story count value for the tag. + story_count: String, +} diff --git a/src/main.rs b/src/main.rs index 3335e59..a9ebb7c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,19 +1,49 @@ use actix_cors::Cors; -use actix_extensible_rate_limit::{backend::SimpleInputFunctionBuilder, RateLimiter}; +use actix_extensible_rate_limit::{ + backend::SimpleInputFunctionBuilder, + RateLimiter, +}; use actix_http::Method; -use actix_web::{http::header::ContentType, web, App, HttpResponse, HttpServer, Responder}; +use actix_web::{ + http::header::ContentType, + web, + App, + HttpResponse, + HttpServer, + Responder, +}; use dotenv::dotenv; use redis::aio::ConnectionManager; -use std::{io, sync::Arc, time::Duration}; +use std::{ + io, + sync::Arc, + time::Duration, +}; use storiny_og::{ config::get_app_config, constants::redis_namespaces::RedisNamespace, + grpc::defs::grpc_service::v1::api_service_client::ApiServiceClient, routes, - telemetry::{get_subscriber, init_subscriber}, + telemetry::{ + get_subscriber, + init_subscriber, + }, + AppState, + AuthInterceptor, + GrpcClient, +}; +use tokio::sync::Mutex; +use tonic::{ + codec::CompressionEncoding, + metadata::MetadataValue, + transport::Channel, }; use tracing::error; use tracing_actix_web::TracingLogger; -use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; +use tracing_subscriber::{ + layer::SubscriberExt, + util::SubscriberInitExt, +}; mod middlewares; @@ -92,7 +122,43 @@ fn main() -> io::Result<()> { .key_prefix(Some(&format!("{}:", RedisNamespace::RateLimit))) .build(); - let web_config = web::Data::new(config.clone()); + // gRPC client + let grpc_channel = match Channel::from_shared(config.rpc_server_url.to_string()) + .expect("unable to resolve the rpc server endpoint") + .connect() + .await + { + Ok(channel) => { + println!("Connected to the rpc service"); + channel + } + Err(err) => { + error!("unable to connect to the rpc service: {err:?}"); + std::process::exit(1); + } + }; + + let auth_token: MetadataValue<_> = format!("Bearer {}", config.rpc_secret_token) + .parse() + .expect("unable to parse the rpc auth token"); + + let grpc_client: GrpcClient = + ApiServiceClient::with_interceptor::( + grpc_channel, + Box::new(move |mut req: tonic::Request<()>| { + req.metadata_mut() + .insert("authorization", auth_token.clone()); + + Ok(req) + }), + ) + .send_compressed(CompressionEncoding::Gzip) + .accept_compressed(CompressionEncoding::Gzip); + + let app_state = web::Data::new(AppState { + config: get_app_config().unwrap(), + grpc_client: Mutex::new(grpc_client), + }); HttpServer::new(move || { let input = SimpleInputFunctionBuilder::new(Duration::from_secs(5), 25) // 25 requests / 5s @@ -122,7 +188,7 @@ fn main() -> io::Result<()> { .wrap(TracingLogger::default()) .wrap(actix_web::middleware::Compress::default()) .wrap(actix_web::middleware::NormalizePath::trim()) - .app_data(web_config.clone()) + .app_data(app_state.clone()) .configure(routes::init_routes) .default_service(web::route().to(not_found)) }) diff --git a/src/middlewares/rate_limiter.rs b/src/middlewares/rate_limiter.rs index e0248d2..b1e70fe 100644 --- a/src/middlewares/rate_limiter.rs +++ b/src/middlewares/rate_limiter.rs @@ -1,8 +1,22 @@ -use actix_extensible_rate_limit::backend::{Backend, SimpleBackend, SimpleInput, SimpleOutput}; -use actix_web::{HttpResponse, ResponseError}; +use actix_extensible_rate_limit::backend::{ + Backend, + SimpleBackend, + SimpleInput, + SimpleOutput, +}; +use actix_web::{ + HttpResponse, + ResponseError, +}; use async_trait::async_trait; -use redis::{aio::ConnectionManager, AsyncCommands}; -use std::{borrow::Cow, time::Duration}; +use redis::{ + aio::ConnectionManager, + AsyncCommands, +}; +use std::{ + borrow::Cow, + time::Duration, +}; use thiserror::Error; use tokio::time::Instant; @@ -291,10 +305,11 @@ mod tests { .await .unwrap(); // In which case nothing should happen - assert!(!con - .exists::<_, bool>("test_rollback_key_gone") - .await - .unwrap()); + assert!( + !con.exists::<_, bool>("test_rollback_key_gone") + .await + .unwrap() + ); } #[tokio::test] @@ -328,15 +343,17 @@ mod tests { key: "test_key_prefix".to_string(), }; backend.request(input.clone()).await.unwrap(); - assert!(con - .exists::<_, bool>("prefix:test_key_prefix") - .await - .unwrap()); + assert!( + con.exists::<_, bool>("prefix:test_key_prefix") + .await + .unwrap() + ); backend.remove_key("test_key_prefix").await.unwrap(); - assert!(!con - .exists::<_, bool>("prefix:test_key_prefix") - .await - .unwrap()); + assert!( + !con.exists::<_, bool>("prefix:test_key_prefix") + .await + .unwrap() + ); } } diff --git a/src/proto/api_service.v1.rs b/src/proto/api_service.v1.rs new file mode 100644 index 0000000..0514982 --- /dev/null +++ b/src/proto/api_service.v1.rs @@ -0,0 +1,8 @@ +// @generated +/// This is necessary to generate an output file using tonic. +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct Placeholder { +} +include!("api_service.v1.tonic.rs"); +// @@protoc_insertion_point(module) \ No newline at end of file diff --git a/src/proto/api_service.v1.serde.rs b/src/proto/api_service.v1.serde.rs new file mode 100644 index 0000000..59b33ea --- /dev/null +++ b/src/proto/api_service.v1.serde.rs @@ -0,0 +1,72 @@ +// @generated +impl serde::Serialize for Placeholder { + #[allow(deprecated)] + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + let len = 0; + let struct_ser = serializer.serialize_struct("api_service.v1.Placeholder", len)?; + struct_ser.end() + } +} +impl<'de> serde::Deserialize<'de> for Placeholder { + #[allow(deprecated)] + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + const FIELDS: &[&str] = &[ + ]; + + #[allow(clippy::enum_variant_names)] + enum GeneratedField { + } + impl<'de> serde::Deserialize<'de> for GeneratedField { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + struct GeneratedVisitor; + + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = GeneratedField; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "expected one of: {:?}", &FIELDS) + } + + #[allow(unused_variables)] + fn visit_str(self, value: &str) -> std::result::Result + where + E: serde::de::Error, + { + Err(serde::de::Error::unknown_field(value, FIELDS)) + } + } + deserializer.deserialize_identifier(GeneratedVisitor) + } + } + struct GeneratedVisitor; + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = Placeholder; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("struct api_service.v1.Placeholder") + } + + fn visit_map(self, mut map: V) -> std::result::Result + where + V: serde::de::MapAccess<'de>, + { + while map.next_key::()?.is_some() { + let _ = map.next_value::()?; + } + Ok(Placeholder { + }) + } + } + deserializer.deserialize_struct("api_service.v1.Placeholder", FIELDS, GeneratedVisitor) + } +} diff --git a/src/proto/api_service.v1.tonic.rs b/src/proto/api_service.v1.tonic.rs new file mode 100644 index 0000000..01dccfb --- /dev/null +++ b/src/proto/api_service.v1.tonic.rs @@ -0,0 +1,149 @@ +// @generated +/// Generated client implementations. +pub mod api_service_client { + #![allow(unused_variables, dead_code, missing_docs, clippy::let_unit_value)] + use tonic::codegen::{ + http::Uri, + *, + }; + /** Service definition + */ + #[derive(Debug, Clone)] + pub struct ApiServiceClient { + inner: tonic::client::Grpc, + } + impl ApiServiceClient { + /// Attempt to create a new client by connecting to a given endpoint. + pub async fn connect(dst: D) -> Result + where + D: TryInto, + D::Error: Into, + { + let conn = tonic::transport::Endpoint::new(dst)?.connect().await?; + Ok(Self::new(conn)) + } + } + impl ApiServiceClient + where + T: tonic::client::GrpcService, + T::Error: Into, + T::ResponseBody: Body + Send + 'static, + ::Error: Into + Send, + { + pub fn new(inner: T) -> Self { + let inner = tonic::client::Grpc::new(inner); + Self { inner } + } + pub fn with_origin(inner: T, origin: Uri) -> Self { + let inner = tonic::client::Grpc::with_origin(inner, origin); + Self { inner } + } + pub fn with_interceptor( + inner: T, + interceptor: F, + ) -> ApiServiceClient> + where + F: tonic::service::Interceptor, + T::ResponseBody: Default, + T: tonic::codegen::Service< + http::Request, + Response = http::Response< + >::ResponseBody, + >, + >, + >>::Error: + Into + Send + Sync, + { + ApiServiceClient::new(InterceptedService::new(inner, interceptor)) + } + /// Compress requests with the given encoding. + /// + /// This requires the server to support it otherwise it might respond with an + /// error. + #[must_use] + pub fn send_compressed(mut self, encoding: CompressionEncoding) -> Self { + self.inner = self.inner.send_compressed(encoding); + self + } + /// Enable decompressing responses. + #[must_use] + pub fn accept_compressed(mut self, encoding: CompressionEncoding) -> Self { + self.inner = self.inner.accept_compressed(encoding); + self + } + /// Limits the maximum size of a decoded message. + /// + /// Default: `4MB` + #[must_use] + pub fn max_decoding_message_size(mut self, limit: usize) -> Self { + self.inner = self.inner.max_decoding_message_size(limit); + self + } + /// Limits the maximum size of an encoded message. + /// + /// Default: `usize::MAX` + #[must_use] + pub fn max_encoding_message_size(mut self, limit: usize) -> Self { + self.inner = self.inner.max_encoding_message_size(limit); + self + } + /** * + Returns the story's open graph data + */ + pub async fn get_story_open_graph_data( + &mut self, + request: impl tonic::IntoRequest< + super::super::super::open_graph_def::v1::GetStoryOpenGraphDataRequest, + >, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner.ready().await.map_err(|e| { + tonic::Status::new( + tonic::Code::Unknown, + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic::codec::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/api_service.v1.ApiService/GetStoryOpenGraphData", + ); + let mut req = request.into_request(); + req.extensions_mut().insert(GrpcMethod::new( + "api_service.v1.ApiService", + "GetStoryOpenGraphData", + )); + self.inner.unary(req, path, codec).await + } + /** * + Returns the tag's open graph data + */ + pub async fn get_tag_open_graph_data( + &mut self, + request: impl tonic::IntoRequest< + super::super::super::open_graph_def::v1::GetTagOpenGraphDataRequest, + >, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner.ready().await.map_err(|e| { + tonic::Status::new( + tonic::Code::Unknown, + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic::codec::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/api_service.v1.ApiService/GetTagOpenGraphData", + ); + let mut req = request.into_request(); + req.extensions_mut().insert(GrpcMethod::new( + "api_service.v1.ApiService", + "GetTagOpenGraphData", + )); + self.inner.unary(req, path, codec).await + } + } +} diff --git a/src/proto/open_graph_def.v1.rs b/src/proto/open_graph_def.v1.rs new file mode 100644 index 0000000..de623d9 --- /dev/null +++ b/src/proto/open_graph_def.v1.rs @@ -0,0 +1,55 @@ +// @generated +// Get story open graph data request + +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GetStoryOpenGraphDataRequest { + #[prost(string, tag="1")] + pub id: ::prost::alloc::string::String, +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GetStoryOpenGraphDataResponse { + #[prost(string, tag="1")] + pub id: ::prost::alloc::string::String, + #[prost(string, tag="2")] + pub title: ::prost::alloc::string::String, + #[prost(string, optional, tag="3")] + pub description: ::core::option::Option<::prost::alloc::string::String>, + #[prost(string, optional, tag="4")] + pub splash_id: ::core::option::Option<::prost::alloc::string::String>, + #[prost(uint32, tag="5")] + pub like_count: u32, + #[prost(uint32, tag="6")] + pub read_count: u32, + #[prost(uint32, tag="7")] + pub comment_count: u32, + #[prost(bool, tag="8")] + pub is_private: bool, + /// User + #[prost(string, tag="9")] + pub user_name: ::prost::alloc::string::String, + #[prost(string, optional, tag="10")] + pub user_avatar_id: ::core::option::Option<::prost::alloc::string::String>, +} +// Get tag open graph data request + +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GetTagOpenGraphDataRequest { + #[prost(string, tag="1")] + pub id: ::prost::alloc::string::String, +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GetTagOpenGraphDataResponse { + #[prost(string, tag="1")] + pub id: ::prost::alloc::string::String, + #[prost(string, tag="2")] + pub name: ::prost::alloc::string::String, + #[prost(uint32, tag="3")] + pub story_count: u32, + #[prost(uint32, tag="4")] + pub follower_count: u32, +} +// @@protoc_insertion_point(module) diff --git a/src/proto/open_graph_def.v1.serde.rs b/src/proto/open_graph_def.v1.serde.rs new file mode 100644 index 0000000..ffc5448 --- /dev/null +++ b/src/proto/open_graph_def.v1.serde.rs @@ -0,0 +1,588 @@ +// @generated +impl serde::Serialize for GetStoryOpenGraphDataRequest { + #[allow(deprecated)] + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + let mut len = 0; + if !self.id.is_empty() { + len += 1; + } + let mut struct_ser = serializer.serialize_struct("open_graph_def.v1.GetStoryOpenGraphDataRequest", len)?; + if !self.id.is_empty() { + struct_ser.serialize_field("id", &self.id)?; + } + struct_ser.end() + } +} +impl<'de> serde::Deserialize<'de> for GetStoryOpenGraphDataRequest { + #[allow(deprecated)] + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + const FIELDS: &[&str] = &[ + "id", + ]; + + #[allow(clippy::enum_variant_names)] + enum GeneratedField { + Id, + } + impl<'de> serde::Deserialize<'de> for GeneratedField { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + struct GeneratedVisitor; + + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = GeneratedField; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "expected one of: {:?}", &FIELDS) + } + + #[allow(unused_variables)] + fn visit_str(self, value: &str) -> std::result::Result + where + E: serde::de::Error, + { + match value { + "id" => Ok(GeneratedField::Id), + _ => Err(serde::de::Error::unknown_field(value, FIELDS)), + } + } + } + deserializer.deserialize_identifier(GeneratedVisitor) + } + } + struct GeneratedVisitor; + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = GetStoryOpenGraphDataRequest; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("struct open_graph_def.v1.GetStoryOpenGraphDataRequest") + } + + fn visit_map(self, mut map: V) -> std::result::Result + where + V: serde::de::MapAccess<'de>, + { + let mut id__ = None; + while let Some(k) = map.next_key()? { + match k { + GeneratedField::Id => { + if id__.is_some() { + return Err(serde::de::Error::duplicate_field("id")); + } + id__ = Some(map.next_value()?); + } + } + } + Ok(GetStoryOpenGraphDataRequest { + id: id__.unwrap_or_default(), + }) + } + } + deserializer.deserialize_struct("open_graph_def.v1.GetStoryOpenGraphDataRequest", FIELDS, GeneratedVisitor) + } +} +impl serde::Serialize for GetStoryOpenGraphDataResponse { + #[allow(deprecated)] + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + let mut len = 0; + if !self.id.is_empty() { + len += 1; + } + if !self.title.is_empty() { + len += 1; + } + if self.description.is_some() { + len += 1; + } + if self.splash_id.is_some() { + len += 1; + } + if self.like_count != 0 { + len += 1; + } + if self.read_count != 0 { + len += 1; + } + if self.comment_count != 0 { + len += 1; + } + if self.is_private { + len += 1; + } + if !self.user_name.is_empty() { + len += 1; + } + if self.user_avatar_id.is_some() { + len += 1; + } + let mut struct_ser = serializer.serialize_struct("open_graph_def.v1.GetStoryOpenGraphDataResponse", len)?; + if !self.id.is_empty() { + struct_ser.serialize_field("id", &self.id)?; + } + if !self.title.is_empty() { + struct_ser.serialize_field("title", &self.title)?; + } + if let Some(v) = self.description.as_ref() { + struct_ser.serialize_field("description", v)?; + } + if let Some(v) = self.splash_id.as_ref() { + struct_ser.serialize_field("splashId", v)?; + } + if self.like_count != 0 { + struct_ser.serialize_field("likeCount", &self.like_count)?; + } + if self.read_count != 0 { + struct_ser.serialize_field("readCount", &self.read_count)?; + } + if self.comment_count != 0 { + struct_ser.serialize_field("commentCount", &self.comment_count)?; + } + if self.is_private { + struct_ser.serialize_field("isPrivate", &self.is_private)?; + } + if !self.user_name.is_empty() { + struct_ser.serialize_field("userName", &self.user_name)?; + } + if let Some(v) = self.user_avatar_id.as_ref() { + struct_ser.serialize_field("userAvatarId", v)?; + } + struct_ser.end() + } +} +impl<'de> serde::Deserialize<'de> for GetStoryOpenGraphDataResponse { + #[allow(deprecated)] + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + const FIELDS: &[&str] = &[ + "id", + "title", + "description", + "splash_id", + "splashId", + "like_count", + "likeCount", + "read_count", + "readCount", + "comment_count", + "commentCount", + "is_private", + "isPrivate", + "user_name", + "userName", + "user_avatar_id", + "userAvatarId", + ]; + + #[allow(clippy::enum_variant_names)] + enum GeneratedField { + Id, + Title, + Description, + SplashId, + LikeCount, + ReadCount, + CommentCount, + IsPrivate, + UserName, + UserAvatarId, + } + impl<'de> serde::Deserialize<'de> for GeneratedField { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + struct GeneratedVisitor; + + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = GeneratedField; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "expected one of: {:?}", &FIELDS) + } + + #[allow(unused_variables)] + fn visit_str(self, value: &str) -> std::result::Result + where + E: serde::de::Error, + { + match value { + "id" => Ok(GeneratedField::Id), + "title" => Ok(GeneratedField::Title), + "description" => Ok(GeneratedField::Description), + "splashId" | "splash_id" => Ok(GeneratedField::SplashId), + "likeCount" | "like_count" => Ok(GeneratedField::LikeCount), + "readCount" | "read_count" => Ok(GeneratedField::ReadCount), + "commentCount" | "comment_count" => Ok(GeneratedField::CommentCount), + "isPrivate" | "is_private" => Ok(GeneratedField::IsPrivate), + "userName" | "user_name" => Ok(GeneratedField::UserName), + "userAvatarId" | "user_avatar_id" => Ok(GeneratedField::UserAvatarId), + _ => Err(serde::de::Error::unknown_field(value, FIELDS)), + } + } + } + deserializer.deserialize_identifier(GeneratedVisitor) + } + } + struct GeneratedVisitor; + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = GetStoryOpenGraphDataResponse; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("struct open_graph_def.v1.GetStoryOpenGraphDataResponse") + } + + fn visit_map(self, mut map: V) -> std::result::Result + where + V: serde::de::MapAccess<'de>, + { + let mut id__ = None; + let mut title__ = None; + let mut description__ = None; + let mut splash_id__ = None; + let mut like_count__ = None; + let mut read_count__ = None; + let mut comment_count__ = None; + let mut is_private__ = None; + let mut user_name__ = None; + let mut user_avatar_id__ = None; + while let Some(k) = map.next_key()? { + match k { + GeneratedField::Id => { + if id__.is_some() { + return Err(serde::de::Error::duplicate_field("id")); + } + id__ = Some(map.next_value()?); + } + GeneratedField::Title => { + if title__.is_some() { + return Err(serde::de::Error::duplicate_field("title")); + } + title__ = Some(map.next_value()?); + } + GeneratedField::Description => { + if description__.is_some() { + return Err(serde::de::Error::duplicate_field("description")); + } + description__ = map.next_value()?; + } + GeneratedField::SplashId => { + if splash_id__.is_some() { + return Err(serde::de::Error::duplicate_field("splashId")); + } + splash_id__ = map.next_value()?; + } + GeneratedField::LikeCount => { + if like_count__.is_some() { + return Err(serde::de::Error::duplicate_field("likeCount")); + } + like_count__ = + Some(map.next_value::<::pbjson::private::NumberDeserialize<_>>()?.0) + ; + } + GeneratedField::ReadCount => { + if read_count__.is_some() { + return Err(serde::de::Error::duplicate_field("readCount")); + } + read_count__ = + Some(map.next_value::<::pbjson::private::NumberDeserialize<_>>()?.0) + ; + } + GeneratedField::CommentCount => { + if comment_count__.is_some() { + return Err(serde::de::Error::duplicate_field("commentCount")); + } + comment_count__ = + Some(map.next_value::<::pbjson::private::NumberDeserialize<_>>()?.0) + ; + } + GeneratedField::IsPrivate => { + if is_private__.is_some() { + return Err(serde::de::Error::duplicate_field("isPrivate")); + } + is_private__ = Some(map.next_value()?); + } + GeneratedField::UserName => { + if user_name__.is_some() { + return Err(serde::de::Error::duplicate_field("userName")); + } + user_name__ = Some(map.next_value()?); + } + GeneratedField::UserAvatarId => { + if user_avatar_id__.is_some() { + return Err(serde::de::Error::duplicate_field("userAvatarId")); + } + user_avatar_id__ = map.next_value()?; + } + } + } + Ok(GetStoryOpenGraphDataResponse { + id: id__.unwrap_or_default(), + title: title__.unwrap_or_default(), + description: description__, + splash_id: splash_id__, + like_count: like_count__.unwrap_or_default(), + read_count: read_count__.unwrap_or_default(), + comment_count: comment_count__.unwrap_or_default(), + is_private: is_private__.unwrap_or_default(), + user_name: user_name__.unwrap_or_default(), + user_avatar_id: user_avatar_id__, + }) + } + } + deserializer.deserialize_struct("open_graph_def.v1.GetStoryOpenGraphDataResponse", FIELDS, GeneratedVisitor) + } +} +impl serde::Serialize for GetTagOpenGraphDataRequest { + #[allow(deprecated)] + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + let mut len = 0; + if !self.id.is_empty() { + len += 1; + } + let mut struct_ser = serializer.serialize_struct("open_graph_def.v1.GetTagOpenGraphDataRequest", len)?; + if !self.id.is_empty() { + struct_ser.serialize_field("id", &self.id)?; + } + struct_ser.end() + } +} +impl<'de> serde::Deserialize<'de> for GetTagOpenGraphDataRequest { + #[allow(deprecated)] + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + const FIELDS: &[&str] = &[ + "id", + ]; + + #[allow(clippy::enum_variant_names)] + enum GeneratedField { + Id, + } + impl<'de> serde::Deserialize<'de> for GeneratedField { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + struct GeneratedVisitor; + + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = GeneratedField; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "expected one of: {:?}", &FIELDS) + } + + #[allow(unused_variables)] + fn visit_str(self, value: &str) -> std::result::Result + where + E: serde::de::Error, + { + match value { + "id" => Ok(GeneratedField::Id), + _ => Err(serde::de::Error::unknown_field(value, FIELDS)), + } + } + } + deserializer.deserialize_identifier(GeneratedVisitor) + } + } + struct GeneratedVisitor; + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = GetTagOpenGraphDataRequest; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("struct open_graph_def.v1.GetTagOpenGraphDataRequest") + } + + fn visit_map(self, mut map: V) -> std::result::Result + where + V: serde::de::MapAccess<'de>, + { + let mut id__ = None; + while let Some(k) = map.next_key()? { + match k { + GeneratedField::Id => { + if id__.is_some() { + return Err(serde::de::Error::duplicate_field("id")); + } + id__ = Some(map.next_value()?); + } + } + } + Ok(GetTagOpenGraphDataRequest { + id: id__.unwrap_or_default(), + }) + } + } + deserializer.deserialize_struct("open_graph_def.v1.GetTagOpenGraphDataRequest", FIELDS, GeneratedVisitor) + } +} +impl serde::Serialize for GetTagOpenGraphDataResponse { + #[allow(deprecated)] + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + let mut len = 0; + if !self.id.is_empty() { + len += 1; + } + if !self.name.is_empty() { + len += 1; + } + if self.story_count != 0 { + len += 1; + } + if self.follower_count != 0 { + len += 1; + } + let mut struct_ser = serializer.serialize_struct("open_graph_def.v1.GetTagOpenGraphDataResponse", len)?; + if !self.id.is_empty() { + struct_ser.serialize_field("id", &self.id)?; + } + if !self.name.is_empty() { + struct_ser.serialize_field("name", &self.name)?; + } + if self.story_count != 0 { + struct_ser.serialize_field("storyCount", &self.story_count)?; + } + if self.follower_count != 0 { + struct_ser.serialize_field("followerCount", &self.follower_count)?; + } + struct_ser.end() + } +} +impl<'de> serde::Deserialize<'de> for GetTagOpenGraphDataResponse { + #[allow(deprecated)] + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + const FIELDS: &[&str] = &[ + "id", + "name", + "story_count", + "storyCount", + "follower_count", + "followerCount", + ]; + + #[allow(clippy::enum_variant_names)] + enum GeneratedField { + Id, + Name, + StoryCount, + FollowerCount, + } + impl<'de> serde::Deserialize<'de> for GeneratedField { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + struct GeneratedVisitor; + + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = GeneratedField; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "expected one of: {:?}", &FIELDS) + } + + #[allow(unused_variables)] + fn visit_str(self, value: &str) -> std::result::Result + where + E: serde::de::Error, + { + match value { + "id" => Ok(GeneratedField::Id), + "name" => Ok(GeneratedField::Name), + "storyCount" | "story_count" => Ok(GeneratedField::StoryCount), + "followerCount" | "follower_count" => Ok(GeneratedField::FollowerCount), + _ => Err(serde::de::Error::unknown_field(value, FIELDS)), + } + } + } + deserializer.deserialize_identifier(GeneratedVisitor) + } + } + struct GeneratedVisitor; + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = GetTagOpenGraphDataResponse; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("struct open_graph_def.v1.GetTagOpenGraphDataResponse") + } + + fn visit_map(self, mut map: V) -> std::result::Result + where + V: serde::de::MapAccess<'de>, + { + let mut id__ = None; + let mut name__ = None; + let mut story_count__ = None; + let mut follower_count__ = None; + while let Some(k) = map.next_key()? { + match k { + GeneratedField::Id => { + if id__.is_some() { + return Err(serde::de::Error::duplicate_field("id")); + } + id__ = Some(map.next_value()?); + } + GeneratedField::Name => { + if name__.is_some() { + return Err(serde::de::Error::duplicate_field("name")); + } + name__ = Some(map.next_value()?); + } + GeneratedField::StoryCount => { + if story_count__.is_some() { + return Err(serde::de::Error::duplicate_field("storyCount")); + } + story_count__ = + Some(map.next_value::<::pbjson::private::NumberDeserialize<_>>()?.0) + ; + } + GeneratedField::FollowerCount => { + if follower_count__.is_some() { + return Err(serde::de::Error::duplicate_field("followerCount")); + } + follower_count__ = + Some(map.next_value::<::pbjson::private::NumberDeserialize<_>>()?.0) + ; + } + } + } + Ok(GetTagOpenGraphDataResponse { + id: id__.unwrap_or_default(), + name: name__.unwrap_or_default(), + story_count: story_count__.unwrap_or_default(), + follower_count: follower_count__.unwrap_or_default(), + }) + } + } + deserializer.deserialize_struct("open_graph_def.v1.GetTagOpenGraphDataResponse", FIELDS, GeneratedVisitor) + } +} diff --git a/src/routes/favicon.rs b/src/routes/favicon.rs index 93959ca..894e100 100644 --- a/src/routes/favicon.rs +++ b/src/routes/favicon.rs @@ -1,5 +1,9 @@ use crate::error::AppError; -use actix_web::{get, web, HttpResponse}; +use actix_web::{ + get, + web, + HttpResponse, +}; use tracing::error; const FAVICON: &[u8] = include_bytes!("../../static/favicon.ico"); diff --git a/src/routes/health.rs b/src/routes/health.rs index 9e702af..36eb1a4 100644 --- a/src/routes/health.rs +++ b/src/routes/health.rs @@ -1,5 +1,9 @@ use crate::error::AppError; -use actix_web::{get, web, HttpResponse}; +use actix_web::{ + get, + web, + HttpResponse, +}; #[get("/health")] #[tracing::instrument(name = "GET /health", skip_all, err)] diff --git a/src/routes/index.rs b/src/routes/index.rs index 6063761..04c71ec 100644 --- a/src/routes/index.rs +++ b/src/routes/index.rs @@ -1,5 +1,9 @@ use crate::error::AppError; -use actix_web::{get, web, HttpResponse}; +use actix_web::{ + get, + web, + HttpResponse, +}; #[get("/")] #[tracing::instrument(name = "GET /", skip_all, err)] diff --git a/src/routes/mod.rs b/src/routes/mod.rs index f607898..f8ad3f8 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -4,6 +4,8 @@ mod favicon; mod health; mod index; mod robots; +mod stories; +mod tags; /// Registers the routes. /// @@ -13,4 +15,6 @@ pub fn init_routes(cfg: &mut web::ServiceConfig) { health::init_routes(cfg); favicon::init_routes(cfg); robots::init_routes(cfg); + tags::init_routes(cfg); + stories::init_routes(cfg); } diff --git a/src/routes/robots.rs b/src/routes/robots.rs index 851ef06..cef1a1a 100644 --- a/src/routes/robots.rs +++ b/src/routes/robots.rs @@ -1,5 +1,10 @@ use crate::error::AppError; -use actix_web::{get, http::header::ContentType, web, HttpResponse}; +use actix_web::{ + get, + http::header::ContentType, + web, + HttpResponse, +}; const ROBOTS_TXT: &str = include_str!("../../static/robots.txt"); diff --git a/src/routes/stories.rs b/src/routes/stories.rs new file mode 100644 index 0000000..e68885d --- /dev/null +++ b/src/routes/stories.rs @@ -0,0 +1,291 @@ +use crate::{ + config::Config, + constants::{ + fonts::{ + CABINET_GROTESK_EXTRABOLD, + SATOSHI_MEDIUM, + SATOSHI_REGULAR, + }, + icons::DEFAULT_AVATAR, + }, + error::AppError, + grpc::defs::open_graph_def::v1::{ + GetStoryOpenGraphDataRequest, + GetStoryOpenGraphDataResponse, + }, + utils::{ + construct_svg_tree, + escape_string, + get_pixmap, + get_text_width, + get_thumbnail_url, + process_text, + truncate_text, + wrap_text, + }, + AppState, + StoryTemplate, +}; +use abbrev_num::abbrev_num; +use actix_http::StatusCode; +use actix_web::{ + get, + http::header::{ + CacheControl, + CacheDirective, + }, + web, + HttpResponse, +}; +use resvg::render; +use sailfish::TemplateOnce; +use serde::Deserialize; +use tiny_skia::Transform; +use tonic::Code; + +#[derive(Deserialize)] +struct Fragments { + id: String, +} + +#[get("/stories/{id}")] +#[tracing::instrument( + name = "GET /stories/{id}", + skip_all, + fields( + id = %path.id + ), + err +)] +async fn get( + path: web::Path, + state: web::Data, +) -> Result { + let id = path + .id + .parse::() + .map_err(|_| AppError::from("Invalid story ID"))?; + + let response = { + let grpc_client = &state.grpc_client; + let mut client = grpc_client.lock().await; + + client + .get_story_open_graph_data(tonic::Request::new(GetStoryOpenGraphDataRequest { + id: id.to_string(), + })) + .await + .map_err(|status| { + if matches!(status.code(), Code::NotFound) { + AppError::ClientError(StatusCode::NOT_FOUND, "Story not found".to_string()) + } else { + AppError::TonicError(status) + } + })? + .into_inner() + }; + + if response.is_private { + return Ok(HttpResponse::Forbidden().body("This story is private")); + } + + let bytes = generate_story_open_graph_image(&state.config, response).await?; + let mut builder = HttpResponse::Ok(); + + builder.insert_header(CacheControl(vec![ + CacheDirective::Public, + CacheDirective::MaxAge(86400_u32), // 24 hours + ])); + + Ok(builder.content_type(mime::IMAGE_PNG).body(bytes)) +} + +/// Renders the open graph image for a story. +/// +/// * `config` - The environment configuration. +/// * `response` - The story open graph response. +async fn generate_story_open_graph_image( + config: &Config, + response: GetStoryOpenGraphDataResponse, +) -> Result, AppError> { + let mut pixmap = get_pixmap()?; + + let splash_url = response + .splash_id + .map(|id| get_thumbnail_url(&config.cdn_server_url, id.as_str())); + let user_avatar_url = response + .user_avatar_id + .map(|id| get_thumbnail_url(&config.cdn_server_url, id.as_str())) + .unwrap_or(DEFAULT_AVATAR.to_string()); + + let read_count = abbrev_num(response.read_count as isize, None).unwrap_or_default(); + let like_count = abbrev_num(response.like_count as isize, None).unwrap_or_default(); + let comment_count = abbrev_num(response.comment_count as isize, None).unwrap_or_default(); + + let mut title_offset = "0"; + + let title = { + let title_lines = wrap_text(&process_text(&response.title), 920, Some(2), |text| { + get_text_width(&CABINET_GROTESK_EXTRABOLD, 46.0, text) as usize + }); + + if title_lines.len() == 1 { + title_offset = "-60"; + } + + title_lines + .iter() + .enumerate() + .map(|(index, line)| { + format!( + r#"{}"#, + if index == 0 { + r#"y="81.57""# + } else { + r#"dy="1.25em""# + }, + escape_string(line) + ) + }) + .collect::>() + .join("") + }; + + let description = response.description.map(|value| { + wrap_text( + &process_text(value.as_str()), + if splash_url.is_some() { 430 } else { 760 }, + Some(3), + |text| get_text_width(&SATOSHI_REGULAR, 30.0, text) as usize, + ) + .iter() + .enumerate() + .map(|(index, line)| { + format!( + r#"{}"#, + if index == 0 { + r#"y="308.133""# + } else { + r#"dy="1.3em""# + }, + escape_string(line) + ) + }) + .collect::>() + .join("") + }); + + let user_name = truncate_text( + &process_text(&response.user_name), + if splash_url.is_some() { 360 } else { 640 }, + |text| get_text_width(&SATOSHI_MEDIUM, 32.0, text) as usize, + ); + + let svg = StoryTemplate { + title, + description, + splash_url, + read_count, + like_count, + comment_count, + user_name, + user_avatar_url, + title_offset: title_offset.to_string(), + } + .render_once()?; + + let tree = construct_svg_tree(&svg).await?; + + render(&tree, Transform::default(), &mut pixmap.as_mut()); + + let bytes = pixmap.encode_png()?; + + Ok(bytes) +} + +pub fn init_routes(cfg: &mut web::ServiceConfig) { + cfg.service(get); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::get_app_config; + use image::EncodableLayout; + + #[tokio::test] + async fn can_generate_open_graph_image_for_a_story() { + let config = get_app_config().unwrap(); + let bytes = generate_story_open_graph_image( + &config, + GetStoryOpenGraphDataResponse { + id: "0".to_string(), + title: "The quick brown fox jumps over the lazy dog's back, vaulting effortlessly, while the hazy sky blankets the quaint village, igniting whispered conversations amongst jovial townsfolk.".to_string(), + description: Some("Jumbled thoughts quickly zapped by fox, quirky vixen whispers joyfully; amid fancy, exploring grand, hazy jungles, kickboxing lions menace, nervously observing playful quetzals, rambunctious sloths, tigers, ungulates, vexing wolverines, xylophones yielding zany antics.".to_string()), + splash_id: None, + read_count: 9_600_000, + like_count: 120_000, + comment_count: 1_890, + is_private: false, + user_name: "Ian Weaver".to_string(), + user_avatar_id: None, + }, + ) + .await + .unwrap(); + + let og_image = image::load_from_memory(bytes.as_bytes()) + .expect("unable to load the open graph result") + .into_rgb8(); + + let expected_image = image::open("fixtures/story.png") + .expect("could not find the fixture image") + .into_rgb8(); + + let result = image_compare::rgb_hybrid_compare(&og_image, &expected_image) + .expect("images had different dimensions"); + + assert_eq!(result.score, 1.0); + } + + #[tokio::test] + async fn can_generate_open_graph_image_for_a_story_with_external_images() { + let config = get_app_config().unwrap(); + let bytes = generate_story_open_graph_image( + &config, + GetStoryOpenGraphDataResponse { + id: "0".to_string(), + title: "The quick brown fox jumps over the lazy dog's back, vaulting effortlessly, while the hazy sky blankets the quaint village, igniting whispered conversations amongst jovial townsfolk.".to_string(), + description: Some("Jumbled thoughts quickly zapped by fox, quirky vixen whispers joyfully; amid fancy, exploring grand, hazy jungles, kickboxing lions menace, nervously observing playful quetzals, rambunctious sloths, tigers, ungulates, vexing wolverines, xylophones yielding zany antics.".to_string()), + splash_id: Some( + "https://gist.github.com/assets/77036902/03350aca-511c-4007-9eff-1bddcd7b5c33" + .to_string(), + ), + read_count: 9_600_000, + like_count: 120_000, + comment_count: 1_890, + is_private: false, + user_name: "Ian Weaver".to_string(), + user_avatar_id: Some( + "https://gist.github.com/assets/77036902/23e75ee6-25d6-4844-b005-b17a099cebd6" + .to_string(), + ), + }, + ) + .await + .unwrap(); + + let og_image = image::load_from_memory(bytes.as_bytes()) + .expect("unable to load the open graph result") + .into_rgb8(); + + let expected_image = image::open("fixtures/story_with_external_images.png") + .expect("could not find the fixture image") + .into_rgb8(); + + let result = image_compare::rgb_hybrid_compare(&og_image, &expected_image) + .expect("images had different dimensions"); + + assert_eq!(result.score, 1.0); + } +} diff --git a/src/routes/tags.rs b/src/routes/tags.rs new file mode 100644 index 0000000..3b4a858 --- /dev/null +++ b/src/routes/tags.rs @@ -0,0 +1,151 @@ +use crate::{ + constants::fonts::CABINET_GROTESK_EXTRABOLD, + error::AppError, + grpc::defs::open_graph_def::v1::{ + GetTagOpenGraphDataRequest, + GetTagOpenGraphDataResponse, + }, + utils::{ + construct_svg_tree, + get_pixmap, + get_text_width, + process_text, + truncate_text, + }, + AppState, + TagTemplate, +}; +use abbrev_num::abbrev_num; +use actix_http::StatusCode; +use actix_web::{ + get, + http::header::{ + CacheControl, + CacheDirective, + }, + web, + HttpResponse, +}; +use resvg::render; +use sailfish::TemplateOnce; +use serde::Deserialize; +use tiny_skia::Transform; +use tonic::Code; + +#[derive(Deserialize)] +struct Fragments { + id: String, +} + +#[get("/tags/{id}")] +#[tracing::instrument( + name = "GET /tags/{id}", + skip_all, + fields( + id = %path.id + ), + err +)] +async fn get( + path: web::Path, + state: web::Data, +) -> Result { + let id = path + .id + .parse::() + .map_err(|_| AppError::from("Invalid tag ID"))?; + + let response = { + let grpc_client = &state.grpc_client; + let mut client = grpc_client.lock().await; + + client + .get_tag_open_graph_data(tonic::Request::new(GetTagOpenGraphDataRequest { + id: id.to_string(), + })) + .await + .map_err(|status| { + if matches!(status.code(), Code::NotFound) { + AppError::ClientError(StatusCode::NOT_FOUND, "Tag not found".to_string()) + } else { + AppError::TonicError(status) + } + })? + .into_inner() + }; + + let bytes = generate_tag_open_graph_image(response).await?; + let mut builder = HttpResponse::Ok(); + + builder.insert_header(CacheControl(vec![ + CacheDirective::Public, + CacheDirective::MaxAge(86400_u32), // 24 hours + ])); + + Ok(builder.content_type(mime::IMAGE_PNG).body(bytes)) +} + +/// Renders the open graph image for a tag. +/// +/// * `response` - The tag open graph response. +async fn generate_tag_open_graph_image( + response: GetTagOpenGraphDataResponse, +) -> Result, AppError> { + let mut pixmap = get_pixmap()?; + + let story_count = abbrev_num(response.story_count as isize, None).unwrap_or_default(); + let follower_count = abbrev_num(response.follower_count as isize, None).unwrap_or_default(); + let name = truncate_text(&process_text(&response.name), 900, |text| { + get_text_width(&CABINET_GROTESK_EXTRABOLD, 112.0, &format!("#{text}")) as usize + }); + + let svg = TagTemplate { + name, + story_count, + follower_count, + } + .render_once()?; + + let tree = construct_svg_tree(&svg).await?; + + render(&tree, Transform::default(), &mut pixmap.as_mut()); + + let bytes = pixmap.encode_png()?; + + Ok(bytes) +} + +pub fn init_routes(cfg: &mut web::ServiceConfig) { + cfg.service(get); +} + +#[cfg(test)] +mod tests { + use super::*; + use image::EncodableLayout; + + #[tokio::test] + async fn can_generate_open_graph_image_for_a_tag() { + let bytes = generate_tag_open_graph_image(GetTagOpenGraphDataResponse { + id: "0".to_string(), + name: "The quick brown fox jumped over the lazy dogs".to_string(), + story_count: 1_344, + follower_count: 1_520_000, + }) + .await + .unwrap(); + + let og_image = image::load_from_memory(bytes.as_bytes()) + .expect("unable to load the open graph result") + .into_rgb8(); + + let expected_image = image::open("fixtures/tag.png") + .expect("could not find the fixture image") + .into_rgb8(); + + let result = image_compare::rgb_hybrid_compare(&og_image, &expected_image) + .expect("images had different dimensions"); + + assert_eq!(result.score, 1.0); + } +} diff --git a/src/telemetry.rs b/src/telemetry.rs index 7273063..ed42d4c 100644 --- a/src/telemetry.rs +++ b/src/telemetry.rs @@ -1,7 +1,18 @@ use actix_web::rt::task::JoinHandle; -use tracing::{subscriber::set_global_default, Subscriber}; -use tracing_bunyan_formatter::{BunyanFormattingLayer, JsonStorageLayer}; -use tracing_subscriber::{fmt::MakeWriter, layer::SubscriberExt, EnvFilter, Registry}; +use tracing::{ + subscriber::set_global_default, + Subscriber, +}; +use tracing_bunyan_formatter::{ + BunyanFormattingLayer, + JsonStorageLayer, +}; +use tracing_subscriber::{ + fmt::MakeWriter, + layer::SubscriberExt, + EnvFilter, + Registry, +}; /// Composes multiple layers into a tracing's subscriber. /// diff --git a/src/utils/construct_svg_tree.rs b/src/utils/construct_svg_tree.rs new file mode 100644 index 0000000..9bb306f --- /dev/null +++ b/src/utils/construct_svg_tree.rs @@ -0,0 +1,65 @@ +use crate::error::AppError; +use lazy_static::lazy_static; +use reqwest::header::ACCEPT; +use std::{ + ops::Deref, + sync::Arc, +}; +use usvg::{ + fontdb, + ImageHrefResolver, + ImageKind, + Options, + Tree, +}; + +lazy_static! { + static ref REQUEST_CLIENT : reqwest::blocking::Client = reqwest::blocking::Client::new(); + // + static ref OPTIONS: Options = Options { + image_href_resolver: ImageHrefResolver { + resolve_string: Box::new(move |path: &str, _, _| { + let res = REQUEST_CLIENT.get(path).header(ACCEPT, "image/png, image/jpeg").send().ok()?; + let content_type = res + .headers() + .get("content-type") + .and_then(|value| value.to_str().ok())? + .to_owned(); + let buffer = res.bytes().ok()?.into_iter().collect::>(); + + match content_type.as_str() { + "image/png" => Some(ImageKind::PNG(Arc::new(buffer))), + "image/jpeg" => Some(ImageKind::JPEG(Arc::new(buffer))), + _ => None, + } + }), + ..Default::default() + }, + ..Default::default() + }; + // + static ref FONT_DB: fontdb::Database = { + let mut font_db = fontdb::Database::new(); + font_db.load_fonts_dir("fonts"); + font_db + }; +} + +/// Constructs a SVG tree using the provided SVG string. +/// +/// # Image fetching behaviour +/// +/// Only PNG and JPEG images are resolved. Other image formats are ignored. +/// +/// * `svg_string` - The SVG string. +#[tracing::instrument(err)] +pub async fn construct_svg_tree(svg_string: &str) -> Result { + let svg_string = svg_string.to_owned(); + + tokio::task::spawn_blocking(move || { + Tree::from_str(&svg_string, OPTIONS.deref(), FONT_DB.deref()) + .map_err(|err| AppError::InternalError(format!("unable to parse the svg: {err:?}"))) + }) + .await + .map_err(|err| AppError::InternalError(format!("unable to complete the task: {err:?}")))? +} diff --git a/src/utils/escape_string.rs b/src/utils/escape_string.rs new file mode 100644 index 0000000..ffe8e9c --- /dev/null +++ b/src/utils/escape_string.rs @@ -0,0 +1,21 @@ +use sailfish::runtime::escape::escape_to_string; + +/// Replaces the characters `&"'<>` in the provided text with the equivalent HTML. +/// +/// * `text` - The text to escape. +pub fn escape_string(text: &str) -> String { + let mut buffer = String::new(); + escape_to_string(text, &mut buffer); + buffer +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn can_escape_a_string() { + let result = escape_string("

Hello, world!"); + assert_eq!(result, "<h1>Hello, world!</ h1>"); + } +} diff --git a/src/utils/get_pixmap.rs b/src/utils/get_pixmap.rs new file mode 100644 index 0000000..1391b7b --- /dev/null +++ b/src/utils/get_pixmap.rs @@ -0,0 +1,15 @@ +use crate::{ + constants::dimensions::{ + IMG_HEIGHT, + IMG_WIDTH, + }, + error::AppError, +}; +use tiny_skia::Pixmap; + +/// Allocates and returns a new [Pixmap] using the default [IMG_WIDTH] and [IMG_HEIGHT] values. +pub fn get_pixmap() -> Result { + Pixmap::new(IMG_WIDTH, IMG_HEIGHT).ok_or(AppError::InternalError( + "pixmap allocation error".to_string(), + )) +} diff --git a/src/utils/get_text_width.rs b/src/utils/get_text_width.rs new file mode 100644 index 0000000..2cb4b43 --- /dev/null +++ b/src/utils/get_text_width.rs @@ -0,0 +1,31 @@ +use rusttype::{ + point, + Font, + Scale, +}; + +/// Computes the width (in pixels) of a given text using the provided font size and family. +/// +/// See https://stackoverflow.com/a/68154492/22683234 +/// +/// * `font` - The font instance to use for measuring the width. +/// * `font_size` - The font size value. +/// * `text` - The target text. +pub fn get_text_width(font: &Font, font_size: f32, text: &str) -> f32 { + font.layout(text, Scale::uniform(font_size), point(0.0, 0.0)) + .last() + .map(|glyph| glyph.position().x + glyph.unpositioned().h_metrics().advance_width) + .unwrap_or(0.0) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::constants::fonts::SATOSHI_REGULAR; + + #[test] + fn can_compute_text_width() { + let text_width = get_text_width(&SATOSHI_REGULAR, 16.0, "Hello world"); + assert_eq!(format!("{:.1}", text_width), "63.7".to_string()); + } +} diff --git a/src/utils/get_thumbnail_url.rs b/src/utils/get_thumbnail_url.rs new file mode 100644 index 0000000..989d193 --- /dev/null +++ b/src/utils/get_thumbnail_url.rs @@ -0,0 +1,12 @@ +/// Returns the thumbnail URL of the image mapped to provided ID. +/// +/// * `cdn_server_url` - The URL of the CDN server. +/// * `id` - The public key of the image. +pub fn get_thumbnail_url(cdn_server_url: &str, id: &str) -> String { + if cfg!(test) { + // Return the ID during tests. + id.to_string() + } else { + format!("{cdn_server_url}/thumb/{id}") + } +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs new file mode 100644 index 0000000..7415c30 --- /dev/null +++ b/src/utils/mod.rs @@ -0,0 +1,17 @@ +mod construct_svg_tree; +mod escape_string; +mod get_pixmap; +mod get_text_width; +mod get_thumbnail_url; +mod process_text; +mod truncate_text; +mod wrap_text; + +pub use construct_svg_tree::*; +pub use escape_string::*; +pub use get_pixmap::*; +pub use get_text_width::*; +pub use get_thumbnail_url::*; +pub use process_text::*; +pub use truncate_text::*; +pub use wrap_text::*; diff --git a/src/utils/process_text.rs b/src/utils/process_text.rs new file mode 100644 index 0000000..953f87d --- /dev/null +++ b/src/utils/process_text.rs @@ -0,0 +1,20 @@ +use unidecode::unidecode; + +/// Normalizes all non-ASCII characters in the text. This serves as a temporary solution to the +/// rendering complications for complex characters and emojis. +/// +/// * `text` - The text to normalize. +pub fn process_text(text: &str) -> String { + unidecode(text) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn can_normalize_a_text() { + let result = process_text("Æneid"); + assert_eq!(result, "AEneid".to_string()); + } +} diff --git a/src/utils/truncate_text.rs b/src/utils/truncate_text.rs new file mode 100644 index 0000000..df43a16 --- /dev/null +++ b/src/utils/truncate_text.rs @@ -0,0 +1,50 @@ +use unicode_segmentation::UnicodeSegmentation; + +/// Truncates the given text to fit inside the provided `max_width` value. The `compute_text_width` +/// should return the width of the text in pixels. +/// +/// * `text` - The text to truncate. +/// * `max_width` - The maximum width in pixels. +/// * `compute_text_width` - The closure to compute width of the text passed as an argument in +/// pixels. +pub fn truncate_text(text: &str, max_width: u32, compute_text_width: F) -> String +where + F: Fn(&str) -> usize, +{ + let mut text = text.to_owned(); + let text_width = compute_text_width(&text); + let text_chars = text.graphemes(true).count().max(1); // Total number of characters. + let char_width = text_width / text_chars; // Average width of each character in pixels. + // Maximum number of characters that can fit inside the given `max_width` value. + let mut fitting_chars = ((max_width / char_width as u32) as f32).floor() as usize; + + // Remove the remaining characters. + if text_chars > fitting_chars { + // For `…` at the end + fitting_chars = fitting_chars.saturating_sub(1); + text.truncate(fitting_chars); + text = format!("{text}…"); + } + + text +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + constants::fonts::SATOSHI_REGULAR, + utils::get_text_width, + }; + + #[test] + fn can_truncate_a_text() { + let result = truncate_text( + "a sufficiently long string to perform truncation", + 128, + |text| get_text_width(&SATOSHI_REGULAR, 16.0, text) as usize, + ); + + assert_eq!(result, "a sufficiently long stri…".to_string()); + } +} diff --git a/src/utils/wrap_text.rs b/src/utils/wrap_text.rs new file mode 100644 index 0000000..2b7ba24 --- /dev/null +++ b/src/utils/wrap_text.rs @@ -0,0 +1,99 @@ +use textwrap::wrap; +use unicode_segmentation::UnicodeSegmentation; + +/// Wraps the text into multiple lines (upto a maximum of `max_lines` if provided) to fit inside the +/// provided `max_width` value. +/// +/// * `text` - The text to wrap. +/// * `max_width` - The maximum width of the text container in pixels. +/// * `max_lines` - The maximum lines to wrap. +/// * `compute_text_width` - The closure to compute width of the text passed as an argument in +/// pixels. +pub fn wrap_text( + text: &str, + max_width: u32, + max_lines: Option, + compute_text_width: F, +) -> Vec +where + F: Fn(&str) -> usize, +{ + let text_width = compute_text_width(text); + let text_chars = text.graphemes(true).count(); + let char_width = text_width / text_chars.max(1); // Average width of each character in pixels. + let columns = max_width / char_width as u32; + let lines = wrap(text, columns as usize); + + lines + .iter() + .enumerate() + .filter_map(|(index, line)| { + if let Some(max_lines) = max_lines { + let index = index as u32; + let max_index = max_lines - 1; + + if index > max_index { + return None; + } + + let is_last_line = index == max_index; + let has_next_line = lines.get((index + 1) as usize).is_some(); + + // Truncate the last line if next line is present. + if is_last_line && has_next_line { + let mut chars = line.chars(); + chars.next_back(); // Remove the last character for `…` + return Some(format!("{}…", chars.as_str())); + } + }; + + Some(line.to_string()) + }) + .collect::>() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + constants::fonts::SATOSHI_REGULAR, + utils::get_text_width, + }; + + #[test] + fn can_wrap_a_text() { + let result = wrap_text( + "The quick brown fox jumps over the lazy dog", + 108, + None, + |text| get_text_width(&SATOSHI_REGULAR, 16.0, text) as usize, + ); + + assert_eq!( + result, + vec![ + "The quick brown fox".to_string(), + "jumps over the lazy".to_string(), + "dog".to_string() + ] + ); + } + + #[test] + fn can_limit_the_number_of_lines() { + let result = wrap_text( + "The quick brown fox jumps over the lazy dog", + 108, + Some(2), + |text| get_text_width(&SATOSHI_REGULAR, 16.0, text) as usize, + ); + + assert_eq!( + result, + vec![ + "The quick brown fox".to_string(), + "jumps over the laz…".to_string(), + ] + ); + } +} diff --git a/templates/story.stpl b/templates/story.stpl new file mode 100644 index 0000000..01b3bd3 --- /dev/null +++ b/templates/story.stpl @@ -0,0 +1,64 @@ + + + + + + + <% if let Some(ref splash_url) = splash_url { %> + + + + + <% } %> + + + + + <% if splash_url.is_some() { %> + + <% } %> + + + + <%= read_count %> + Reads + + + <%= like_count %> + Likes + + + <%= comment_count %> + Comments + + + + + <%- title %> + + + <%= user_name %> + + <% if let Some(description) = description { %> + <%- description %> + <% } %> + diff --git a/templates/tag.stpl b/templates/tag.stpl new file mode 100644 index 0000000..e7f9459 --- /dev/null +++ b/templates/tag.stpl @@ -0,0 +1,30 @@ + + + + + + + + <%= story_count %> + Stories + + + <%= follower_count %> + Followers + + + + + #<%= name %> +