BLOG-78 Backend image upload and download (#84)
All checks were successful
Frontend CI / build (push) Successful in 1m4s

### Description

- Add some endpoints about image:
  - POST `/image/upload`
  - GET `/image/{id}`

> [!NOTE]
> Since there isn't identity authentication, the `/image` endpoints should be restricted to private network in nginx.

> [!NOTE]
> Volume for backend should be configured in `pod.yaml`.

### Package Changes

```toml
actix-multipart = "0.7.2"
```

### Screenshots

_No response_

### Reference

Resolves #78

### Checklist

- [x] A milestone is set
- [x] The related issuse has been linked to this branch

Reviewed-on: #84
Co-authored-by: SquidSpirit <squid@squidspirit.com>
Co-committed-by: SquidSpirit <squid@squidspirit.com>
This commit is contained in:
SquidSpirit 2025-07-27 13:10:46 +08:00 committed by squid
parent f400bcf486
commit ab3050db69
51 changed files with 811 additions and 59 deletions

181
backend/Cargo.lock generated
View File

@ -34,7 +34,7 @@ dependencies = [
"brotli",
"bytes",
"bytestring",
"derive_more",
"derive_more 2.0.1",
"encoding_rs",
"flate2",
"foldhash",
@ -68,6 +68,44 @@ dependencies = [
"syn",
]
[[package]]
name = "actix-multipart"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d5118a26dee7e34e894f7e85aa0ee5080ae4c18bf03c0e30d49a80e418f00a53"
dependencies = [
"actix-multipart-derive",
"actix-utils",
"actix-web",
"derive_more 0.99.20",
"futures-core",
"futures-util",
"httparse",
"local-waker",
"log",
"memchr",
"mime",
"rand 0.8.5",
"serde",
"serde_json",
"serde_plain",
"tempfile",
"tokio",
]
[[package]]
name = "actix-multipart-derive"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e11eb847f49a700678ea2fa73daeb3208061afa2b9d1a8527c03390f4c4a1c6b"
dependencies = [
"darling",
"parse-size",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "actix-router"
version = "0.5.3"
@ -149,7 +187,7 @@ dependencies = [
"bytestring",
"cfg-if",
"cookie",
"derive_more",
"derive_more 2.0.1",
"encoding_rs",
"foldhash",
"futures-core",
@ -466,6 +504,12 @@ version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "convert_case"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e"
[[package]]
name = "cookie"
version = "0.16.2"
@ -541,6 +585,41 @@ dependencies = [
"typenum",
]
[[package]]
name = "darling"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
dependencies = [
"darling_core",
"darling_macro",
]
[[package]]
name = "darling_core"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
dependencies = [
"fnv",
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn",
]
[[package]]
name = "darling_macro"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
dependencies = [
"darling_core",
"quote",
"syn",
]
[[package]]
name = "der"
version = "0.7.10"
@ -561,6 +640,19 @@ dependencies = [
"powerfmt",
]
[[package]]
name = "derive_more"
version = "0.99.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f"
dependencies = [
"convert_case",
"proc-macro2",
"quote",
"rustc_version",
"syn",
]
[[package]]
name = "derive_more"
version = "2.0.1"
@ -744,6 +836,21 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "futures"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-channel"
version = "0.3.31"
@ -788,6 +895,17 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
[[package]]
name = "futures-macro"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "futures-sink"
version = "0.3.31"
@ -806,8 +924,10 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-macro",
"futures-sink",
"futures-task",
"memchr",
@ -1098,6 +1218,12 @@ dependencies = [
"syn",
]
[[package]]
name = "ident_case"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]]
name = "idna"
version = "1.0.3"
@ -1119,6 +1245,20 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "image"
version = "0.2.0"
dependencies = [
"actix-multipart",
"actix-web",
"async-trait",
"chrono",
"futures",
"log",
"serde",
"sqlx",
]
[[package]]
name = "impl-more"
version = "0.1.9"
@ -1413,6 +1553,12 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "parse-size"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "487f2ccd1e17ce8c1bfab3a65c89525af41cfad4c8659021a1e9a2aacd73b89b"
[[package]]
name = "pem-rfc7468"
version = "0.7.0"
@ -1676,6 +1822,15 @@ version = "0.1.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f"
[[package]]
name = "rustc_version"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
dependencies = [
"semver",
]
[[package]]
name = "rustix"
version = "1.0.7"
@ -1738,6 +1893,12 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "semver"
version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0"
[[package]]
name = "serde"
version = "1.0.219"
@ -1770,6 +1931,15 @@ dependencies = [
"serde",
]
[[package]]
name = "serde_plain"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50"
dependencies = [
"serde",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
@ -1789,6 +1959,7 @@ dependencies = [
"actix-web",
"dotenv",
"env_logger",
"image",
"percent-encoding",
"post",
"sqlx",
@ -2100,6 +2271,12 @@ dependencies = [
"unicode-properties",
]
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"

View File

@ -1,5 +1,5 @@
[workspace]
members = ["feature/post", "server"]
members = ["server", "feature/image", "feature/post"]
resolver = "2"
[workspace.package]
@ -7,6 +7,7 @@ version = "0.2.0"
edition = "2024"
[workspace.dependencies]
actix-multipart = "0.7.2"
actix-web = "4.10.2"
async-trait = "0.1.88"
chrono = "0.4.41"
@ -25,4 +26,5 @@ sqlx = { version = "0.8.5", features = [
tokio = { version = "1.45.0", features = ["full"] }
server.path = "server"
image.path = "feature/image"
post.path = "feature/post"

View File

@ -11,9 +11,11 @@ FROM alpine:latest AS runner
WORKDIR /app
COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/server .
EXPOSE 8080
VOLUME ["/app/static"]
ENV RUST_LOG=info
ENV HOST=0.0.0.0
ENV PORT=8080
ENV STORAGE_PATH=/app/static
ENV DATABASE_HOST=127.0.0.1
ENV DATABASE_PORT=5432
ENV DATABASE_USER=postgres

View File

@ -39,4 +39,3 @@
```bash
RUST_LOG=debug watchexec -e rs -r 'cargo run'
```

View File

@ -0,0 +1,14 @@
[package]
name = "image"
version.workspace = true
edition.workspace = true
[dependencies]
actix-multipart.workspace = true
actix-web.workspace = true
async-trait.workspace = true
chrono.workspace = true
futures.workspace = true
log.workspace = true
serde.workspace = true
sqlx.workspace = true

View File

@ -0,0 +1,2 @@
pub mod delivery;
pub mod gateway;

View File

@ -0,0 +1,4 @@
pub mod image_controller;
pub mod image_info_response_dto;
pub mod image_request_dto;
pub mod image_response_dto;

View File

@ -0,0 +1,82 @@
use std::sync::Arc;
use async_trait::async_trait;
use crate::{
adapter::delivery::{
image_info_response_dto::ImageInfoResponseDto, image_request_dto::ImageRequestDto,
image_response_dto::ImageResponseDto,
},
application::{
error::image_error::ImageError,
use_case::{
get_image_use_case::GetImageUseCase, upload_image_use_case::UploadImageUseCase,
},
},
};
#[async_trait]
pub trait ImageController: Send + Sync {
async fn upload_image(
&self,
image: ImageRequestDto,
) -> Result<ImageInfoResponseDto, ImageError>;
async fn get_image_by_id(&self, id: i32) -> Result<ImageResponseDto, ImageError>;
}
pub struct ImageControllerImpl {
upload_image_use_case: Arc<dyn UploadImageUseCase>,
get_image_use_case: Arc<dyn GetImageUseCase>,
mime_type_whitelist: Vec<String>,
}
impl ImageControllerImpl {
pub fn new(
upload_image_use_case: Arc<dyn UploadImageUseCase>,
get_image_use_case: Arc<dyn GetImageUseCase>,
) -> Self {
Self {
upload_image_use_case,
get_image_use_case,
mime_type_whitelist: vec![
"image/jpeg".to_string(),
"image/png".to_string(),
"image/gif".to_string(),
"image/webp".to_string(),
],
}
}
}
#[async_trait]
impl ImageController for ImageControllerImpl {
async fn upload_image(
&self,
image: ImageRequestDto,
) -> Result<ImageInfoResponseDto, ImageError> {
if !self.mime_type_whitelist.contains(&image.mime_type) {
return Err(ImageError::UnsupportedMimeType);
}
let mime_type = image.mime_type.clone();
let id = self
.upload_image_use_case
.execute(image.into_entity())
.await?;
Ok(ImageInfoResponseDto {
id: id,
mime_type: mime_type,
})
}
async fn get_image_by_id(&self, id: i32) -> Result<ImageResponseDto, ImageError> {
let image = self.get_image_use_case.execute(id).await?;
Ok(ImageResponseDto {
id: id,
mime_type: image.mime_type,
data: image.data,
})
}
}

View File

@ -0,0 +1,7 @@
use serde::Serialize;
#[derive(Serialize)]
pub struct ImageInfoResponseDto {
pub id: i32,
pub mime_type: String,
}

View File

@ -0,0 +1,16 @@
use crate::domain::entity::image::Image;
pub struct ImageRequestDto {
pub mime_type: String,
pub data: Vec<u8>,
}
impl ImageRequestDto {
pub fn into_entity(self) -> Image {
Image {
id: None,
mime_type: self.mime_type,
data: self.data,
}
}
}

View File

@ -0,0 +1,5 @@
pub struct ImageResponseDto {
pub id: i32,
pub mime_type: String,
pub data: Vec<u8>,
}

View File

@ -0,0 +1,4 @@
pub mod image_db_service;
pub mod image_db_mapper;
pub mod image_repository_impl;
pub mod image_storage;

View File

@ -0,0 +1,25 @@
use crate::domain::entity::image::Image;
pub struct ImageDbMapper {
pub id: Option<i32>,
pub mime_type: String,
}
impl ImageDbMapper {
pub fn into_entity(self) -> Image {
Image {
id: self.id,
mime_type: self.mime_type,
data: Vec::new(),
}
}
}
impl From<Image> for ImageDbMapper {
fn from(image: Image) -> Self {
ImageDbMapper {
id: image.id,
mime_type: image.mime_type,
}
}
}

View File

@ -0,0 +1,11 @@
use async_trait::async_trait;
use crate::{
adapter::gateway::image_db_mapper::ImageDbMapper, application::error::image_error::ImageError,
};
#[async_trait]
pub trait ImageDbService: Send + Sync {
async fn create_image_info(&self, image: ImageDbMapper) -> Result<i32, ImageError>;
async fn get_image_info_by_id(&self, id: i32) -> Result<ImageDbMapper, ImageError>;
}

View File

@ -0,0 +1,52 @@
use std::sync::Arc;
use async_trait::async_trait;
use crate::{
adapter::gateway::{
image_db_mapper::ImageDbMapper, image_db_service::ImageDbService,
image_storage::ImageStorage,
},
application::{error::image_error::ImageError, gateway::image_repository::ImageRepository},
domain::entity::image::Image,
};
pub struct ImageRepositoryImpl {
image_db_service: Arc<dyn ImageDbService>,
image_storage: Arc<dyn ImageStorage>,
}
impl ImageRepositoryImpl {
pub fn new(
image_db_service: Arc<dyn ImageDbService>,
image_storage: Arc<dyn ImageStorage>,
) -> Self {
Self {
image_db_service,
image_storage,
}
}
}
#[async_trait]
impl ImageRepository for ImageRepositoryImpl {
async fn save_image(&self, image: Image) -> Result<i32, ImageError> {
let data = image.data.clone();
let image_id = self
.image_db_service
.create_image_info(ImageDbMapper::from(image))
.await?;
self.image_storage.write_data(image_id, &data)?;
Ok(image_id)
}
async fn get_image_by_id(&self, id: i32) -> Result<Image, ImageError> {
let image_mapper = self.image_db_service.get_image_info_by_id(id).await?;
let data = self.image_storage.read_data(id)?;
Ok(Image {
id: image_mapper.id,
mime_type: image_mapper.mime_type,
data,
})
}
}

View File

@ -0,0 +1,6 @@
use crate::application::error::image_error::ImageError;
pub trait ImageStorage: Send + Sync {
fn write_data(&self, id: i32, data: &[u8]) -> Result<(), ImageError>;
fn read_data(&self, id: i32) -> Result<Vec<u8>, ImageError>;
}

View File

@ -0,0 +1,3 @@
pub mod error;
pub mod gateway;
pub mod use_case;

View File

@ -0,0 +1 @@
pub mod image_error;

View File

@ -0,0 +1,7 @@
#[derive(Debug, PartialEq)]
pub enum ImageError {
DatabaseError(String),
StorageError(String),
NotFound,
UnsupportedMimeType,
}

View File

@ -0,0 +1 @@
pub mod image_repository;

View File

@ -0,0 +1,9 @@
use async_trait::async_trait;
use crate::{application::error::image_error::ImageError, domain::entity::image::Image};
#[async_trait]
pub trait ImageRepository: Send + Sync {
async fn save_image(&self, image: Image) -> Result<i32, ImageError>;
async fn get_image_by_id(&self, id: i32) -> Result<Image, ImageError>;
}

View File

@ -0,0 +1,2 @@
pub mod get_image_use_case;
pub mod upload_image_use_case;

View File

@ -0,0 +1,30 @@
use std::sync::Arc;
use async_trait::async_trait;
use crate::{
application::{error::image_error::ImageError, gateway::image_repository::ImageRepository},
domain::entity::image::Image,
};
#[async_trait]
pub trait GetImageUseCase: Send + Sync {
async fn execute(&self, id: i32) -> Result<Image, ImageError>;
}
pub struct GetImageUseCaseImpl {
image_repository: Arc<dyn ImageRepository>,
}
impl GetImageUseCaseImpl {
pub fn new(image_repository: Arc<dyn ImageRepository>) -> Self {
Self { image_repository }
}
}
#[async_trait]
impl GetImageUseCase for GetImageUseCaseImpl {
async fn execute(&self, id: i32) -> Result<Image, ImageError> {
self.image_repository.get_image_by_id(id).await
}
}

View File

@ -0,0 +1,28 @@
use crate::{
application::{error::image_error::ImageError, gateway::image_repository::ImageRepository},
domain::entity::image::Image,
};
use async_trait::async_trait;
use std::sync::Arc;
#[async_trait]
pub trait UploadImageUseCase: Send + Sync {
async fn execute(&self, image: Image) -> Result<i32, ImageError>;
}
pub struct UploadImageUseCaseImpl {
image_repository: Arc<dyn ImageRepository>,
}
impl UploadImageUseCaseImpl {
pub fn new(image_repository: Arc<dyn ImageRepository>) -> Self {
Self { image_repository }
}
}
#[async_trait]
impl UploadImageUseCase for UploadImageUseCaseImpl {
async fn execute(&self, image: Image) -> Result<i32, ImageError> {
self.image_repository.save_image(image).await
}
}

View File

@ -0,0 +1 @@
pub mod entity;

View File

@ -0,0 +1 @@
pub mod image;

View File

@ -0,0 +1,5 @@
pub struct Image {
pub id: Option<i32>,
pub mime_type: String,
pub data: Vec<u8>,
}

View File

@ -0,0 +1,3 @@
pub mod db;
pub mod storage;
pub mod web;

View File

@ -0,0 +1,2 @@
pub mod image_db_service_impl;
pub mod image_record;

View File

@ -0,0 +1,65 @@
use async_trait::async_trait;
use sqlx::{Pool, Postgres};
use crate::{
adapter::gateway::{image_db_mapper::ImageDbMapper, image_db_service::ImageDbService},
application::error::image_error::ImageError,
framework::db::image_record::ImageRecord,
};
pub struct ImageDbServiceImpl {
db_pool: Pool<Postgres>,
}
impl ImageDbServiceImpl {
pub fn new(db_pool: Pool<Postgres>) -> Self {
Self { db_pool }
}
}
#[async_trait]
impl ImageDbService for ImageDbServiceImpl {
async fn create_image_info(&self, image: ImageDbMapper) -> Result<i32, ImageError> {
let mime_type = image.mime_type.clone();
let id = sqlx::query_scalar!(
r#"
INSERT INTO image (mime_type)
VALUES ($1)
RETURNING id
"#,
mime_type
)
.fetch_one(&self.db_pool)
.await;
match id {
Ok(id) => Ok(id),
Err(e) => Err(ImageError::DatabaseError(e.to_string())),
}
}
async fn get_image_info_by_id(&self, id: i32) -> Result<ImageDbMapper, ImageError> {
let image_record = sqlx::query_as!(
ImageRecord,
r#"
SELECT id, mime_type
FROM image
WHERE id = $1 AND deleted_time IS NULL
"#,
id
)
.fetch_optional(&self.db_pool)
.await;
match image_record {
Ok(record) => match record {
Some(record) => Ok(ImageDbMapper {
id: Some(record.id),
mime_type: record.mime_type,
}),
None => Err(ImageError::NotFound),
},
Err(e) => Err(ImageError::DatabaseError(e.to_string())),
}
}
}

View File

@ -0,0 +1,5 @@
#[derive(sqlx::FromRow)]
pub struct ImageRecord {
pub id: i32,
pub mime_type: String,
}

View File

@ -0,0 +1 @@
pub mod image_storage_impl;

View File

@ -0,0 +1,41 @@
use std::{
fs::{self, File},
io::Write,
};
use crate::{
adapter::gateway::image_storage::ImageStorage, application::error::image_error::ImageError,
};
pub struct ImageStorageImpl {
sotrage_path: String,
}
impl ImageStorageImpl {
pub fn new(storage_path: &str) -> Self {
ImageStorageImpl {
sotrage_path: storage_path.to_string(),
}
}
}
impl ImageStorage for ImageStorageImpl {
fn write_data(&self, id: i32, data: &[u8]) -> Result<(), ImageError> {
let dir_path = format!("{}/images", self.sotrage_path);
fs::create_dir_all(&dir_path).map_err(|e| ImageError::StorageError(e.to_string()))?;
let file_path = format!("{}/{}", dir_path, id);
let mut file =
File::create(&file_path).map_err(|e| ImageError::StorageError(e.to_string()))?;
file.write_all(data)
.map_err(|e| ImageError::StorageError(e.to_string()))?;
Ok(())
}
fn read_data(&self, id: i32) -> Result<Vec<u8>, ImageError> {
let file_path = format!("{}/images/{}", self.sotrage_path, id);
let data = fs::read(&file_path).map_err(|e| ImageError::StorageError(e.to_string()))?;
Ok(data)
}
}

View File

@ -0,0 +1 @@
pub mod image_web_routes;

View File

@ -0,0 +1,89 @@
use actix_multipart::Multipart;
use actix_web::{HttpResponse, Responder, web};
use futures::StreamExt;
use crate::{
adapter::delivery::{image_controller::ImageController, image_request_dto::ImageRequestDto},
application::error::image_error::ImageError,
};
pub fn configure_image_routes(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("/image")
.route("/upload", web::post().to(upload_image_handler))
.route("/{id}", web::get().to(get_image_by_id_handler)),
);
}
async fn upload_image_handler(
image_controller: web::Data<dyn ImageController>,
mut payload: Multipart,
) -> impl Responder {
let mut image_request_dto: Option<ImageRequestDto> = None;
while let Some(item) = payload.next().await {
let mut field = match item {
Ok(field) => field,
Err(_) => return HttpResponse::BadRequest().finish(),
};
if field.name() != Some("file") {
continue;
}
let mime_type = field
.content_type()
.cloned()
.map(|mt| mt.to_string())
.unwrap_or_else(|| "application/octet-stream".to_string());
let mut data = Vec::new();
while let Some(chunk) = field.next().await {
match chunk {
Ok(bytes) => data.extend_from_slice(&bytes),
Err(_) => return HttpResponse::InternalServerError().finish(),
}
}
image_request_dto = Some(ImageRequestDto { mime_type, data });
break;
}
let image_request_dto = match image_request_dto {
Some(dto) => dto,
None => return HttpResponse::BadRequest().finish(),
};
let result = image_controller.upload_image(image_request_dto).await;
match result {
Ok(image_info) => HttpResponse::Created().json(image_info),
Err(e) => match e {
ImageError::UnsupportedMimeType => HttpResponse::BadRequest().body(format!("{e:?}")),
_ => {
log::error!("{e:?}");
HttpResponse::InternalServerError().finish()
}
},
}
}
async fn get_image_by_id_handler(
image_controller: web::Data<dyn ImageController>,
path: web::Path<i32>,
) -> impl Responder {
let id = path.into_inner();
let result = image_controller.get_image_by_id(id).await;
match result {
Ok(image_response) => HttpResponse::Ok()
.content_type(image_response.mime_type)
.body(image_response.data),
Err(e) => match e {
ImageError::NotFound => HttpResponse::NotFound().finish(),
_ => {
log::error!("{e:?}");
HttpResponse::InternalServerError().finish()
}
},
}
}

View File

@ -0,0 +1,4 @@
pub mod adapter;
pub mod application;
pub mod domain;
pub mod framework;

View File

@ -1,6 +1,6 @@
pub mod color_mapper;
pub mod label_mapper;
pub mod color_db_mapper;
pub mod label_db_mapper;
pub mod post_db_mapper;
pub mod post_db_service;
pub mod post_info_mapper;
pub mod post_mapper;
pub mod post_info_db_mapper;
pub mod post_repository_impl;

View File

@ -5,7 +5,7 @@ pub struct ColorMapper {
}
impl ColorMapper {
pub fn to_entity(&self) -> Color {
pub fn into_entity(self) -> Color {
Color {
red: (self.value >> 24) as u8,
green: ((self.value >> 16) & 0xFF) as u8,

View File

@ -0,0 +1,17 @@
use crate::{adapter::gateway::color_db_mapper::ColorMapper, domain::entity::label::Label};
pub struct LabelMapper {
pub id: i32,
pub name: String,
pub color: ColorMapper,
}
impl LabelMapper {
pub fn into_entity(self) -> Label {
Label {
id: self.id,
name: self.name,
color: self.color.into_entity(),
}
}
}

View File

@ -1,17 +0,0 @@
use crate::{adapter::gateway::color_mapper::ColorMapper, domain::entity::label::Label};
pub struct LabelMapper {
pub id: i32,
pub name: String,
pub color: ColorMapper,
}
impl LabelMapper {
pub fn to_entity(&self) -> Label {
Label {
id: self.id,
name: self.name.clone(),
color: self.color.to_entity(),
}
}
}

View File

@ -0,0 +1,17 @@
use crate::{adapter::gateway::post_info_db_mapper::PostInfoMapper, domain::entity::post::Post};
pub struct PostMapper {
pub id: i32,
pub info: PostInfoMapper,
pub content: String,
}
impl PostMapper {
pub fn into_entity(self) -> Post {
Post {
id: self.id,
info: self.info.into_entity(),
content: self.content,
}
}
}

View File

@ -1,7 +1,7 @@
use async_trait::async_trait;
use crate::{
adapter::gateway::{post_info_mapper::PostInfoMapper, post_mapper::PostMapper},
adapter::gateway::{post_info_db_mapper::PostInfoMapper, post_db_mapper::PostMapper},
application::error::post_error::PostError,
};

View File

@ -1,6 +1,6 @@
use chrono::{DateTime, NaiveDateTime, Utc};
use crate::{adapter::gateway::label_mapper::LabelMapper, domain::entity::post_info::PostInfo};
use crate::{adapter::gateway::label_db_mapper::LabelMapper, domain::entity::post_info::PostInfo};
pub struct PostInfoMapper {
pub id: i32,
@ -12,7 +12,7 @@ pub struct PostInfoMapper {
}
impl PostInfoMapper {
pub fn to_entity(&self) -> PostInfo {
pub fn into_entity(self) -> PostInfo {
PostInfo {
id: self.id,
title: self.title.clone(),
@ -21,7 +21,7 @@ impl PostInfoMapper {
published_time: self
.published_time
.map(|dt| DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc)),
labels: self.labels.iter().map(LabelMapper::to_entity).collect(),
labels: self.labels.into_iter().map(LabelMapper::into_entity).collect(),
}
}
}

View File

@ -1,17 +0,0 @@
use crate::{adapter::gateway::post_info_mapper::PostInfoMapper, domain::entity::post::Post};
pub struct PostMapper {
pub id: i32,
pub info: PostInfoMapper,
pub content: String,
}
impl PostMapper {
pub fn to_entity(&self) -> Post {
Post {
id: self.id,
info: self.info.to_entity(),
content: self.content.clone(),
}
}
}

View File

@ -28,7 +28,7 @@ impl PostRepository for PostRepositoryImpl {
.map(|mappers| {
mappers
.into_iter()
.map(|mapper| mapper.to_entity())
.map(|mapper| mapper.into_entity())
.collect::<Vec<PostInfo>>()
})
}
@ -37,6 +37,6 @@ impl PostRepository for PostRepositoryImpl {
self.post_db_service
.get_full_post(id)
.await
.map(|mapper| mapper.to_entity())
.map(|mapper| mapper.into_entity())
}
}

View File

@ -5,8 +5,8 @@ use sqlx::{Pool, Postgres};
use crate::{
adapter::gateway::{
color_mapper::ColorMapper, label_mapper::LabelMapper, post_db_service::PostDbService,
post_info_mapper::PostInfoMapper, post_mapper::PostMapper,
color_db_mapper::ColorMapper, label_db_mapper::LabelMapper, post_db_service::PostDbService,
post_info_db_mapper::PostInfoMapper, post_db_mapper::PostMapper,
},
application::error::post_error::PostError,
};

View File

@ -1,6 +1,6 @@
use chrono::NaiveDateTime;
#[derive(sqlx::FromRow, Debug)]
#[derive(sqlx::FromRow)]
pub struct PostWithLabelRecord {
pub post_id: i32,
pub title: String,

View File

@ -0,0 +1,12 @@
CREATE TABLE "image" (
"id" SERIAL PRIMARY KEY NOT NULL,
"mime_type" VARCHAR(100) NOT NULL,
"deleted_time" TIMESTAMP,
"created_time" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_time" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TRIGGER "update_image_updated_time"
BEFORE UPDATE ON "image"
FOR EACH ROW
EXECUTE FUNCTION update_updated_time_column();

View File

@ -10,4 +10,5 @@ env_logger.workspace = true
percent-encoding.workspace = true
sqlx.workspace = true
image.workspace = true
post.workspace = true

View File

@ -1,5 +1,18 @@
use std::sync::Arc;
use image::{
adapter::{
delivery::image_controller::{ImageController, ImageControllerImpl},
gateway::image_repository_impl::ImageRepositoryImpl,
},
application::use_case::{
get_image_use_case::GetImageUseCaseImpl, upload_image_use_case::UploadImageUseCaseImpl,
},
framework::{
db::image_db_service_impl::ImageDbServiceImpl,
storage::image_storage_impl::ImageStorageImpl,
},
};
use post::{
adapter::{
delivery::post_controller::{PostController, PostControllerImpl},
@ -15,23 +28,37 @@ use sqlx::{Pool, Postgres};
pub struct Container {
pub post_controller: Arc<dyn PostController>,
pub image_controller: Arc<dyn ImageController>,
}
impl Container {
pub fn new(db_pool: Pool<Postgres>) -> Self {
pub fn new(db_pool: Pool<Postgres>, storage_path: &str) -> Self {
let post_db_service = Arc::new(PostDbServiceImpl::new(db_pool.clone()));
let post_repository = Arc::new(PostRepositoryImpl::new(post_db_service.clone()));
let get_all_post_info_use_case =
Arc::new(GetAllPostInfoUseCaseImpl::new(post_repository.clone()));
let get_full_post_use_case = Arc::new(GetFullPostUseCaseImpl::new(post_repository.clone()));
let post_controller = Arc::new(PostControllerImpl::new(
get_all_post_info_use_case,
get_full_post_use_case,
));
Self { post_controller }
let image_db_service = Arc::new(ImageDbServiceImpl::new(db_pool.clone()));
let image_storage = Arc::new(ImageStorageImpl::new(storage_path.into()));
let image_repository = Arc::new(ImageRepositoryImpl::new(
image_db_service.clone(),
image_storage.clone(),
));
let upload_image_use_case = Arc::new(UploadImageUseCaseImpl::new(image_repository.clone()));
let get_image_use_case = Arc::new(GetImageUseCaseImpl::new(image_repository));
let image_controller = Arc::new(ImageControllerImpl::new(
upload_image_use_case,
get_image_use_case,
));
Self {
post_controller,
image_controller,
}
}
}

View File

@ -4,6 +4,7 @@ use actix_web::{
dev::{ServiceFactory, ServiceRequest, ServiceResponse},
web,
};
use image::framework::web::image_web_routes::configure_image_routes;
use post::framework::web::post_web_routes::configure_post_routes;
use server::container::Container;
use sqlx::{Pool, Postgres, postgres::PgPoolOptions};
@ -15,6 +16,7 @@ async fn main() -> std::io::Result<()> {
env_logger::init();
let db_pool = init_database().await;
let storage_path = env::var("STORAGE_PATH").unwrap_or_else(|_| "static".to_string());
let host = env::var("HOST").unwrap_or_else(|_| "0.0.0.0".to_string());
let port = env::var("PORT")
@ -22,7 +24,7 @@ async fn main() -> std::io::Result<()> {
.parse::<u16>()
.unwrap();
HttpServer::new(move || create_app(db_pool.clone()))
HttpServer::new(move || create_app(db_pool.clone(), storage_path.clone()))
.bind((host, port))?
.run()
.await
@ -59,6 +61,7 @@ async fn init_database() -> Pool<Postgres> {
fn create_app(
db_pool: Pool<Postgres>,
storage_path: String,
) -> App<
impl ServiceFactory<
ServiceRequest,
@ -68,9 +71,11 @@ fn create_app(
Error = Error,
>,
> {
let container = Container::new(db_pool);
let container = Container::new(db_pool, &storage_path);
App::new()
.app_data(web::Data::from(container.post_controller))
.app_data(web::Data::from(container.image_controller))
.configure(configure_post_routes)
.configure(configure_image_routes)
}