feat(metadata): Implement OMDB metadata provider and refactor metadata client

- Added `OmdbProvider` to fetch movie metadata from the OMDB API.
- Refactored `MetadataClient` to use `MetadataSearchCriteria` for fetching movie metadata.
- Updated `MetadataClientImpl` to support fetching metadata using OMDB.
- Modified `log_review` use case to utilize the new metadata fetching mechanism.
- Updated tests and presentation layer to accommodate changes in metadata handling.
- Added dependencies for `reqwest` and `async-trait` in relevant `Cargo.toml` files.
This commit is contained in:
2026-05-04 11:19:51 +02:00
parent 93c65cd155
commit da72ab1446
12 changed files with 827 additions and 54 deletions

View File

@@ -4,3 +4,7 @@ version = "0.1.0"
edition = "2024"
[dependencies]
async-trait = { workspace = true }
reqwest = { workspace = true }
serde = { workspace = true }
domain = { workspace = true }

View File

@@ -1,14 +1,54 @@
pub fn add(left: u64, right: u64) -> u64 {
left + right
use async_trait::async_trait;
use domain::{
errors::DomainError,
models::Movie,
ports::{MetadataClient, MetadataSearchCriteria},
value_objects::{ExternalMetadataId, MovieTitle, PosterUrl, ReleaseYear},
};
mod omdb;
pub(crate) struct ProviderMovie {
pub imdb_id: ExternalMetadataId,
pub title: MovieTitle,
pub release_year: ReleaseYear,
pub director: Option<String>,
pub poster_url: Option<PosterUrl>,
}
#[cfg(test)]
mod tests {
use super::*;
#[async_trait]
pub(crate) trait MetadataProvider: Send + Sync {
async fn fetch(&self, criteria: &MetadataSearchCriteria) -> Result<ProviderMovie, DomainError>;
}
#[test]
fn it_works() {
let result = add(2, 2);
assert_eq!(result, 4);
pub struct MetadataClientImpl {
provider: Box<dyn MetadataProvider>,
}
impl MetadataClientImpl {
pub fn new_omdb(api_key: String) -> Self {
Self {
provider: Box::new(omdb::OmdbProvider::new(api_key)),
}
}
}
#[async_trait]
impl MetadataClient for MetadataClientImpl {
async fn fetch_movie_metadata(
&self,
criteria: &MetadataSearchCriteria,
) -> Result<Movie, DomainError> {
let pm = self.provider.fetch(criteria).await?;
Ok(Movie::new(Some(pm.imdb_id), pm.title, pm.release_year, pm.director, None))
}
async fn get_poster_url(
&self,
external_metadata_id: &ExternalMetadataId,
) -> Result<Option<PosterUrl>, DomainError> {
let criteria = MetadataSearchCriteria::ImdbId(external_metadata_id.clone());
let pm = self.provider.fetch(&criteria).await?;
Ok(pm.poster_url)
}
}

View File

@@ -0,0 +1,119 @@
use async_trait::async_trait;
use domain::{
errors::DomainError,
ports::MetadataSearchCriteria,
value_objects::{ExternalMetadataId, MovieTitle, PosterUrl, ReleaseYear},
};
use serde::Deserialize;
use crate::{MetadataProvider, ProviderMovie};
pub(crate) struct OmdbProvider {
client: reqwest::Client,
api_key: String,
base_url: String,
}
impl OmdbProvider {
pub(crate) fn new(api_key: String) -> Self {
Self {
client: reqwest::Client::new(),
api_key,
base_url: "http://www.omdbapi.com/".to_string(),
}
}
}
#[derive(Deserialize)]
struct OmdbResponse {
#[serde(rename = "Title")]
title: String,
#[serde(rename = "Year")]
year: String,
#[serde(rename = "Director")]
director: String,
#[serde(rename = "Poster")]
poster: String,
#[serde(rename = "imdbID")]
imdb_id: String,
#[serde(rename = "Response")]
response: String,
#[serde(rename = "Error")]
error: Option<String>,
}
#[async_trait]
impl MetadataProvider for OmdbProvider {
async fn fetch(&self, criteria: &MetadataSearchCriteria) -> Result<ProviderMovie, DomainError> {
let mut url = reqwest::Url::parse(&self.base_url)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
{
let mut params = url.query_pairs_mut();
params.append_pair("apikey", &self.api_key);
match criteria {
MetadataSearchCriteria::ImdbId(id) => {
params.append_pair("i", id.value());
}
MetadataSearchCriteria::Title { title, year } => {
params.append_pair("t", title);
if let Some(y) = year {
params.append_pair("y", &y.to_string());
}
}
}
}
let http_resp = self
.client
.get(url)
.send()
.await
.map_err(|e: reqwest::Error| DomainError::InfrastructureError(e.to_string()))?
.error_for_status()
.map_err(|e: reqwest::Error| DomainError::InfrastructureError(e.to_string()))?;
let resp: OmdbResponse = http_resp
.json()
.await
.map_err(|e: reqwest::Error| DomainError::InfrastructureError(e.to_string()))?;
if resp.response != "True" {
let msg = resp.error.unwrap_or_default();
return if msg.to_lowercase().contains("not found") {
Err(DomainError::NotFound(msg))
} else {
Err(DomainError::InfrastructureError(msg))
};
}
let year: u16 = resp
.year
.chars()
.take(4)
.collect::<String>()
.parse()
.map_err(|_| {
DomainError::InfrastructureError(format!("Unparseable year: {}", resp.year))
})?;
let imdb_id = ExternalMetadataId::new(resp.imdb_id)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let title = MovieTitle::new(resp.title)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let release_year = ReleaseYear::new(year)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let director = match resp.director.as_str() {
"N/A" | "" => None,
d => Some(d.to_string()),
};
let poster_url = match resp.poster.as_str() {
"N/A" | "" => None,
url => PosterUrl::new(url.to_string()).ok(),
};
Ok(ProviderMovie { imdb_id, title, release_year, director, poster_url })
}
}

View File

@@ -2,6 +2,7 @@ use domain::{
errors::DomainError,
events::DomainEvent,
models::{Movie, Review},
ports::MetadataSearchCriteria,
value_objects::{Comment, ExternalMetadataId, MovieTitle, Rating, ReleaseYear, UserId},
};
@@ -47,7 +48,11 @@ async fn resolve_external_movie(
return Ok(Some((m, false)));
}
match ctx.metadata_client.fetch_movie_metadata(&tmdb_id).await {
match ctx
.metadata_client
.fetch_movie_metadata(&MetadataSearchCriteria::ImdbId(tmdb_id))
.await
{
Ok(m) => Ok(Some((m, true))),
Err(e) => {
tracing::warn!(

View File

@@ -34,11 +34,16 @@ pub trait MovieRepository: Send + Sync {
async fn get_review_history(&self, movie_id: &MovieId) -> Result<ReviewHistory, DomainError>;
}
pub enum MetadataSearchCriteria {
ImdbId(ExternalMetadataId),
Title { title: String, year: Option<u16> },
}
#[async_trait]
pub trait MetadataClient: Send + Sync {
async fn fetch_movie_metadata(
&self,
external_metadata_id: &ExternalMetadataId,
criteria: &MetadataSearchCriteria,
) -> Result<Movie, DomainError>;
async fn get_poster_url(
&self,

View File

@@ -22,6 +22,7 @@ async-trait = { workspace = true }
domain = { workspace = true }
application = { workspace = true }
auth = { workspace = true }
metadata = { workspace = true }
sqlite = { workspace = true }
sqlx = { workspace = true }
template-askama = { workspace = true }

View File

@@ -80,7 +80,7 @@ mod tests {
}
struct PanicMeta; struct PanicFetcher; struct PanicStorage; struct PanicEvent; struct PanicHasher; struct PanicAuth; struct PanicUserRepo;
#[async_trait::async_trait] impl domain::ports::MetadataClient for PanicMeta { async fn fetch_movie_metadata(&self, _: &domain::value_objects::ExternalMetadataId) -> Result<domain::models::Movie, domain::errors::DomainError> { panic!() } async fn get_poster_url(&self, _: &domain::value_objects::ExternalMetadataId) -> Result<Option<domain::value_objects::PosterUrl>, domain::errors::DomainError> { panic!() } }
#[async_trait::async_trait] impl domain::ports::MetadataClient for PanicMeta { async fn fetch_movie_metadata(&self, _: &domain::ports::MetadataSearchCriteria) -> Result<domain::models::Movie, domain::errors::DomainError> { panic!() } async fn get_poster_url(&self, _: &domain::value_objects::ExternalMetadataId) -> Result<Option<domain::value_objects::PosterUrl>, domain::errors::DomainError> { panic!() } }
#[async_trait::async_trait] impl domain::ports::PosterFetcherClient for PanicFetcher { async fn fetch_poster_bytes(&self, _: &domain::value_objects::PosterUrl) -> Result<Vec<u8>, domain::errors::DomainError> { panic!() } }
#[async_trait::async_trait] impl domain::ports::PosterStorage for PanicStorage { async fn store_poster(&self, _: &domain::value_objects::MovieId, _: &[u8]) -> Result<domain::value_objects::PosterPath, domain::errors::DomainError> { panic!() } async fn get_poster(&self, _: &domain::value_objects::PosterPath) -> Result<Vec<u8>, domain::errors::DomainError> { panic!() } }
#[async_trait::async_trait] impl domain::ports::EventPublisher for PanicEvent { async fn publish(&self, _: &domain::events::DomainEvent) -> Result<(), domain::errors::DomainError> { panic!() } }

View File

@@ -5,9 +5,8 @@ use async_trait::async_trait;
use domain::{
errors::DomainError,
events::DomainEvent,
models::Movie,
ports::{EventPublisher, MetadataClient, PosterFetcherClient, PosterStorage},
value_objects::{ExternalMetadataId, MovieId, PosterPath, PosterUrl},
ports::{EventPublisher, PosterFetcherClient, PosterStorage},
value_objects::{MovieId, PosterPath, PosterUrl},
};
use sqlx::SqlitePool;
use tokio::net::TcpListener;
@@ -15,31 +14,12 @@ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use application::{config::AppConfig, context::AppContext};
use auth::{AuthConfig, Argon2PasswordHasher, JwtAuthService};
use metadata::MetadataClientImpl;
use sqlite::{SqliteMovieRepository, SqliteUserRepository};
use template_askama::AskamaHtmlRenderer;
use presentation::{routes, state::AppState};
struct StubMetadataClient;
#[async_trait]
impl MetadataClient for StubMetadataClient {
async fn fetch_movie_metadata(&self, _id: &ExternalMetadataId) -> Result<Movie, DomainError> {
Err(DomainError::InfrastructureError(
"metadata client not implemented".into(),
))
}
async fn get_poster_url(
&self,
_id: &ExternalMetadataId,
) -> Result<Option<PosterUrl>, DomainError> {
Err(DomainError::InfrastructureError(
"metadata client not implemented".into(),
))
}
}
struct StubPosterFetcher;
#[async_trait]
@@ -102,6 +82,7 @@ async fn main() -> anyhow::Result<()> {
async fn wire_dependencies() -> anyhow::Result<AppState> {
let auth_config = AuthConfig::from_env()?;
let app_config = AppConfig::from_env();
let omdb_api_key = std::env::var("OMDB_API_KEY").context("OMDB_API_KEY must be set")?;
let pool = SqlitePool::connect("sqlite://reviews.db")
.await
@@ -118,7 +99,7 @@ async fn wire_dependencies() -> anyhow::Result<AppState> {
let app_ctx = AppContext {
repository: Arc::new(movie_repo),
metadata_client: Arc::new(StubMetadataClient),
metadata_client: Arc::new(MetadataClientImpl::new_omdb(omdb_api_key)),
poster_fetcher: Arc::new(StubPosterFetcher),
poster_storage: Arc::new(StubPosterStorage),
event_publisher: Arc::new(StubEventPublisher),

View File

@@ -12,8 +12,8 @@ use domain::{
events::DomainEvent,
models::{Movie, User},
ports::{
AuthService, EventPublisher, GeneratedToken, MetadataClient, PasswordHasher,
PosterFetcherClient, PosterStorage, UserRepository,
AuthService, EventPublisher, GeneratedToken, MetadataClient, MetadataSearchCriteria,
PasswordHasher, PosterFetcherClient, PosterStorage, UserRepository,
},
value_objects::{
Email, ExternalMetadataId, MovieId, PasswordHash, PosterPath, PosterUrl, UserId,
@@ -37,7 +37,7 @@ impl EventPublisher for NoopEventPublisher {
struct PanicMeta;
#[async_trait]
impl MetadataClient for PanicMeta {
async fn fetch_movie_metadata(&self, _: &ExternalMetadataId) -> Result<Movie, DomainError> {
async fn fetch_movie_metadata(&self, _: &MetadataSearchCriteria) -> Result<Movie, DomainError> {
panic!("metadata not wired in tests")
}
async fn get_poster_url(&self, _: &ExternalMetadataId) -> Result<Option<PosterUrl>, DomainError> {