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:
@@ -4,3 +4,7 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
async-trait = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
domain = { workspace = true }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
119
crates/adapters/metadata/src/omdb.rs
Normal file
119
crates/adapters/metadata/src/omdb.rs
Normal 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 })
|
||||
}
|
||||
}
|
||||
@@ -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!(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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!() } }
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user