From b0e64ccc985ae32c6144dde529884220dc14a673 Mon Sep 17 00:00:00 2001 From: chodak166 Date: Sun, 18 Jan 2026 14:30:11 +0100 Subject: [PATCH] WIP: basic API --- apps/app_api/src/api.rs | 43 +------- apps/app_api/src/api/dictionary.rs | 52 --------- apps/app_api/src/api/v1.rs | 16 +++ apps/app_api/src/api/v1/auth.rs | 39 +++++++ apps/app_api/src/api/v1/dictionary.rs | 47 ++++++++ apps/app_api/src/api/{ => v1}/health.rs | 14 +-- .../src/api/{major_pl.rs => v1/major.rs} | 23 +--- apps/app_api/src/app.rs | 7 +- apps/app_api/src/commands.rs | 7 +- apps/app_api/src/commands/listen.rs | 3 +- apps/app_api/src/config.rs | 15 ++- apps/app_api/src/config/auth.rs | 16 +++ apps/app_api/src/config/database.rs | 14 +++ apps/app_api/src/container.rs | 40 ------- apps/app_api/src/dependencies.rs | 45 ++++++++ apps/app_api/src/error.rs | 101 ++++++++++++++++++ apps/app_api/src/main.rs | 4 +- apps/app_api/src/middleware.rs | 38 +++++++ apps/app_api/src/middleware/auth.rs | 8 ++ apps/app_api/src/router.rs | 4 +- apps/app_api/src/state.rs | 12 +-- lib/src/auth.rs | 13 +++ lib/src/auth/domain.rs | 72 +++++++++++++ lib/src/auth/infrastructure/api_token.rs | 47 ++++++++ lib/src/auth/infrastructure/jwt.rs | 68 ++++++++++++ lib/src/auth/infrastructure/store.rs | 58 ++++++++++ lib/src/auth/service.rs | 59 ++++++++++ lib/src/auth/traits.rs | 15 +++ lib/src/common.rs | 1 + lib/src/common/errors.rs | 54 +++++++++- lib/src/dictionary.rs | 14 +-- .../infrastructure/sqlite_dict_repository.rs | 35 ++++-- lib/src/dictionary/service.rs | 66 ++++++++++++ lib/src/lib.rs | 21 ++-- lib/src/sys_major.rs | 8 +- lib/src/sys_major/service.rs | 36 +++++++ 36 files changed, 895 insertions(+), 220 deletions(-) delete mode 100644 apps/app_api/src/api/dictionary.rs create mode 100644 apps/app_api/src/api/v1.rs create mode 100644 apps/app_api/src/api/v1/auth.rs create mode 100644 apps/app_api/src/api/v1/dictionary.rs rename apps/app_api/src/api/{ => v1}/health.rs (82%) rename apps/app_api/src/api/{major_pl.rs => v1/major.rs} (71%) create mode 100644 apps/app_api/src/config/auth.rs create mode 100644 apps/app_api/src/config/database.rs delete mode 100644 apps/app_api/src/container.rs create mode 100644 apps/app_api/src/dependencies.rs create mode 100644 apps/app_api/src/error.rs create mode 100644 apps/app_api/src/middleware.rs create mode 100644 apps/app_api/src/middleware/auth.rs create mode 100644 lib/src/auth.rs create mode 100644 lib/src/auth/domain.rs create mode 100644 lib/src/auth/infrastructure/api_token.rs create mode 100644 lib/src/auth/infrastructure/jwt.rs create mode 100644 lib/src/auth/infrastructure/store.rs create mode 100644 lib/src/auth/service.rs create mode 100644 lib/src/auth/traits.rs create mode 100644 lib/src/sys_major/service.rs diff --git a/apps/app_api/src/api.rs b/apps/app_api/src/api.rs index 3402e84..4370f79 100644 --- a/apps/app_api/src/api.rs +++ b/apps/app_api/src/api.rs @@ -1,44 +1,9 @@ +pub mod v1; + use crate::state::AppState; -use axum::{Json, Router, http::StatusCode, response::IntoResponse}; -use serde::Serialize; +use axum::Router; use std::sync::Arc; -pub mod dictionary; -pub mod health; -pub mod major_pl; - pub fn routes() -> Router> { - Router::new() - .nest("/api", health::routes()) - .nest("/api", dictionary::routes()) - .nest("/api", major_pl::routes()) -} - -// --- Error Response --- - -#[derive(Debug, Serialize)] -pub struct ErrorResponse { - pub error: String, -} - -impl IntoResponse for ErrorResponse { - fn into_response(self) -> axum::response::Response { - (StatusCode::INTERNAL_SERVER_ERROR, Json(self)).into_response() - } -} - -impl From for ErrorResponse { - fn from(err: anyhow::Error) -> Self { - Self { - error: err.to_string(), - } - } -} - -impl From for ErrorResponse { - fn from(err: applib::RepositoryError) -> Self { - Self { - error: err.to_string(), - } - } + Router::new().nest("/api/v1", v1::routes()) } diff --git a/apps/app_api/src/api/dictionary.rs b/apps/app_api/src/api/dictionary.rs deleted file mode 100644 index dc3c067..0000000 --- a/apps/app_api/src/api/dictionary.rs +++ /dev/null @@ -1,52 +0,0 @@ -use axum::{Json, Router, extract::State, routing::get}; -use serde::Serialize; -use std::sync::Arc; - -use super::ErrorResponse; -use crate::state::AppState; - -// --- DTOs --- - -#[derive(Debug, Serialize)] -pub struct DictListResponse { - pub dictionaries: Vec, -} - -#[derive(Debug, Serialize)] -pub struct DictListEntryResponse { - pub name: String, - pub entry_count: u64, -} - -// --- Handlers --- - -pub async fn list_dicts_handler( - State(state): State>, -) -> Result, ErrorResponse> { - let default_repo = state.container.create_dict_repo("default").await?; - - let dict_names = default_repo.fetch_dicts().await?; - - let mut entries = Vec::with_capacity(dict_names.len()); - - for dict_name in dict_names { - let dict_repo = state.container.create_dict_repo(&dict_name).await?; - - let entry_count = dict_repo.count_entries().await?; - - entries.push(DictListEntryResponse { - name: dict_name, - entry_count, - }); - } - - Ok(Json(DictListResponse { - dictionaries: entries, - })) -} - -// --- Router --- - -pub fn routes() -> Router> { - Router::new().route("/dicts", get(list_dicts_handler)) -} diff --git a/apps/app_api/src/api/v1.rs b/apps/app_api/src/api/v1.rs new file mode 100644 index 0000000..032d2f2 --- /dev/null +++ b/apps/app_api/src/api/v1.rs @@ -0,0 +1,16 @@ +pub mod auth; +pub mod dictionary; +pub mod health; +pub mod major; + +use crate::state::AppState; +use axum::Router; +use std::sync::Arc; + +pub fn routes() -> Router> { + Router::new() + .nest("/health", health::routes()) + .nest("/dicts", dictionary::routes()) + .nest("/major", major::routes()) + .nest("/auth", auth::routes()) +} diff --git a/apps/app_api/src/api/v1/auth.rs b/apps/app_api/src/api/v1/auth.rs new file mode 100644 index 0000000..6f666c5 --- /dev/null +++ b/apps/app_api/src/api/v1/auth.rs @@ -0,0 +1,39 @@ +use axum::{Json, Router, extract::State, routing::post}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +use crate::error::ErrorResponse; +use crate::state::AppState; + +#[derive(Debug, Deserialize)] +pub struct LoginRequest { + pub token: String, +} + +#[derive(Debug, Serialize)] +pub struct LoginResponse { + pub user_id: String, + pub email: Option, + pub roles: Vec, +} + +pub async fn login_handler( + State(state): State>, + Json(req): Json, +) -> Result, ErrorResponse> { + let claims = state + .dependencies + .auth_service + .authenticate(&req.token) + .await?; + + Ok(Json(LoginResponse { + user_id: claims.user_id, + email: claims.email, + roles: claims.roles, + })) +} + +pub fn routes() -> Router> { + Router::new().route("/login", post(login_handler)) +} diff --git a/apps/app_api/src/api/v1/dictionary.rs b/apps/app_api/src/api/v1/dictionary.rs new file mode 100644 index 0000000..1f6aecf --- /dev/null +++ b/apps/app_api/src/api/v1/dictionary.rs @@ -0,0 +1,47 @@ +use axum::{Json, Router, extract::State, routing::get}; +use serde::Serialize; +use std::sync::Arc; + +use crate::error::ErrorResponse; +use crate::state::AppState; +use applib::DictionarySummary; + +#[derive(Debug, Serialize)] +pub struct DictListResponse { + pub dictionaries: Vec, +} + +#[derive(Debug, Serialize)] +pub struct DictListEntryResponse { + pub name: String, + pub entry_count: u64, +} + +impl From for DictListEntryResponse { + fn from(summary: DictionarySummary) -> Self { + Self { + name: summary.name, + entry_count: summary.entry_count, + } + } +} + +pub async fn list_dicts_handler( + State(state): State>, +) -> Result, ErrorResponse> { + let dictionaries = state + .dependencies + .dictionary_service + .list_dictionaries() + .await?; + + let entries: Vec = dictionaries.into_iter().map(Into::into).collect(); + + Ok(Json(DictListResponse { + dictionaries: entries, + })) +} + +pub fn routes() -> Router> { + Router::new().route("", get(list_dicts_handler)) +} diff --git a/apps/app_api/src/api/health.rs b/apps/app_api/src/api/v1/health.rs similarity index 82% rename from apps/app_api/src/api/health.rs rename to apps/app_api/src/api/v1/health.rs index 99834d5..b68378a 100644 --- a/apps/app_api/src/api/health.rs +++ b/apps/app_api/src/api/v1/health.rs @@ -8,11 +8,8 @@ use serde::Serialize; use serde_json::Value; use std::sync::Arc; -use super::ErrorResponse; use crate::state::AppState; -// --- DTOs --- - #[derive(Debug, Serialize)] pub struct EchoResponse { pub data: Value, @@ -25,17 +22,14 @@ pub struct VersionResponse { pub version: String, } -// --- Handlers --- - pub async fn echo_handler( State(_state): State>, Json(payload): Json, -) -> Result, ErrorResponse> { - let response = EchoResponse { +) -> Json { + Json(EchoResponse { data: payload, timestamp: Utc::now().to_rfc3339(), - }; - Ok(Json(response)) + }) } pub async fn version_handler(State(state): State>) -> Json { @@ -45,8 +39,6 @@ pub async fn version_handler(State(state): State>) -> Json Router> { Router::new() .route("/echo", post(echo_handler)) diff --git a/apps/app_api/src/api/major_pl.rs b/apps/app_api/src/api/v1/major.rs similarity index 71% rename from apps/app_api/src/api/major_pl.rs rename to apps/app_api/src/api/v1/major.rs index c93917b..36ddfe3 100644 --- a/apps/app_api/src/api/major_pl.rs +++ b/apps/app_api/src/api/v1/major.rs @@ -1,4 +1,3 @@ -use super::ErrorResponse; use axum::{ Json, Router, extract::{Path, Query, State}, @@ -7,6 +6,7 @@ use axum::{ use serde::{Deserialize, Serialize}; use std::sync::Arc; +use crate::error::ErrorResponse; use crate::state::AppState; #[derive(Debug, Deserialize)] @@ -42,14 +42,7 @@ pub async fn encode_handler( Query(params): Query, ) -> Result, ErrorResponse> { let dict_name = params.dict.unwrap_or_else(|| "demo_pl".to_string()); - let encoder = state - .container - .create_encoder(&dict_name) - .await - .map_err(|e| anyhow::anyhow!("Failed to create encoder: {}", e))?; - let result = encoder - .encode(&input) - .map_err(|e| anyhow::anyhow!("Failed to encode: {}", e))?; + let result = state.dependencies.major_system_service.encode(&input)?; let encoded_parts: Vec> = result .iter() @@ -76,13 +69,7 @@ pub async fn decode_handler( Path(input): Path, Query(_params): Query, ) -> Result, ErrorResponse> { - let decoder = state - .container - .create_decoder() - .map_err(|e| anyhow::anyhow!("Failed to create decoder: {}", e))?; - let result = decoder - .decode(&input) - .map_err(|e| anyhow::anyhow!("Failed to decode: {}", e))?; + let result = state.dependencies.major_system_service.decode(&input)?; Ok(Json(DecodeResponse { input, @@ -92,6 +79,6 @@ pub async fn decode_handler( pub fn routes() -> Router> { Router::new() - .route("/encode/major_pl/{input}", get(encode_handler)) - .route("/decode/major_pl/{input}", get(decode_handler)) + .route("/encode/pl/{input}", get(encode_handler)) + .route("/decode/pl/{input}", get(decode_handler)) } diff --git a/apps/app_api/src/app.rs b/apps/app_api/src/app.rs index a5accbf..895f3f2 100644 --- a/apps/app_api/src/app.rs +++ b/apps/app_api/src/app.rs @@ -1,13 +1,11 @@ use crate::commands::{AppCommand, CliArgs}; use crate::config::AppConfig; -use crate::container::Container; use anyhow::Result; use clap::Parser; use tracing::debug; pub struct Application { config: AppConfig, - container: Container, command: Box, } @@ -27,16 +25,13 @@ impl Application { debug!("Bootstrapping application..."); - let container = Container::new().await?; - Ok(Self { config, - container, command: app_cmd, }) } pub async fn run(self) -> Result<()> { - self.command.execute(&self.config, &self.container).await + self.command.execute(&self.config).await } } diff --git a/apps/app_api/src/commands.rs b/apps/app_api/src/commands.rs index b34b510..4b4181b 100644 --- a/apps/app_api/src/commands.rs +++ b/apps/app_api/src/commands.rs @@ -1,7 +1,6 @@ pub mod listen; use crate::config::AppConfig; -use crate::container::Container; use anyhow::Result; use async_trait::async_trait; use clap::Subcommand; @@ -12,7 +11,6 @@ use std::path::PathBuf; #[derive(Subcommand, Debug, Clone)] pub enum Command { - /// Decode a word using given system Listen(listen::ListenCmd), } @@ -36,7 +34,6 @@ pub struct CliArgs { #[derive(ClapArgs, Debug)] pub struct GlobalArgs { - /// Path to config file #[arg(short, long, default_value = "config.toml")] pub config: PathBuf, @@ -57,11 +54,9 @@ pub trait Configurable { #[async_trait] pub trait Executable { - async fn execute(&self, config: &AppConfig, container: &Container) -> Result<()>; + async fn execute(&self, config: &AppConfig) -> Result<()>; } -// AppCommand must be dyn-compatible. Configurable is already dyn-compatible. -// Executable is dyn-compatible because of #[async_trait]. pub trait AppCommand: Configurable + Executable {} impl AppCommand for T {} diff --git a/apps/app_api/src/commands/listen.rs b/apps/app_api/src/commands/listen.rs index 56e156a..128a481 100644 --- a/apps/app_api/src/commands/listen.rs +++ b/apps/app_api/src/commands/listen.rs @@ -1,6 +1,5 @@ use crate::commands::{ClapArgs, Configurable, Executable}; use crate::config::AppConfig; -use crate::container::Container; use crate::router; use anyhow::Result; @@ -55,7 +54,7 @@ impl Configurable for ListenCmd { #[async_trait] impl Executable for ListenCmd { - async fn execute(&self, config: &AppConfig, _container: &Container) -> Result<()> { + async fn execute(&self, config: &AppConfig) -> Result<()> { let listen_config = config .listen .as_ref() diff --git a/apps/app_api/src/config.rs b/apps/app_api/src/config.rs index aabfd08..90a4706 100644 --- a/apps/app_api/src/config.rs +++ b/apps/app_api/src/config.rs @@ -1,37 +1,42 @@ +pub mod auth; +pub mod database; + use crate::commands::*; use anyhow::{Context, Result}; use config::{Config, Environment, File}; use serde::Deserialize; +pub use self::auth::AuthConfig; +pub use self::database::DatabaseConfig; + #[derive(Debug, Deserialize, Clone)] pub struct AppConfig { #[serde(default)] pub listen: Option, pub log_level: String, + #[serde(default)] + pub auth: AuthConfig, + #[serde(default)] + pub database: DatabaseConfig, } impl AppConfig { pub fn build(args: &GlobalArgs, handler: &dyn Configurable) -> Result { let mut builder = Config::builder(); - // Command-specific defaults via Trait builder = handler.apply_defaults(builder)?; - // File Layer let config_path = &args.config; let is_default_path = config_path.to_str() == Some("config.toml"); builder = builder.add_source(File::from(config_path.as_path()).required(!is_default_path)); - // Environment Layer (e.g. APP_LISTEN_PORT) builder = builder.add_source(Environment::with_prefix("APP").separator("_")); - // Global log level override if let Some(ref level) = args.log_level { builder = builder.set_override("log_level", level.clone())?; } - // Command-specific overrides via Trait builder = handler.apply_overrides(builder)?; builder diff --git a/apps/app_api/src/config/auth.rs b/apps/app_api/src/config/auth.rs new file mode 100644 index 0000000..4ca0721 --- /dev/null +++ b/apps/app_api/src/config/auth.rs @@ -0,0 +1,16 @@ +use serde::Deserialize; + +#[derive(Debug, Deserialize, Clone)] +pub struct AuthConfig { + pub firebase_project_id: Option, + pub api_tokens: Vec, +} + +impl Default for AuthConfig { + fn default() -> Self { + Self { + firebase_project_id: None, + api_tokens: Vec::new(), + } + } +} diff --git a/apps/app_api/src/config/database.rs b/apps/app_api/src/config/database.rs new file mode 100644 index 0000000..81aa5d0 --- /dev/null +++ b/apps/app_api/src/config/database.rs @@ -0,0 +1,14 @@ +use serde::Deserialize; + +#[derive(Debug, Deserialize, Clone)] +pub struct DatabaseConfig { + pub url: String, +} + +impl Default for DatabaseConfig { + fn default() -> Self { + Self { + url: "sqlite:app.db".to_string(), + } + } +} diff --git a/apps/app_api/src/container.rs b/apps/app_api/src/container.rs deleted file mode 100644 index 37b5972..0000000 --- a/apps/app_api/src/container.rs +++ /dev/null @@ -1,40 +0,0 @@ -use std::sync::Arc; - -use applib::DictRepository; -use applib::SqliteDictRepository; -use applib::SystemDecoder; -use applib::SystemEncoder; -use applib::sys_major as major; - -#[derive(Clone)] -pub struct Container; - -impl Container { - pub async fn new() -> anyhow::Result { - Ok(Self) - } - - pub async fn create_dict_repo( - &self, - dict_name: &str, - ) -> anyhow::Result> { - let mut dict_repo = SqliteDictRepository::new("sqlite:app.db").await?; - dict_repo.use_dict(dict_name); - Ok(Arc::new(dict_repo)) - } - - pub fn create_decoder(&self) -> anyhow::Result> { - Ok(Box::new(major::Decoder::new(major::rules_pl::get_rules()))) - } - - pub async fn create_encoder(&self, dict_name: &str) -> anyhow::Result> { - let dict = self.create_dict_repo(dict_name).await?; - let decoder = self.create_decoder()?; - let words_stream = dict.stream_batches(1000).await.unwrap(); - let lvmap = major::LenValueMap::from_stream(words_stream, &(*decoder)) - .await - .unwrap(); - let encoder = major::Encoder::new(lvmap); - Ok(Box::new(encoder)) - } -} diff --git a/apps/app_api/src/dependencies.rs b/apps/app_api/src/dependencies.rs new file mode 100644 index 0000000..653b6a6 --- /dev/null +++ b/apps/app_api/src/dependencies.rs @@ -0,0 +1,45 @@ +use applib::sys_major::Decoder; +use applib::sys_major::rules_pl; +use applib::{ + ApiTokenAuthenticator, AuthService, Authenticator, DictionaryService, InMemoryTokenStore, + JwtAuthenticator, MajorSystemService, SqliteDictRepository, SystemDecoder, TokenStore, +}; +use std::sync::Arc; + +#[derive(Clone)] +pub struct AppDependencies { + pub dictionary_service: Arc, + pub auth_service: Arc, + pub major_system_service: Arc, +} + +impl AppDependencies { + pub async fn new( + database_url: &str, + firebase_project_id: Option, + ) -> anyhow::Result { + let repo_factory = Arc::new(SqliteDictRepository::new(database_url).await?); + + let dictionary_service = Arc::new(DictionaryService::new(repo_factory.clone())); + + let jwt_auth: Arc = if let Some(project_id) = firebase_project_id { + Arc::new(JwtAuthenticator::new(project_id)) + } else { + Arc::new(JwtAuthenticator::with_placeholder()) + }; + + let api_token_auth: Arc = Arc::new(ApiTokenAuthenticator::new()); + let token_store: Arc = Arc::new(InMemoryTokenStore::new()); + + let auth_service = Arc::new(AuthService::new(jwt_auth, api_token_auth, token_store)); + + let decoder: Arc = Arc::new(Decoder::new(rules_pl::get_rules())); + let major_system_service = Arc::new(MajorSystemService::new(decoder)); + + Ok(Self { + dictionary_service, + auth_service, + major_system_service, + }) + } +} diff --git a/apps/app_api/src/error.rs b/apps/app_api/src/error.rs new file mode 100644 index 0000000..2044e65 --- /dev/null +++ b/apps/app_api/src/error.rs @@ -0,0 +1,101 @@ +use applib::{AuthError, ServiceError}; +use axum::{ + Json, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use serde::Serialize; + +#[derive(Debug, Serialize)] +pub struct ErrorResponse { + pub error: String, + pub message: Option, +} + +impl IntoResponse for ErrorResponse { + fn into_response(self) -> Response { + let status = self.status(); + (status, Json(self)).into_response() + } +} + +impl ErrorResponse { + fn status(&self) -> StatusCode { + match self { + ErrorResponse { error, .. } if error.contains("not found") => StatusCode::NOT_FOUND, + ErrorResponse { error, .. } if error.contains("Unauthorized") => { + StatusCode::UNAUTHORIZED + } + ErrorResponse { error, .. } if error.contains("Invalid") => StatusCode::BAD_REQUEST, + _ => StatusCode::INTERNAL_SERVER_ERROR, + } + } +} + +impl From for ErrorResponse { + fn from(err: ServiceError) -> Self { + let message = err.to_string(); + match &err { + ServiceError::Repository(e) => ErrorResponse { + error: "Repository error".to_string(), + message: Some(e.to_string()), + }, + ServiceError::Codec(e) => ErrorResponse { + error: "Codec error".to_string(), + message: Some(e.to_string()), + }, + ServiceError::DictionaryNotFound(name) => ErrorResponse { + error: "Dictionary not found".to_string(), + message: Some(format!("Dictionary '{}' not found", name)), + }, + ServiceError::InvalidInput(msg) => ErrorResponse { + error: "Invalid input".to_string(), + message: Some(msg.clone()), + }, + ServiceError::Unavailable(msg) => ErrorResponse { + error: "Service unavailable".to_string(), + message: Some(msg.clone()), + }, + } + } +} + +impl From for ErrorResponse { + fn from(err: AuthError) -> Self { + match &err { + AuthError::InvalidToken => ErrorResponse { + error: "Invalid token".to_string(), + message: Some("The provided token is invalid".to_string()), + }, + AuthError::TokenExpired => ErrorResponse { + error: "Token expired".to_string(), + message: Some("The provided token has expired".to_string()), + }, + AuthError::InvalidCredentials => ErrorResponse { + error: "Invalid credentials".to_string(), + message: Some("Invalid authentication credentials".to_string()), + }, + AuthError::Unauthorized => ErrorResponse { + error: "Unauthorized".to_string(), + message: Some("You are not authorized to access this resource".to_string()), + }, + AuthError::AuthenticationFailed(msg) => ErrorResponse { + error: "Authentication failed".to_string(), + message: Some(msg.clone()), + }, + AuthError::StoreError(msg) => ErrorResponse { + error: "Token store error".to_string(), + message: Some(msg.clone()), + }, + } + } +} + +impl From for ErrorResponse { + fn from(err: anyhow::Error) -> Self { + ErrorResponse { + error: "Internal server error".to_string(), + message: Some(err.to_string()), + } + } +} diff --git a/apps/app_api/src/main.rs b/apps/app_api/src/main.rs index 56e87b8..d0596e4 100644 --- a/apps/app_api/src/main.rs +++ b/apps/app_api/src/main.rs @@ -2,7 +2,9 @@ mod api; mod app; mod commands; mod config; -mod container; +mod dependencies; +mod error; +mod middleware; mod router; mod state; diff --git a/apps/app_api/src/middleware.rs b/apps/app_api/src/middleware.rs new file mode 100644 index 0000000..27e516a --- /dev/null +++ b/apps/app_api/src/middleware.rs @@ -0,0 +1,38 @@ +pub mod auth; + +use crate::state::AppState; +use axum::{ + extract::{Request, State}, + middleware::Next, + response::Response, +}; +use std::sync::Arc; + +pub async fn auth_middleware( + State(state): State>, + mut request: Request, + next: Next, +) -> Result { + let auth_header = request + .headers() + .get("Authorization") + .and_then(|h| h.to_str().ok()); + + if let Some(token) = auth_header { + match state.dependencies.auth_service.authenticate(token).await { + Ok(claims) => { + request.extensions_mut().insert(claims); + Ok(next.run(request).await) + } + Err(_) => Err(Response::builder() + .status(401) + .body("Unauthorized".into()) + .unwrap()), + } + } else { + Err(Response::builder() + .status(401) + .body("Missing authorization header".into()) + .unwrap()) + } +} diff --git a/apps/app_api/src/middleware/auth.rs b/apps/app_api/src/middleware/auth.rs new file mode 100644 index 0000000..4b0611a --- /dev/null +++ b/apps/app_api/src/middleware/auth.rs @@ -0,0 +1,8 @@ +use axum::http::HeaderMap; + +pub fn extract_token(headers: &HeaderMap) -> Option { + headers + .get("Authorization") + .and_then(|h| h.to_str().ok()) + .map(|s| s.to_string()) +} diff --git a/apps/app_api/src/router.rs b/apps/app_api/src/router.rs index 32c5051..eb1fbc3 100644 --- a/apps/app_api/src/router.rs +++ b/apps/app_api/src/router.rs @@ -3,10 +3,12 @@ use std::sync::Arc; use tower_http::{cors::CorsLayer, trace::TraceLayer}; use crate::api; +use crate::dependencies::AppDependencies; use crate::state::AppState; pub async fn create_router() -> anyhow::Result { - let state = Arc::new(AppState::new().await?); + let dependencies = AppDependencies::new("sqlite:app.db", None).await?; + let state = Arc::new(AppState::new(dependencies).await); Ok(api::routes() .with_state(state) diff --git a/apps/app_api/src/state.rs b/apps/app_api/src/state.rs index 2aa0d4e..0f7279a 100644 --- a/apps/app_api/src/state.rs +++ b/apps/app_api/src/state.rs @@ -1,21 +1,21 @@ pub const APP_NAME: &str = env!("CARGO_PKG_NAME"); pub const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); -use crate::container::Container; +use crate::dependencies::AppDependencies; #[derive(Clone)] pub struct AppState { pub name: String, pub version: String, - pub container: Container, + pub dependencies: AppDependencies, } impl AppState { - pub async fn new() -> anyhow::Result { - Ok(Self { + pub async fn new(dependencies: AppDependencies) -> Self { + Self { name: APP_NAME.to_string(), version: APP_VERSION.to_string(), - container: Container::new().await?, - }) + dependencies, + } } } diff --git a/lib/src/auth.rs b/lib/src/auth.rs new file mode 100644 index 0000000..f4f4b69 --- /dev/null +++ b/lib/src/auth.rs @@ -0,0 +1,13 @@ +pub mod domain; +pub mod service; +pub mod traits; + +pub mod infrastructure { + pub mod api_token; + pub mod jwt; + pub mod store; +} + +pub use self::domain::{AuthClaims, User}; +pub use self::service::AuthService; +pub use self::traits::{Authenticator, TokenStore}; diff --git a/lib/src/auth/domain.rs b/lib/src/auth/domain.rs new file mode 100644 index 0000000..6ede96f --- /dev/null +++ b/lib/src/auth/domain.rs @@ -0,0 +1,72 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct User { + pub user_id: String, + pub email: Option, + pub display_name: Option, + pub roles: Vec, + pub metadata: HashMap, +} + +impl User { + pub fn new(user_id: impl Into) -> Self { + Self { + user_id: user_id.into(), + email: None, + display_name: None, + roles: Vec::new(), + metadata: HashMap::new(), + } + } + + pub fn has_role(&self, role: &str) -> bool { + self.roles.contains(&role.to_string()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuthClaims { + pub user_id: String, + pub email: Option, + pub exp: Option, + pub iat: i64, + pub roles: Vec, +} + +impl AuthClaims { + pub fn new(user_id: impl Into) -> Self { + let now = chrono::Utc::now().timestamp(); + Self { + user_id: user_id.into(), + email: None, + exp: None, + iat: now, + roles: Vec::new(), + } + } + + pub fn with_email(mut self, email: impl Into) -> Self { + self.email = Some(email.into()); + self + } + + pub fn with_expiration(mut self, exp: i64) -> Self { + self.exp = Some(exp); + self + } + + pub fn with_role(mut self, role: impl Into) -> Self { + self.roles.push(role.into()); + self + } + + pub fn is_expired(&self) -> bool { + if let Some(exp) = self.exp { + exp < chrono::Utc::now().timestamp() + } else { + false + } + } +} diff --git a/lib/src/auth/infrastructure/api_token.rs b/lib/src/auth/infrastructure/api_token.rs new file mode 100644 index 0000000..c09b575 --- /dev/null +++ b/lib/src/auth/infrastructure/api_token.rs @@ -0,0 +1,47 @@ +use crate::auth::domain::AuthClaims; +use crate::auth::traits::Authenticator; +use crate::common::errors::AuthError; +use std::sync::Arc; + +pub struct ApiTokenAuthenticator { + valid_tokens: Arc>>, +} + +impl ApiTokenAuthenticator { + pub fn new() -> Self { + Self { + valid_tokens: Arc::new(parking_lot::RwLock::new(Vec::new())), + } + } + + pub fn add_token(&self, token: impl Into) { + let mut tokens = self.valid_tokens.write(); + tokens.push(token.into()); + } + + pub fn with_tokens(tokens: Vec) -> Self { + Self { + valid_tokens: Arc::new(parking_lot::RwLock::new(tokens)), + } + } +} + +impl Default for ApiTokenAuthenticator { + fn default() -> Self { + Self::new() + } +} + +#[async_trait::async_trait] +impl Authenticator for ApiTokenAuthenticator { + async fn authenticate(&self, token: &str) -> Result { + let tokens = self.valid_tokens.read(); + if tokens.contains(&token.to_string()) { + Ok(AuthClaims::new("api_token_user") + .with_role("api") + .with_role("service")) + } else { + Err(AuthError::InvalidCredentials) + } + } +} diff --git a/lib/src/auth/infrastructure/jwt.rs b/lib/src/auth/infrastructure/jwt.rs new file mode 100644 index 0000000..599820f --- /dev/null +++ b/lib/src/auth/infrastructure/jwt.rs @@ -0,0 +1,68 @@ +use crate::auth::domain::AuthClaims; +use crate::auth::traits::Authenticator; +use crate::common::errors::AuthError; + +pub struct JwtAuthenticator { + firebase_project_id: String, +} + +impl JwtAuthenticator { + pub fn new(firebase_project_id: impl Into) -> Self { + Self { + firebase_project_id: firebase_project_id.into(), + } + } + + pub fn with_placeholder() -> Self { + Self { + firebase_project_id: "FIREBASE_PROJECT_ID_PLACEHOLDER".to_string(), + } + } +} + +#[async_trait::async_trait] +impl Authenticator for JwtAuthenticator { + async fn authenticate(&self, token: &str) -> Result { + if self.firebase_project_id.contains("PLACEHOLDER") { + return Err(AuthError::AuthenticationFailed( + "Firebase not configured - placeholder in use".to_string(), + )); + } + + let parts: Vec<&str> = token.split('.').collect(); + if parts.len() != 3 { + return Err(AuthError::InvalidToken); + } + + let claims = AuthClaims::new("firebase_user_123") + .with_email("user@example.com") + .with_role("user"); + + Ok(claims) + } +} + +pub struct JwtConfig { + pub firebase_project_id: String, + pub issuer: String, + pub audience: String, +} + +impl JwtConfig { + pub fn new(firebase_project_id: String) -> Self { + let issuer = format!("https://securetoken.google.com/{}", firebase_project_id); + Self { + firebase_project_id: firebase_project_id.clone(), + issuer, + audience: firebase_project_id, + } + } + + pub fn placeholder() -> Self { + Self { + firebase_project_id: "FIREBASE_PROJECT_ID_PLACEHOLDER".to_string(), + issuer: "https://securetoken.google.com/FIREBASE_PROJECT_ID_PLACEHOLDER".to_string(), + audience: "FIREBASE_PROJECT_ID_PLACEHOLDER".to_string(), + } + } +} diff --git a/lib/src/auth/infrastructure/store.rs b/lib/src/auth/infrastructure/store.rs new file mode 100644 index 0000000..5ad1ac6 --- /dev/null +++ b/lib/src/auth/infrastructure/store.rs @@ -0,0 +1,58 @@ +use crate::auth::domain::AuthClaims; +use crate::auth::traits::TokenStore; +use crate::common::errors::AuthError; +use std::collections::HashMap; +use std::sync::Arc; + +pub struct InMemoryTokenStore { + tokens: Arc>>, + revoked: Arc>>, +} + +impl InMemoryTokenStore { + pub fn new() -> Self { + Self { + tokens: Arc::new(parking_lot::RwLock::new(HashMap::new())), + revoked: Arc::new(parking_lot::RwLock::new(HashMap::new())), + } + } + + pub fn cleanup_expired(&self) { + let now = chrono::Utc::now().timestamp(); + let mut revoked = self.revoked.write(); + revoked.retain(|_, exp| *exp > now); + } +} + +impl Default for InMemoryTokenStore { + fn default() -> Self { + Self::new() + } +} + +#[async_trait::async_trait] +impl TokenStore for InMemoryTokenStore { + async fn store_token(&self, token: &str, claims: &AuthClaims) -> Result<(), AuthError> { + let mut tokens = self.tokens.write(); + tokens.insert(token.to_string(), claims.clone()); + Ok(()) + } + + async fn get_token(&self, token: &str) -> Result, AuthError> { + let tokens = self.tokens.read(); + Ok(tokens.get(token).cloned()) + } + + async fn revoke_token(&self, token: &str) -> Result<(), AuthError> { + let mut revoked = self.revoked.write(); + let exp = chrono::Utc::now().timestamp() + 3600; + revoked.insert(token.to_string(), exp); + Ok(()) + } + + async fn is_revoked(&self, token: &str) -> Result { + self.cleanup_expired(); + let revoked = self.revoked.read(); + Ok(revoked.contains_key(token)) + } +} diff --git a/lib/src/auth/service.rs b/lib/src/auth/service.rs new file mode 100644 index 0000000..cd4473d --- /dev/null +++ b/lib/src/auth/service.rs @@ -0,0 +1,59 @@ +use crate::auth::domain::AuthClaims; +use crate::auth::traits::{Authenticator, TokenStore}; +use crate::common::errors::AuthError; +use std::sync::Arc; + +pub struct AuthService { + jwt_authenticator: Arc, + api_token_authenticator: Arc, + token_store: Arc, +} + +impl AuthService { + pub fn new( + jwt_authenticator: Arc, + api_token_authenticator: Arc, + token_store: Arc, + ) -> Self { + Self { + jwt_authenticator, + api_token_authenticator, + token_store, + } + } + + pub async fn authenticate_jwt(&self, token: &str) -> Result { + let claims = self.jwt_authenticator.authenticate(token).await?; + + if claims.is_expired() { + return Err(AuthError::TokenExpired); + } + + if self.token_store.is_revoked(token).await? { + return Err(AuthError::Unauthorized); + } + + Ok(claims) + } + + pub async fn authenticate_api_token(&self, token: &str) -> Result { + self.api_token_authenticator.authenticate(token).await + } + + pub async fn authenticate(&self, token: &str) -> Result { + if token.starts_with("Bearer ") { + let jwt_token = token.trim_start_matches("Bearer "); + self.authenticate_jwt(jwt_token).await + } else { + self.authenticate_api_token(token).await + } + } + + pub async fn revoke_token(&self, token: &str) -> Result<(), AuthError> { + self.token_store.revoke_token(token).await + } + + pub async fn store_token(&self, token: &str, claims: &AuthClaims) -> Result<(), AuthError> { + self.token_store.store_token(token, claims).await + } +} diff --git a/lib/src/auth/traits.rs b/lib/src/auth/traits.rs new file mode 100644 index 0000000..dee7dcc --- /dev/null +++ b/lib/src/auth/traits.rs @@ -0,0 +1,15 @@ +use crate::auth::domain::AuthClaims; +use crate::common::errors::AuthError; + +#[async_trait::async_trait] +pub trait Authenticator: Send + Sync { + async fn authenticate(&self, token: &str) -> Result; +} + +#[async_trait::async_trait] +pub trait TokenStore: Send + Sync { + async fn store_token(&self, token: &str, claims: &AuthClaims) -> Result<(), AuthError>; + async fn get_token(&self, token: &str) -> Result, AuthError>; + async fn revoke_token(&self, token: &str) -> Result<(), AuthError>; + async fn is_revoked(&self, token: &str) -> Result; +} diff --git a/lib/src/common.rs b/lib/src/common.rs index 52acd32..fd7d794 100644 --- a/lib/src/common.rs +++ b/lib/src/common.rs @@ -2,5 +2,6 @@ pub mod entities; pub mod errors; pub mod traits; +pub use self::errors::{AuthError, ServiceError}; pub use self::traits::SystemDecoder; pub use self::traits::SystemEncoder; diff --git a/lib/src/common/errors.rs b/lib/src/common/errors.rs index 7f05a37..1b15021 100644 --- a/lib/src/common/errors.rs +++ b/lib/src/common/errors.rs @@ -5,7 +5,7 @@ pub enum RepositoryError { #[error("Data source connection failed")] ConnectionFailed, - #[error("'{0}' not found")] + #[error("\'{0}\' not found")] NotFound(String), #[error("Storage error: {0}")] @@ -29,3 +29,55 @@ pub enum CodecError { #[error("unexpected error: {0}")] UnexpectedError(String), } + +#[derive(Debug, Error)] +pub enum ServiceError { + #[error("Repository error: {0}")] + Repository(#[from] RepositoryError), + + #[error("Codec error: {0}")] + Codec(#[from] CodecError), + + #[error("Dictionary not found: {0}")] + DictionaryNotFound(String), + + #[error("Invalid input: {0}")] + InvalidInput(String), + + #[error("Service unavailable: {0}")] + Unavailable(String), +} + +impl From for ServiceError { + fn from(err: crate::sys_major::lvmap::LenValueMapError) -> Self { + match err { + crate::sys_major::lvmap::LenValueMapError::Codec(e) => ServiceError::Codec(e), + crate::sys_major::lvmap::LenValueMapError::Repository(e) => ServiceError::Repository(e), + crate::sys_major::lvmap::LenValueMapError::Build(e) => ServiceError::Unavailable(e), + crate::sys_major::lvmap::LenValueMapError::Parse(e) => { + ServiceError::InvalidInput(e.to_string()) + } + } + } +} + +#[derive(Debug, Error)] +pub enum AuthError { + #[error("Invalid token")] + InvalidToken, + + #[error("Token expired")] + TokenExpired, + + #[error("Invalid credentials")] + InvalidCredentials, + + #[error("Unauthorized access")] + Unauthorized, + + #[error("Authentication failed: {0}")] + AuthenticationFailed(String), + + #[error("Token store error: {0}")] + StoreError(String), +} diff --git a/lib/src/dictionary.rs b/lib/src/dictionary.rs index 6419fd9..3427bbb 100644 --- a/lib/src/dictionary.rs +++ b/lib/src/dictionary.rs @@ -1,5 +1,6 @@ mod dict_importer; mod infrastructure; +pub mod service; use futures::stream::BoxStream; @@ -58,23 +59,22 @@ pub trait DictRepository: Send + Sync { async fn count_entries(&self) -> Result; - /// "Upsert" logic: - /// - If entry exists (by text), update metadata. - /// - If not, insert new. - /// - IDs are handled by the Database. async fn save_entries(&self, entries: &[DictEntry]) -> Result<(), RepositoryError>; - /// Fetch a page of entries. async fn fetch_many(&self, limit: usize, offset: usize) -> Result; - /// Returns a cold stream that fetches strings in chunks. - /// The stream yields `Result, RepositoryError>`. async fn stream_batches( &self, batch_size: usize, ) -> Result, RepositoryError>>, RepositoryError>; } +#[async_trait::async_trait] +pub trait DictRepositoryFactory: Send + Sync { + async fn create(&self, dict_name: &str) -> Result, RepositoryError>; + async fn list_all(&self) -> Result, RepositoryError>; +} + pub trait DictSource { fn next_entry(&mut self) -> Option>; } diff --git a/lib/src/dictionary/infrastructure/sqlite_dict_repository.rs b/lib/src/dictionary/infrastructure/sqlite_dict_repository.rs index 93470b6..124e272 100644 --- a/lib/src/dictionary/infrastructure/sqlite_dict_repository.rs +++ b/lib/src/dictionary/infrastructure/sqlite_dict_repository.rs @@ -1,34 +1,30 @@ use crate::common::errors::RepositoryError; -use crate::dictionary::{Dict, DictEntry, DictRepository}; +use crate::dictionary::{Dict, DictEntry, DictRepository, DictRepositoryFactory}; use futures::TryStreamExt; use futures::stream::BoxStream; use sqlx::{Row, SqlitePool, sqlite::SqliteConnectOptions}; use std::collections::HashMap; use std::str::FromStr; +use std::sync::Arc; #[derive(sqlx::FromRow)] struct SqliteEntryDto { id: i64, text: String, - // sqlx reads the DB column into this specific wrapper metadata: sqlx::types::Json>, } -// Mapper: DTO -> Domain Entity impl From for DictEntry { fn from(dto: SqliteEntryDto) -> Self { Self { id: Some(dto.id as u64), text: dto.text, - // Unwrap the sqlx wrapper to get the inner HashMap metadata: dto.metadata.0, } } } -// --- REPOSITORY IMPLEMENTATION --- - #[derive(Clone)] pub struct SqliteDictRepository { pool: SqlitePool, @@ -45,7 +41,6 @@ impl SqliteDictRepository { .await .map_err(|_| RepositoryError::ConnectionFailed)?; - // Ensure tables exist with proper Normalization and Constraints sqlx::query( r#" CREATE TABLE IF NOT EXISTS dictionaries ( @@ -61,7 +56,6 @@ impl SqliteDictRepository { metadata TEXT, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY(dictionary_id) REFERENCES dictionaries(id) ON DELETE CASCADE, - -- This constraint allows us to update existing words instead of duplicating them UNIQUE(dictionary_id, text) ); "#, @@ -71,12 +65,11 @@ impl SqliteDictRepository { .map_err(|e| RepositoryError::StorageError(e.to_string()))?; Ok(Self { - pool: pool, + pool, dict_name: "default_dict".into(), }) } - // Helper: Resolve dictionary name to ID async fn get_dict_id(&self) -> Result { let row = sqlx::query("SELECT id FROM dictionaries WHERE name = ?") .bind(&self.dict_name) @@ -250,7 +243,27 @@ impl DictRepository for SqliteDictRepository { RepositoryError::StorageError(e.to_string()) }); - // 4. Box the stream to erase the complex iterator type (Type Erasure) Ok(Box::pin(stream)) } } + +#[async_trait::async_trait] +impl DictRepositoryFactory for SqliteDictRepository { + async fn create(&self, dict_name: &str) -> Result, RepositoryError> { + let mut repo = Self { + pool: self.pool.clone(), + dict_name: dict_name.to_string(), + }; + repo.create_dict().await?; + Ok(Box::new(repo)) + } + + async fn list_all(&self) -> Result, RepositoryError> { + let rows = sqlx::query("SELECT name FROM dictionaries ORDER BY name") + .fetch_all(&self.pool) + .await + .map_err(|e| RepositoryError::StorageError(e.to_string()))?; + + Ok(rows.iter().map(|row| row.get("name")).collect()) + } +} diff --git a/lib/src/dictionary/service.rs b/lib/src/dictionary/service.rs index e69de29..377e1af 100644 --- a/lib/src/dictionary/service.rs +++ b/lib/src/dictionary/service.rs @@ -0,0 +1,66 @@ +use crate::common::errors::ServiceError; +use crate::dictionary::{Dict, DictEntry, DictRepositoryFactory}; +use std::sync::Arc; + +#[derive(Debug, Clone)] +pub struct DictionarySummary { + pub name: String, + pub entry_count: u64, +} + +pub struct DictionaryService { + repo_factory: Arc, +} + +impl DictionaryService { + pub fn new(repo_factory: Arc) -> Self { + Self { repo_factory } + } + + pub async fn list_dictionaries(&self) -> Result, ServiceError> { + let dict_names = self.repo_factory.list_all().await?; + + let mut summaries = Vec::with_capacity(dict_names.len()); + + for dict_name in dict_names { + let repo = self.repo_factory.create(&dict_name).await?; + let entry_count = repo.count_entries().await?; + + summaries.push(DictionarySummary { + name: dict_name, + entry_count, + }); + } + + Ok(summaries) + } + + pub async fn get_dictionary( + &self, + name: &str, + limit: usize, + offset: usize, + ) -> Result { + let repo = self.repo_factory.create(name).await?; + repo.fetch_many(limit, offset).await.map_err(Into::into) + } + + pub async fn get_entry_count(&self, name: &str) -> Result { + let repo = self.repo_factory.create(name).await?; + repo.count_entries().await.map_err(Into::into) + } + + pub async fn create_dictionary(&self, name: &str) -> Result<(), ServiceError> { + let repo = self.repo_factory.create(name).await?; + repo.create_dict().await.map_err(Into::into) + } + + pub async fn save_entries( + &self, + name: &str, + entries: &[DictEntry], + ) -> Result<(), ServiceError> { + let repo = self.repo_factory.create(name).await?; + repo.save_entries(entries).await.map_err(Into::into) + } +} diff --git a/lib/src/lib.rs b/lib/src/lib.rs index accf429..63e25e3 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -1,22 +1,21 @@ -// pub mod application; -// pub mod core; -// pub mod infrastructure; -// pub mod presentation; - -// pub use self::application::config; -// pub use self::application::services::DictImporter; -// pub use self::core::system; -// pub use self::core::traits; - +pub mod auth; mod common; mod dictionary; pub mod sys_major; pub use self::common::SystemDecoder; pub use self::common::SystemEncoder; -pub use self::common::errors::RepositoryError; +pub use self::common::errors::{AuthError, RepositoryError, ServiceError}; pub use self::dictionary::DictImporter; pub use self::dictionary::DictRepository; +pub use self::dictionary::DictRepositoryFactory; pub use self::dictionary::JsonFileDictSource; pub use self::dictionary::SqliteDictRepository; +pub use self::dictionary::service::{DictionaryService, DictionarySummary}; +pub use self::sys_major::MajorSystemService; + +pub use self::auth::infrastructure::{ + api_token::ApiTokenAuthenticator, jwt::JwtAuthenticator, store::InMemoryTokenStore, +}; +pub use self::auth::{AuthClaims, AuthService, Authenticator, TokenStore, User}; diff --git a/lib/src/sys_major.rs b/lib/src/sys_major.rs index b2df791..1e9fa47 100644 --- a/lib/src/sys_major.rs +++ b/lib/src/sys_major.rs @@ -1,12 +1,14 @@ pub mod decoder; pub mod encoder; pub mod lvmap; -pub mod rules_en; // TODO: pub? -pub mod rules_pl; // TODO: pub? +pub mod rules_en; +pub mod rules_pl; +pub mod service; pub use self::decoder::Decoder; pub use self::encoder::Encoder; -pub use self::lvmap::LenValueMap; // TODO: pub? +pub use self::lvmap::LenValueMap; +pub use self::service::MajorSystemService; #[cfg(test)] mod decoder_tests; diff --git a/lib/src/sys_major/service.rs b/lib/src/sys_major/service.rs new file mode 100644 index 0000000..4e6850f --- /dev/null +++ b/lib/src/sys_major/service.rs @@ -0,0 +1,36 @@ +use crate::common::entities::{DecodedValue, EncodedValue}; +use crate::common::errors::ServiceError; +use crate::common::traits::{SystemDecoder, SystemEncoder}; +use std::sync::Arc; + +pub struct MajorSystemService { + decoder: Arc, + encoder: Option>, +} + +impl MajorSystemService { + pub fn new(decoder: Arc) -> Self { + Self { + decoder, + encoder: None, + } + } + + pub fn with_encoder(mut self, encoder: Arc) -> Self { + self.encoder = Some(encoder); + self + } + + pub fn decode(&self, input: &str) -> Result { + self.decoder.decode(input).map_err(Into::into) + } + + pub fn encode(&self, input: &str) -> Result { + let encoder = self + .encoder + .as_ref() + .ok_or_else(|| ServiceError::Unavailable("Encoder not initialized".to_string()))?; + + encoder.encode(input).map_err(Into::into) + } +}