Skip to content

Commit

Permalink
Add meta endpoint (#1)
Browse files Browse the repository at this point in the history
* Add better internal resize target model
* Fix up empty query param handling
* Add image operations DSL and runner
* Add the endpoint
* Update readme

Signed-off-by: lloydmeta <[email protected]>
  • Loading branch information
lloydmeta authored Oct 30, 2024
1 parent 87f9229 commit 56bd061
Show file tree
Hide file tree
Showing 9 changed files with 396 additions and 41 deletions.
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@ To fulfil the above:

An example Terraform config in `terraform/prod` is provided to show how to deploy at a subdomain using Cloudflare as our (free!) CDN + WAF.

## Usage:

We only support resizing at the moment

1. An "image" endpoint [a la Thumbor](https://thumbor.readthedocs.io/en/latest/usage.html#image-endpoint)
2. A "metadata" endpoint [a la Thumbor](https://thumbor.readthedocs.io/en/latest/usage.html#metadata-endpoint)
* Difference: target image size is _not_ returned (might change in the future)

## Flow

1. Layer 1 validations (is the request well-formed?)
Expand Down Expand Up @@ -115,7 +123,6 @@ Use `Makefile` targets.
* https://imgproxy.net/blog/almost-free-image-processing-with-imgproxy-and-aws-lambda/
* https://zenn.dev/devneko/articles/0a6fb5c9ea5689
* https://crates.io/crates/image
* A [metadata endpoint](https://thumbor.readthedocs.io/en/stable/usage.html#metadata-endpoint)
* [Logs, tracing](https://github.com/tokio-rs/tracing?tab=readme-ov-file#in-applications)
* Improve image resizing:
* Encapsulate and test
Expand Down
12 changes: 6 additions & 6 deletions server/src/api/requests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,17 @@ use serde::{de, Deserialize, Deserializer};
pub(crate) struct Signature(pub(crate) String);

#[derive(Eq, PartialEq, Debug, Clone, Copy)]
pub struct ImageResize {
pub struct ImageResizePathParam {
pub target_width: i32,
pub target_height: i32,
}

impl<'de> serde::Deserialize<'de> for ImageResize {
impl<'de> serde::Deserialize<'de> for ImageResizePathParam {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
d.deserialize_str(ImageResizeVisitor)
}
}
impl serde::Serialize for ImageResize {
impl serde::Serialize for ImageResizePathParam {
fn serialize<S>(&self, s: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
Expand All @@ -30,7 +30,7 @@ struct ImageResizeVisitor;
const IMAGE_RESIZE_PARSE_ERROR: &str = "A string with two numbers and an x in between";

impl<'de> de::Visitor<'de> for ImageResizeVisitor {
type Value = ImageResize;
type Value = ImageResizePathParam;

fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str(IMAGE_RESIZE_PARSE_ERROR)
Expand All @@ -44,7 +44,7 @@ impl<'de> de::Visitor<'de> for ImageResizeVisitor {
}
}

impl FromStr for ImageResize {
impl FromStr for ImageResizePathParam {
type Err = anyhow::Error;

fn from_str(s: &str) -> Result<Self, Self::Err> {
Expand All @@ -55,7 +55,7 @@ impl FromStr for ImageResize {
let width: i32 = width_str.parse()?;
let height: i32 = height_str.parse()?;

Ok(ImageResize {
Ok(ImageResizePathParam {
target_width: width,
target_height: height,
})
Expand Down
94 changes: 94 additions & 0 deletions server/src/api/responses.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,100 @@
use serde::*;

use crate::infra::image_manipulation;

#[derive(Serialize)]
pub struct Standard {
pub message: String,
}

#[derive(Serialize, Deserialize, Eq, PartialEq, Debug)]
pub struct MetadataResponse {
pub source: Source,
pub operations: Vec<Operation>,
}

impl MetadataResponse {
pub fn build(url: &str, ops: &image_manipulation::Operations) -> Self {
let operations = ops
.0
.iter()
.map(|op| match *op {
image_manipulation::Operation::Resize { width, height } => Operation {
r#type: "resize".to_string(),
width: Some(width),
height: Some(height),
},
image_manipulation::Operation::FlipHorizontally => Operation {
r#type: "flip_horizontally".to_string(),
width: None,
height: None,
},
image_manipulation::Operation::FlipVertically => Operation {
r#type: "flip_vertically".to_string(),
width: None,
height: None,
},
})
.collect();

MetadataResponse {
source: Source {
url: url.to_string(),
},
operations,
}
}
}

#[derive(Serialize, Deserialize, Eq, PartialEq, Debug)]
pub struct Source {
pub url: String,
}

#[derive(Serialize, Deserialize, Eq, PartialEq, Debug)]
pub struct Operation {
pub r#type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub width: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub height: Option<u32>,
}

#[cfg(test)]
mod tests {
use crate::infra::image_caching::ImageResize;

use super::*;

#[test]
fn test_metadata_response_build() {
let domain = image_manipulation::Operations::build(&Some(ImageResize {
target_width: -100,
target_height: -300,
}));
let result = MetadataResponse::build("http://beachape.com/images/lol.png", &domain);
let expected = MetadataResponse {
source: Source {
url: "http://beachape.com/images/lol.png".to_string(),
},
operations: vec![
Operation {
r#type: "resize".to_string(),
width: Some(100),
height: Some(300),
},
Operation {
r#type: "flip_horizontally".to_string(),
width: None,
height: None,
},
Operation {
r#type: "flip_vertically".to_string(),
width: None,
height: None,
},
],
};
assert_eq!(expected, result)
}
}
57 changes: 38 additions & 19 deletions server/src/api/routing/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,16 @@ use reqwest::header::{CACHE_CONTROL, CONTENT_TYPE};
use responses::Standard;
use tower_http::catch_panic::CatchPanicLayer;

use crate::api::requests::{ImageResize, Signature};
use crate::api::responses;
use crate::api::requests::{ImageResizePathParam, Signature};
use crate::api::responses::{self, MetadataResponse};
use crate::infra::components::AppComponents;
use crate::infra::config::AuthenticationSettings;
use crate::infra::errors::AppError;
use crate::infra::image_caching::{
ImageCacher, ImageFetchRequest, ImageFetchedCacheRequest, ImageResizeRequest,
ImageResizedCacheRequest,
};
use crate::infra::image_manipulation::{Operations, OperationsRunner, SingletonOperationsRunner};

use miniaturs_shared::signature::{ensure_signature_is_valid_for_path_and_query, SignatureError};

Expand All @@ -39,6 +40,7 @@ pub fn create_router(app_components: AppComponents) -> Router {
.route("/", get(root))
.route("/health", get(health_check))
.route("/:signature/:resized_image/*image_url", get(resize))
.route("/:signature/meta/:resized_image/*image_url", get(metadata))
.fallback(handle_404)
.layer(CatchPanicLayer::custom(handle_panic))
.with_state(app_components)
Expand All @@ -53,17 +55,18 @@ async fn root() -> Json<Standard> {
async fn resize(
State(app_components): State<AppComponents>,
uri: Uri,
Path((signature, resized_image, image_url)): Path<(Signature, ImageResize, String)>,
Path((signature, resized_image, image_url)): Path<(Signature, ImageResizePathParam, String)>,
) -> Result<Response, AppError> {
ensure_signature_is_valid(
&app_components.config.authentication_settings,
&uri,
signature,
)?;
let operations = Operations::build(&Some(resized_image.into()));
let processed_image_request = {
ImageResizeRequest {
requested_image_url: image_url.clone(),
resize_target: resized_image,
operations,
}
};
let maybe_cached_resized_image = app_components
Expand Down Expand Up @@ -137,23 +140,16 @@ async fn resize(
let format = reader_with_format
.format()
.ok_or(AppError::UnableToDetermineFormat)?;
let mut dynamic_image = reader_with_format.decode()?;

dynamic_image = dynamic_image.resize(
resized_image.target_width as u32,
resized_image.target_height as u32,
image::imageops::FilterType::Gaussian,
);

if resized_image.target_width < 0 {
dynamic_image = dynamic_image.fliph();
}
if resized_image.target_height < 0 {
dynamic_image = dynamic_image.flipv();
}
let image = SingletonOperationsRunner
.run(
reader_with_format.decode()?,
&processed_image_request.operations,
)
.await;

let mut cursor = Cursor::new(Vec::new());
dynamic_image.write_to(&mut cursor, format)?;
image.write_to(&mut cursor, format)?;
let written_bytes = cursor.into_inner();

let cache_image_req = ImageResizedCacheRequest {
Expand Down Expand Up @@ -181,6 +177,24 @@ async fn resize(
}
}

async fn metadata(
State(app_components): State<AppComponents>,
uri: Uri,
Path((signature, resized_image, image_url)): Path<(Signature, ImageResizePathParam, String)>,
) -> Result<Response, AppError> {
ensure_signature_is_valid(
&app_components.config.authentication_settings,
&uri,
signature,
)?;

let operations = Operations::build(&Some(resized_image.into()));
let metadata = MetadataResponse::build(&image_url, &operations);
let mut response_headers = HeaderMap::new();
response_headers.insert(CACHE_CONTROL, CACHE_CONTROL_HEADER_VALUE);
Ok((StatusCode::OK, response_headers, Json(metadata)).into_response())
}

/// Example on how to return status codes and data from an Axum function
async fn health_check() -> (StatusCode, Json<Standard>) {
let health = true;
Expand Down Expand Up @@ -228,7 +242,12 @@ fn ensure_signature_is_valid(
.map(|pq| {
// lambda axum seems to insert empty query params when handling reqs
// as a lambda
pq.as_str().strip_suffix("?").unwrap_or(pq.as_str())
let pq_as_str = pq.as_str();
if uri.query().filter(|q| !q.trim().is_empty()).is_some() {
pq_as_str
} else {
pq_as_str.strip_suffix("?").unwrap_or(pq_as_str)
}
})
.unwrap_or("")
};
Expand Down
47 changes: 34 additions & 13 deletions server/src/infra/image_caching.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,34 @@ use serde::{de::DeserializeOwned, Deserialize, Serialize};
use serde_json;
use sha256;

use crate::api::requests::ImageResize;
use crate::api::requests::ImageResizePathParam;

use super::image_manipulation::Operations;

#[derive(Serialize, Deserialize, Eq, PartialEq, Debug, Clone)]
pub struct ImageResizeRequest {
pub requested_image_url: String,
pub resize_target: ImageResize,
pub operations: Operations,
}

#[derive(Serialize, Deserialize, Eq, PartialEq, Debug, Clone, Copy)]
pub struct ImageResize {
pub target_width: i32,
pub target_height: i32,
}

impl From<ImageResizePathParam> for ImageResize {
fn from(
ImageResizePathParam {
target_width,
target_height,
}: ImageResizePathParam,
) -> Self {
Self {
target_width,
target_height,
}
}
}

#[derive(Serialize, Deserialize, Eq, PartialEq, Debug)]
Expand Down Expand Up @@ -254,10 +276,10 @@ mod tests {
fn test_cache_key() -> TestResult {
let req = ImageResizeRequest {
requested_image_url: "https://beachape.com/images/something.png".to_string(),
resize_target: ImageResize {
operations: Operations::build(&Some(ImageResize {
target_width: 100,
target_height: 100,
},
})),
};
let key = req.cache_key();
assert!(key.0.len() < 1024);
Expand All @@ -269,10 +291,10 @@ mod tests {
let req = ImageResizedCacheRequest {
request: ImageResizeRequest {
requested_image_url: "https://beachape.com/images/something.png".to_string(),
resize_target: ImageResize {
operations: Operations::build(&Some(ImageResize {
target_width: 100,
target_height: 200,
},
})),
},
content_type: "image/png".to_string(),
};
Expand All @@ -297,10 +319,10 @@ mod tests {
let req = ImageResizeRequest {
requested_image_url: "https://beachape.com/images/something_that_does_not_exist.png"
.to_string(),
resize_target: ImageResize {
operations: Operations::build(&Some(ImageResize {
target_width: 100,
target_height: 100,
},
})),
};
let retrieved = s3_image_cacher.get(&req).await;
assert!(retrieved.is_none());
Expand All @@ -318,10 +340,10 @@ mod tests {

let req = ImageResizeRequest {
requested_image_url: "https://beachape.com/images/something.png".to_string(),
resize_target: ImageResize {
operations: Operations::build(&Some(ImageResize {
target_width: 100,
target_height: 100,
},
})),
};
let content = b"testcontent";
let image_set_req = ImageResizedCacheRequest {
Expand All @@ -348,11 +370,10 @@ mod tests {
};
let req = ImageResizeRequest {
requested_image_url: "https://beachape.com/images/something_else.png".to_string(),

resize_target: ImageResize {
operations: Operations::build(&Some(ImageResize {
target_width: 300,
target_height: 500,
},
})),
};
let content = b"testcontent";
let image_set_req = ImageResizedCacheRequest {
Expand Down
Loading

0 comments on commit 56bd061

Please sign in to comment.