From b44526f5cb994a55199d9ff6ef400d7658ec0b86 Mon Sep 17 00:00:00 2001 From: chodak166 Date: Wed, 1 Apr 2026 11:12:02 +0200 Subject: [PATCH] Refactored code structure; added tavern test plans --- README.md | 152 ++++++++- apps/app_api/config.toml.example | 10 +- apps/app_api/src/api.rs | 9 + apps/app_api/src/api/v1.rs | 75 +++++ apps/app_api/src/api/v1/auth.rs | 40 +++ apps/app_api/src/api/v1/dictionary.rs | 47 +++ apps/app_api/src/api/v1/info.rs | 46 +++ apps/app_api/src/api/v1/major.rs | 84 +++++ apps/app_api/src/app.rs | 7 +- apps/app_api/src/commands.rs | 7 +- apps/app_api/src/commands/listen.rs | 8 +- apps/app_api/src/config.rs | 15 +- apps/app_api/src/config/auth.rs | 18 ++ apps/app_api/src/config/database.rs | 14 + apps/app_api/src/container.rs | 31 -- apps/app_api/src/dependencies.rs | 52 ++++ apps/app_api/src/error.rs | 100 ++++++ apps/app_api/src/main.rs | 5 +- apps/app_api/src/router.rs | 31 +- apps/app_api/src/router/handlers.rs | 25 -- apps/app_api/src/router/responses.rs | 34 --- apps/app_api/src/{router => }/state.rs | 12 +- apps/app_cli/src/commands.rs | 21 ++ apps/app_cli/src/commands/list_dicts.rs | 77 +++++ apps/app_cli/src/config.rs | 5 +- config.toml | 15 +- lib/Cargo.toml | 3 + 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 | 288 ++++++++++++++++++ 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/entities.rs | 42 +-- lib/src/common/errors.rs | 54 +++- lib/src/common/traits.rs | 32 +- lib/src/dictionary.rs | 74 +++++ lib/src/dictionary/dict_importer.rs | 2 +- .../infrastructure/json_file_dict_source.rs | 4 +- .../infrastructure/sqlite_dict_repository.rs | 65 +++- lib/src/dictionary/service.rs | 66 ++++ lib/src/lib.rs | 23 +- lib/src/sys_major.rs | 8 +- lib/src/sys_major/service.rs | 36 +++ tavern-tests/export.sh | 15 + tavern-tests/requirements.txt | 3 + tavern-tests/tavern-run-all.sh | 20 ++ tavern-tests/tavern-run-single.sh | 19 ++ tavern-tests/test_plans/common_stages.yaml | 29 ++ .../test_plans/decode_test.tavern.yaml | 29 ++ .../test_plans/dictionary_test.tavern.yaml | 30 ++ .../test_plans/encode_test.tavern.yaml | 44 +++ tavern-tests/test_plans/includes.yaml | 6 + .../test_plans/version_test.tavern.yaml | 20 ++ 56 files changed, 1874 insertions(+), 243 deletions(-) create mode 100644 apps/app_api/src/api.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 create mode 100644 apps/app_api/src/api/v1/info.rs create mode 100644 apps/app_api/src/api/v1/major.rs 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 delete mode 100644 apps/app_api/src/router/handlers.rs delete mode 100644 apps/app_api/src/router/responses.rs rename apps/app_api/src/{router => }/state.rs (66%) create mode 100644 apps/app_cli/src/commands/list_dicts.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/dictionary/service.rs create mode 100644 lib/src/sys_major/service.rs create mode 100644 tavern-tests/export.sh create mode 100644 tavern-tests/requirements.txt create mode 100755 tavern-tests/tavern-run-all.sh create mode 100755 tavern-tests/tavern-run-single.sh create mode 100644 tavern-tests/test_plans/common_stages.yaml create mode 100644 tavern-tests/test_plans/decode_test.tavern.yaml create mode 100644 tavern-tests/test_plans/dictionary_test.tavern.yaml create mode 100644 tavern-tests/test_plans/encode_test.tavern.yaml create mode 100644 tavern-tests/test_plans/includes.yaml create mode 100644 tavern-tests/test_plans/version_test.tavern.yaml diff --git a/README.md b/README.md index 0defe30..8654d31 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,152 @@ -# mnemo-r +# Phomnemic +A phonemic encoding/decoding system with dictionary management and REST API. + +## Configure and Run + +### Prerequisites + +- Rust 1.70 or later +- SQLite (for local database) +- Firebase Auth (optional, for JWT authentication) + +### Installation + +```bash +cargo build --release +``` + +### Configuration + +Create a `config.toml` file in the project root: + +```toml +[listen] +host = "0.0.0.0" +port = 3000 + +log_level = "info" + +[auth] +firebase_project_id = "your-firebase-project-id" +firebase_emulator_url = "http://localhost:9099" +api_tokens = ["your-api-key-1", "your-api-key-2"] + +[database] +url = "sqlite:app.db" +``` + +#### Authentication Options + +The application supports two authentication methods: + +1. **Firebase JWT Authentication** (for frontend clients) + - Set `firebase_project_id` to your Firebase project ID + - Or use `firebase_emulator_url` for local development with Firebase Auth Emulator + +2. **API Key Authentication** (for server-to-server or CLI access) + - Add valid API keys to the `api_tokens` array + - Send requests with `X-API-Key` header containing your API key + +### Running with Firebase Auth Emulator + +1. Start the Firebase Auth Emulator: + ```bash + firebase emulators:start --only auth + ``` + The emulator runs on port 9099 by default. + +2. Configure the application to use the emulator: + ```toml + [auth] + firebase_emulator_url = "http://localhost:9099" + api_tokens = ["dev-api-key"] + ``` + +3. Get a test token from the emulator: + ```bash + # Using curl + curl -X POST "http://localhost:9099/identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=fake-api-key" \ + -H "Content-Type: application/json" \ + -d '{ + "email": "test@example.com", + "password": "password", + "returnSecureToken": true + }' + ``` + +4. Make authenticated requests: + ```bash + curl -X GET "http://localhost:3000/api/v1/info" \ + -H "Authorization: Bearer " + ``` + +### Running with API Keys + +1. Configure API keys in `config.toml`: + ```toml + [auth] + api_tokens = ["my-secret-api-key"] + ``` + +2. Make authenticated requests: + ```bash + curl -X GET "http://localhost:3000/api/v1/info" \ + -H "X-API-Key: my-secret-api-key" + ``` + +### Starting the Server + +```bash +cargo run --release -- listen +``` + +Or with custom options: + +```bash +cargo run --release -- listen --host 127.0.0.1 --port 8080 +``` + +### API Endpoints + +#### info Check +```bash +GET /api/v1/info +``` + +#### Dictionary Management +```bash +GET /api/v1/dicts +GET /api/v1/dicts/:name +``` + +#### Major System Encoding/Decoding +```bash +POST /api/v1/major/encode +POST /api/v1/major/decode +``` + +#### Authentication +```bash +POST /api/v1/auth/login +``` + +### Development + +Run in development mode with auto-reload: + +```bash +cargo run -- listen +``` + +### Testing + +Run tests: + +```bash +cargo test +``` + +### License + +MIT diff --git a/apps/app_api/config.toml.example b/apps/app_api/config.toml.example index 0309283..8560164 100644 --- a/apps/app_api/config.toml.example +++ b/apps/app_api/config.toml.example @@ -2,4 +2,12 @@ host = "0.0.0.0" port = 3000 -log_level = "info" \ No newline at end of file +log_level = "info" + +[auth] +firebase_project_id = "your-firebase-project-id" +firebase_emulator_url = "http://localhost:9099" +api_tokens = ["your-api-key-1", "your-api-key-2"] + +[database] +url = "sqlite:app.db" \ No newline at end of file diff --git a/apps/app_api/src/api.rs b/apps/app_api/src/api.rs new file mode 100644 index 0000000..18c3dff --- /dev/null +++ b/apps/app_api/src/api.rs @@ -0,0 +1,9 @@ +pub mod v1; + +use crate::state::AppState; +use axum::Router; +use std::sync::Arc; + +pub fn routes(state: Arc) -> Router> { + Router::new().nest("/api/v1", v1::routes(state)) +} diff --git a/apps/app_api/src/api/v1.rs b/apps/app_api/src/api/v1.rs new file mode 100644 index 0000000..0ffed1f --- /dev/null +++ b/apps/app_api/src/api/v1.rs @@ -0,0 +1,75 @@ +pub mod auth; +pub mod dictionary; +pub mod info; +pub mod major; + +use crate::state::AppState; +use axum::{ + Json, Router, + extract::Request, + extract::State, + http::StatusCode, + middleware::Next, + response::{IntoResponse, Response}, +}; +use serde::Serialize; +use std::sync::Arc; + +#[derive(Debug, Serialize)] +struct ErrorResponseBody { + error: String, +} + +pub fn routes(state: Arc) -> Router> { + Router::new() + .nest("/info", info::routes()) + .nest("/auth", auth::routes()) + .nest("/dicts", dictionary::routes()) + .nest("/major", major::routes()) + .route_layer(axum::middleware::from_fn_with_state( + state, + |state: State>, request: Request, next: Next| async move { + auth_middleware_inner(state, request, next).await + }, + )) +} + +async fn auth_middleware_inner( + state: State>, + mut request: Request, + next: Next, +) -> Response { + let auth_header = request + .headers() + .get("Authorization") + .and_then(|h| h.to_str().ok()); + + let api_key_header = request + .headers() + .get("X-API-Key") + .and_then(|h| h.to_str().ok()); + + let token = if let Some(header) = auth_header { + header.to_string() + } else if let Some(key) = api_key_header { + key.to_string() + } else { + let error = ErrorResponseBody { + error: "Missing authorization header or API key".to_string(), + }; + return (StatusCode::UNAUTHORIZED, Json(error)).into_response(); + }; + + match state.0.dependencies.auth_service.authenticate(&token).await { + Ok(claims) => { + request.extensions_mut().insert(claims); + next.run(request).await + } + Err(_) => { + let error = ErrorResponseBody { + error: "Unauthorized".to_string(), + }; + (StatusCode::UNAUTHORIZED, Json(error)).into_response() + } + } +} 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..27b5c49 --- /dev/null +++ b/apps/app_api/src/api/v1/auth.rs @@ -0,0 +1,40 @@ +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: Option, +} + +#[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 token = req.token.ok_or_else(|| ErrorResponse { + error: "Invalid input".to_string(), + message: Some("Token field is required".to_string()), + })?; + + let claims = state.dependencies.auth_service.authenticate(&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..8408393 --- /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/v1/info.rs b/apps/app_api/src/api/v1/info.rs new file mode 100644 index 0000000..b68378a --- /dev/null +++ b/apps/app_api/src/api/v1/info.rs @@ -0,0 +1,46 @@ +use axum::{ + Json, Router, + extract::State, + routing::{get, post}, +}; +use chrono::Utc; +use serde::Serialize; +use serde_json::Value; +use std::sync::Arc; + +use crate::state::AppState; + +#[derive(Debug, Serialize)] +pub struct EchoResponse { + pub data: Value, + pub timestamp: String, +} + +#[derive(Debug, Serialize)] +pub struct VersionResponse { + pub name: String, + pub version: String, +} + +pub async fn echo_handler( + State(_state): State>, + Json(payload): Json, +) -> Json { + Json(EchoResponse { + data: payload, + timestamp: Utc::now().to_rfc3339(), + }) +} + +pub async fn version_handler(State(state): State>) -> Json { + Json(VersionResponse { + name: state.name.clone(), + version: state.version.clone(), + }) +} + +pub fn routes() -> Router> { + Router::new() + .route("/echo", post(echo_handler)) + .route("/version", get(version_handler)) +} diff --git a/apps/app_api/src/api/v1/major.rs b/apps/app_api/src/api/v1/major.rs new file mode 100644 index 0000000..36ddfe3 --- /dev/null +++ b/apps/app_api/src/api/v1/major.rs @@ -0,0 +1,84 @@ +use axum::{ + Json, Router, + extract::{Path, Query, State}, + routing::get, +}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +use crate::error::ErrorResponse; +use crate::state::AppState; + +#[derive(Debug, Deserialize)] +pub struct EncodeQuery { + pub dict: Option, +} + +#[derive(Debug, Deserialize)] +pub struct DecodeQuery {} + +#[derive(Debug, Serialize)] +pub struct EncodeResponse { + pub input: String, + pub dict: String, + pub result: Vec>, +} + +#[derive(Debug, Serialize)] +pub struct EncodePart { + pub value: u64, + pub words: Vec, +} + +#[derive(Debug, Serialize)] +pub struct DecodeResponse { + pub input: String, + pub result: String, +} + +pub async fn encode_handler( + State(state): State>, + Path(input): Path, + Query(params): Query, +) -> Result, ErrorResponse> { + let dict_name = params.dict.unwrap_or_else(|| "demo_pl".to_string()); + let result = state.dependencies.major_system_service.encode(&input)?; + + let encoded_parts: Vec> = result + .iter() + .map(|split| { + split + .iter() + .map(|part| EncodePart { + value: part.value, + words: part.words.clone(), + }) + .collect() + }) + .collect(); + + Ok(Json(EncodeResponse { + input, + dict: dict_name, + result: encoded_parts, + })) +} + +pub async fn decode_handler( + State(state): State>, + Path(input): Path, + Query(_params): Query, +) -> Result, ErrorResponse> { + let result = state.dependencies.major_system_service.decode(&input)?; + + Ok(Json(DecodeResponse { + input, + result: result.as_str().to_string(), + })) +} + +pub fn routes() -> Router> { + Router::new() + .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 431004d..2ee2262 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; @@ -32,6 +31,7 @@ impl Configurable for ListenCmd { builder: ConfigBuilder, ) -> Result> { builder + .set_default("log_level", defaults::LOG_LEVEL)? .set_default("listen.host", defaults::LISTEN_HOST)? .set_default("listen.port", defaults::LISTEN_PORT) .map_err(Into::into) @@ -55,18 +55,19 @@ 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() .ok_or_else(|| anyhow::anyhow!("Listen config missing"))?; - let app = router::create_router(); + let app = router::create_router(config).await?; let addr = format!("{}:{}", listen_config.host, listen_config.port); let listener = TcpListener::bind(&addr).await?; info!("Starting server on {}", addr); + // axum::serve(listener, app.into_make_service()).await?; axum::serve(listener, app) .with_graceful_shutdown(shutdown_signal()) .await?; @@ -104,6 +105,7 @@ async fn shutdown_signal() { mod defaults { use const_format::formatcp; + pub const LOG_LEVEL: &str = "info"; pub const LISTEN_HOST: &str = "0.0.0.0"; pub const LISTEN_PORT: u16 = 3000; pub const HELP_LISTEN_HOST: &str = formatcp!("Host address [default: {}]", LISTEN_HOST); 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..dee2101 --- /dev/null +++ b/apps/app_api/src/config/auth.rs @@ -0,0 +1,18 @@ +use serde::Deserialize; + +#[derive(Debug, Deserialize, Clone)] +pub struct AuthConfig { + pub firebase_project_id: Option, + pub firebase_emulator_url: Option, + pub api_tokens: Vec, +} + +impl Default for AuthConfig { + fn default() -> Self { + Self { + firebase_project_id: None, + firebase_emulator_url: 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 981a0b3..0000000 --- a/apps/app_api/src/container.rs +++ /dev/null @@ -1,31 +0,0 @@ -// use std::sync::Arc; - -// use applib::DictImporter; -// 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_importer(&self, dict_name: &str) -> anyhow::Result { - // let repo = self.create_dict_repo(dict_name).await?; - // Ok(DictImporter::new(repo)) - // } - - // 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)) - // } -} diff --git a/apps/app_api/src/dependencies.rs b/apps/app_api/src/dependencies.rs new file mode 100644 index 0000000..ee9262b --- /dev/null +++ b/apps/app_api/src/dependencies.rs @@ -0,0 +1,52 @@ +use applib::sys_major::rules_pl; +use applib::sys_major::{Decoder, Encoder, LenValueMap}; +use applib::{ + ApiTokenAuthenticator, AuthService, Authenticator, DictionaryService, InMemoryTokenStore, + JwtAuthenticator, MajorSystemService, SqliteDictRepository, SystemDecoder, SystemEncoder, + 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, + firebase_emulator_url: Option, + api_tokens: Vec, + ) -> 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(emulator_url) = firebase_emulator_url { + Arc::new(JwtAuthenticator::with_emulator(emulator_url)) + } else if let Some(project_id) = firebase_project_id { + Arc::new(JwtAuthenticator::new(project_id)) + } else { + Arc::new(JwtAuthenticator::with_placeholder()) + }; + + let api_token_authenticator = ApiTokenAuthenticator::with_tokens(api_tokens); + let api_token_auth: Arc = Arc::new(api_token_authenticator); + 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 encoder: Arc = Arc::new(Encoder::new(LenValueMap::new())); + let major_system_service = Arc::new(MajorSystemService::new(decoder).with_encoder(encoder)); + + 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..95e1884 --- /dev/null +++ b/apps/app_api/src/error.rs @@ -0,0 +1,100 @@ +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 { + 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 6ec4d00..cf28f90 100644 --- a/apps/app_api/src/main.rs +++ b/apps/app_api/src/main.rs @@ -1,8 +1,11 @@ +mod api; mod app; mod commands; mod config; -mod container; +mod dependencies; +mod error; mod router; +mod state; use anyhow::Result; use app::Application; diff --git a/apps/app_api/src/router.rs b/apps/app_api/src/router.rs index 16faf29..f85c596 100644 --- a/apps/app_api/src/router.rs +++ b/apps/app_api/src/router.rs @@ -1,23 +1,26 @@ -use axum::{ - Router, - routing::{get, post}, -}; use std::sync::Arc; use tower_http::{cors::CorsLayer, trace::TraceLayer}; -mod handlers; -mod responses; -mod state; +use crate::api; +use crate::config::AppConfig; +use crate::dependencies::AppDependencies; +use crate::state::AppState; -pub use state::AppState; +pub async fn create_router(config: &AppConfig) -> anyhow::Result { + let database_url = &config.database.url; -pub fn create_router() -> Router { - let state = Arc::new(AppState::new()); + let dependencies = AppDependencies::new( + database_url, + config.auth.firebase_project_id.clone(), + config.auth.firebase_emulator_url.clone(), + config.auth.api_tokens.clone(), + ) + .await?; - Router::new() - .route("/api/echo", post(handlers::echo_handler)) - .route("/api/version", get(handlers::version_handler)) + let state = Arc::new(AppState::new(dependencies).await); + + Ok(api::routes(state.clone()) .with_state(state) .layer(TraceLayer::new_for_http()) - .layer(CorsLayer::permissive()) + .layer(CorsLayer::permissive())) } diff --git a/apps/app_api/src/router/handlers.rs b/apps/app_api/src/router/handlers.rs deleted file mode 100644 index ea79e21..0000000 --- a/apps/app_api/src/router/handlers.rs +++ /dev/null @@ -1,25 +0,0 @@ -use axum::{Json, extract::State}; -use chrono::Utc; -use serde_json::Value; -use std::sync::Arc; - -use super::responses::{EchoResponse, ErrorResponse, VersionResponse}; -use super::state::AppState; - -pub async fn echo_handler( - State(_state): State>, - Json(payload): Json, -) -> Result, ErrorResponse> { - let response = EchoResponse { - data: payload, - timestamp: Utc::now().to_rfc3339(), - }; - Ok(Json(response)) -} - -pub async fn version_handler(State(state): State>) -> Json { - Json(VersionResponse { - name: state.name.clone(), - version: state.version.clone(), - }) -} diff --git a/apps/app_api/src/router/responses.rs b/apps/app_api/src/router/responses.rs deleted file mode 100644 index e2ab6cd..0000000 --- a/apps/app_api/src/router/responses.rs +++ /dev/null @@ -1,34 +0,0 @@ -use axum::{Json, http::StatusCode, response::IntoResponse}; -use serde::Serialize; -use serde_json::Value; - -#[derive(Debug, Serialize)] -pub struct EchoResponse { - pub data: Value, - pub timestamp: String, -} - -#[derive(Debug, Serialize)] -pub struct VersionResponse { - pub name: String, - pub version: String, -} - -#[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: E) -> Self { - Self { - error: err.to_string(), - } - } -} diff --git a/apps/app_api/src/router/state.rs b/apps/app_api/src/state.rs similarity index 66% rename from apps/app_api/src/router/state.rs rename to apps/app_api/src/state.rs index 5a2e6b4..0f7279a 100644 --- a/apps/app_api/src/router/state.rs +++ b/apps/app_api/src/state.rs @@ -1,23 +1,21 @@ pub const APP_NAME: &str = env!("CARGO_PKG_NAME"); pub const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); +use crate::dependencies::AppDependencies; + #[derive(Clone)] pub struct AppState { pub name: String, pub version: String, + pub dependencies: AppDependencies, } impl AppState { - pub fn new() -> Self { + pub async fn new(dependencies: AppDependencies) -> Self { Self { name: APP_NAME.to_string(), version: APP_VERSION.to_string(), + dependencies, } } } - -impl Default for AppState { - fn default() -> Self { - Self::new() - } -} diff --git a/apps/app_cli/src/commands.rs b/apps/app_cli/src/commands.rs index ab7645a..6dcef7a 100644 --- a/apps/app_cli/src/commands.rs +++ b/apps/app_cli/src/commands.rs @@ -1,6 +1,7 @@ pub mod decode; pub mod encode; pub mod import_dict; +pub mod list_dicts; use crate::config::AppConfig; use crate::container::Container; @@ -22,6 +23,9 @@ pub enum Command { /// Import dictionary ImportDict(import_dict::ImportDictCmd), + + /// List all dictionaries + ListDicts(list_dicts::ListDictsCmd), } impl Command { @@ -30,10 +34,27 @@ impl Command { Command::Decode(cmd) => Box::new(cmd), Command::Encode(cmd) => Box::new(cmd), Command::ImportDict(cmd) => Box::new(cmd), + Command::ListDicts(cmd) => Box::new(cmd), } } } +// pub fn resolve_command(command: &Command) -> &dyn AppCommand { +// match command { +// Command::Decode(app_cmd) => app_cmd, +// Command::Encode(app_cmd) => app_cmd, +// Command::ImportDict(app_cmd) => app_cmd, +// } +// } + +// pub fn resolve_command_box(command: Command) -> Box { +// match command { +// Command::Decode(cmd) => Box::new(cmd), +// Command::Encode(cmd) => Box::new(cmd), +// Command::ImportDict(cmd) => Box::new(cmd), +// } +// } + #[derive(Parser, Debug)] #[command(author, version, about)] pub struct CliArgs { diff --git a/apps/app_cli/src/commands/list_dicts.rs b/apps/app_cli/src/commands/list_dicts.rs new file mode 100644 index 0000000..e85acb5 --- /dev/null +++ b/apps/app_cli/src/commands/list_dicts.rs @@ -0,0 +1,77 @@ +use crate::commands::{ClapArgs, Configurable, Executable}; +use crate::config::AppConfig; +use crate::container::Container; +use anyhow::Result; +use async_trait::async_trait; +use config::ConfigBuilder; +use config::builder::DefaultState; +use serde::Deserialize; + +#[derive(Debug, Deserialize, Clone)] +pub struct Config { + pub show_counts: bool, +} + +#[derive(ClapArgs, Debug, Clone)] +pub struct ListDictsCmd { + #[arg(short, long, help = defaults::HELP_LIST_DICTS_COUNTS)] + pub show_counts: bool, +} + +impl Configurable for ListDictsCmd { + fn apply_defaults( + &self, + builder: ConfigBuilder, + ) -> Result> { + builder + .set_default("list_dicts.show_counts", defaults::SHOW_COUNTS) + .map_err(Into::into) + } + + fn apply_overrides( + &self, + builder: ConfigBuilder, + ) -> Result> { + builder + .set_override("list_dicts.show_counts", self.show_counts) + .map_err(Into::into) + } +} + +#[async_trait] +impl Executable for ListDictsCmd { + async fn execute(&self, config: &AppConfig, container: &Container) -> Result<()> { + let config = config + .list_dicts + .as_ref() + .expect("ListDicts config not set"); + + let repo = container.create_dict_repo("default").await?; + + let dicts = repo.fetch_dicts().await?; + + if dicts.is_empty() { + println!("No dictionaries found."); + return Ok(()); + } + + println!("Dictionaries:"); + for dict_name in &dicts { + if config.show_counts { + let count_repo = container.create_dict_repo(dict_name).await?; + let count = count_repo.count_entries().await?; + println!(" - {} ({} entries)", dict_name, count); + } else { + println!(" - {}", dict_name); + } + } + + Ok(()) + } +} + +mod defaults { + use const_format::formatcp; + pub const SHOW_COUNTS: bool = false; + pub const HELP_LIST_DICTS_COUNTS: &str = formatcp!("Show entry counts for each dictionary"); +} diff --git a/apps/app_cli/src/config.rs b/apps/app_cli/src/config.rs index 23acf15..7b28e3d 100644 --- a/apps/app_cli/src/config.rs +++ b/apps/app_cli/src/config.rs @@ -1,5 +1,4 @@ use crate::commands::*; -// use crate::commands::{Configurable, GlobalArgs}; use anyhow::{Context, Result}; use config::{Config, Environment, File}; use serde::Deserialize; @@ -12,6 +11,8 @@ pub struct AppConfig { pub encode: Option, #[serde(default)] pub import_dict: Option, + #[serde(default)] + pub list_dicts: Option, pub log_level: String, } @@ -28,7 +29,7 @@ impl AppConfig { builder = builder.add_source(File::from(config_path.as_path()).required(!is_default_path)); - // Environment Layer (e.g. APP_LISTEN_PORT) + // Environment Layer (APP_SERVER_PORT) builder = builder.add_source(Environment::with_prefix("APP").separator("_")); // Global log level override diff --git a/config.toml b/config.toml index db1cf0c..3a5c3e2 100644 --- a/config.toml +++ b/config.toml @@ -1,4 +1,13 @@ -log_level = "info" +log_level = "trace" -[decode] -input = "CONFIGTEST" +[listen] +host = "0.0.0.0" +port = 3000 + +[auth] +firebase_project_id = "phomnemic" +# firebase_emulator_url = "http://192.168.1.23:9099" +api_tokens = ["AIzaSyCCgWH9Qg5vLTMFYLTuU0tyLFKBgtBkucE", "test-api-key"] + +[database] +url = "sqlite:app.db" diff --git a/lib/Cargo.toml b/lib/Cargo.toml index a62f2e5..a765b95 100644 --- a/lib/Cargo.toml +++ b/lib/Cargo.toml @@ -19,6 +19,9 @@ async-trait = "0.1" parking_lot = "0.12" sqlx = { version = "0.8.6", features = ["runtime-tokio", "sqlite", "chrono", "migrate"] } futures = "0.3.31" +jsonwebtoken = "9.3" +reqwest = { version = "0.12", features = ["json"] } +base64 = "0.22" [dev-dependencies] mockall = "0.14.0" 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..ca2ae4b --- /dev/null +++ b/lib/src/auth/infrastructure/jwt.rs @@ -0,0 +1,288 @@ +use crate::auth::domain::AuthClaims; +use crate::auth::traits::Authenticator; +use crate::common::errors::AuthError; +use base64::Engine; +use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode, decode_header}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::RwLock; + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct FirebaseClaims { + iss: String, + aud: String, + auth_time: i64, + user_id: String, + sub: String, + iat: i64, + exp: i64, + email: Option, + email_verified: Option, + firebase: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct FirebaseData { + identities: Option, + sign_in_provider: Option, + tenant: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct PublicKey { + n: String, + e: String, + kid: String, +} + +#[derive(Debug, Clone, Deserialize)] +struct JwksResponse { + keys: Vec, +} + +#[derive(Clone)] +struct KeyCache { + keys: HashMap, + expires_at: i64, +} + +pub struct JwtAuthenticator { + firebase_project_id: String, + issuer: String, + audience: String, + emulator_url: Option, + key_cache: Arc>>, +} + +impl JwtAuthenticator { + pub fn new(firebase_project_id: impl Into) -> Self { + let project_id = firebase_project_id.into(); + let issuer = format!("https://securetoken.google.com/{}", project_id); + Self { + firebase_project_id: project_id.clone(), + issuer, + audience: project_id, + emulator_url: None, + key_cache: Arc::new(RwLock::new(None)), + } + } + + pub fn with_emulator(emulator_url: impl Into) -> Self { + Self { + firebase_project_id: "emulator".to_string(), + issuer: "https://securetoken.google.com/emulator".to_string(), + audience: "emulator".to_string(), + emulator_url: Some(emulator_url.into()), + key_cache: Arc::new(RwLock::new(None)), + } + } + + pub fn with_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(), + emulator_url: None, + key_cache: Arc::new(RwLock::new(None)), + } + } + + fn decode_emulator_token(&self, token: &str) -> Result { + let parts: Vec<&str> = token.split('.').collect(); + if parts.len() != 3 { + return Err(AuthError::InvalidToken); + } + + let header_json = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(parts[0]) + .map_err(|e| { + eprintln!("Failed to decode emulated header: {}", e); + AuthError::InvalidToken + })?; + let header: serde_json::Value = + serde_json::from_slice(&header_json).map_err(|_| AuthError::InvalidToken)?; + + if header.get("alg").and_then(|v| v.as_str()) != Some("none") { + return Err(AuthError::InvalidToken); + } + + let payload_json = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(parts[1]) + .map_err(|e| { + eprintln!("Failed to decode emulated payload: {}", e); + AuthError::InvalidToken + })?; + let claims: FirebaseClaims = + serde_json::from_slice(&payload_json).map_err(|_| AuthError::InvalidToken)?; + + let now = chrono::Utc::now().timestamp(); + + if claims.exp < now { + return Err(AuthError::AuthenticationFailed("Token expired".to_string())); + } + + if claims.aud != self.audience { + return Err(AuthError::AuthenticationFailed( + "Invalid audience".to_string(), + )); + } + + if claims.iss != self.issuer { + return Err(AuthError::AuthenticationFailed( + "Invalid issuer".to_string(), + )); + } + + let mut auth_claims = AuthClaims::new(claims.user_id) + .with_expiration(claims.exp) + .with_iat(claims.iat); + + if let Some(email) = claims.email { + auth_claims = auth_claims.with_email(email); + } + + if let Some(ref firebase) = claims.firebase { + if let Some(ref provider) = firebase.sign_in_provider { + auth_claims = auth_claims.with_role(format!("auth:{}", provider)); + } + } + + auth_claims = auth_claims.with_role("authenticated"); + + Ok(auth_claims) + } + + async fn get_public_keys(&self) -> Result, AuthError> { + let cache = self.key_cache.read().await; + if let Some(ref cached) = *cache { + let now = chrono::Utc::now().timestamp(); + if cached.expires_at > now { + return Ok(cached.keys.clone()); + } + } + drop(cache); + + let url = if let Some(ref emulator) = self.emulator_url { + format!("{}/.well-known/jwks.json", emulator.trim_end_matches('/')) + } else { + format!( + "https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com" + ) + }; + + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .build() + .map_err(|e| { + AuthError::AuthenticationFailed(format!("Failed to build HTTP client: {}", e)) + })?; + + let response = client.get(&url).send().await.map_err(|e| { + AuthError::AuthenticationFailed(format!("Failed to fetch public keys: {}", e)) + })?; + + if !response.status().is_success() { + return Err(AuthError::AuthenticationFailed(format!( + "Failed to fetch public keys: HTTP {}", + response.status() + ))); + } + + let body = response.text().await.map_err(|e| { + AuthError::AuthenticationFailed(format!("Failed to read response: {}", e)) + })?; + + let jwks: JwksResponse = serde_json::from_str(&body) + .map_err(|e| AuthError::AuthenticationFailed(format!("Failed to parse JWKS: {}", e)))?; + + let mut keys = HashMap::new(); + for key in jwks.keys { + let decoding_key = DecodingKey::from_rsa_components(&key.n, &key.e).map_err(|e| { + AuthError::AuthenticationFailed(format!("Failed to create decoding key: {}", e)) + })?; + keys.insert(key.kid, decoding_key); + } + + let expires_at = chrono::Utc::now().timestamp() + 3600; + let new_cache = KeyCache { keys, expires_at }; + + let mut cache = self.key_cache.write().await; + *cache = Some(new_cache); + + Ok(cache.as_ref().unwrap().keys.clone()) + } +} + +#[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 header_result = decode_header(token); + + let header = match header_result { + Ok(h) => h, + Err(e) => { + if self.emulator_url.is_some() { + if let Ok(claims) = self.decode_emulator_token(token) { + return Ok(claims); + } + } + tracing::debug!("Failed to decode header: {}", e.to_string()); + return Err(AuthError::InvalidToken); + } + }; + + let kid = header + .kid + .ok_or_else(|| AuthError::AuthenticationFailed("Token missing key ID".to_string()))?; + + let public_keys = self.get_public_keys().await.map_err(|e| { + AuthError::AuthenticationFailed(format!("Failed to fetch public keys: {}", e)) + })?; + let decoding_key = public_keys + .get(&kid) + .ok_or_else(|| AuthError::AuthenticationFailed("Unknown key ID".to_string()))?; + + let mut validation = Validation::new(Algorithm::RS256); + validation.set_audience(&[&self.audience]); + validation.set_issuer(&[&self.issuer]); + validation.validate_exp = true; + + let token_data = + decode::(token, decoding_key, &validation).map_err(|e| { + AuthError::AuthenticationFailed(format!("Token validation failed: {}", e)) + })?; + + let mut claims = AuthClaims::new(token_data.claims.user_id) + .with_expiration(token_data.claims.exp) + .with_iat(token_data.claims.iat); + + if let Some(email) = token_data.claims.email { + claims = claims.with_email(email); + } + + if let Some(ref firebase) = token_data.claims.firebase { + if let Some(ref provider) = firebase.sign_in_provider { + claims = claims.with_role(format!("auth:{}", provider)); + } + } + + claims = claims.with_role("authenticated"); + + Ok(claims) + } +} + +impl AuthClaims { + pub fn with_iat(mut self, iat: i64) -> Self { + self.iat = iat; + self + } +} 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 b7854fc..52acd32 100644 --- a/lib/src/common.rs +++ b/lib/src/common.rs @@ -2,6 +2,5 @@ pub mod entities; pub mod errors; pub mod traits; -pub use self::traits::DictRepository; pub use self::traits::SystemDecoder; pub use self::traits::SystemEncoder; diff --git a/lib/src/common/entities.rs b/lib/src/common/entities.rs index ffb209b..5790cf0 100644 --- a/lib/src/common/entities.rs +++ b/lib/src/common/entities.rs @@ -2,7 +2,7 @@ use super::errors::CodecError; use serde::Serialize; use std::num::ParseIntError; use std::ops::Deref; -use std::{collections::HashMap, u64}; +use std::u64; /// A number encoded as a sequence of words #[derive(Debug, Clone, Serialize)] @@ -104,43 +104,3 @@ impl TryFrom for DecodedLength { } } } - -// --- Dictionary --- - -pub type DictEntryId = u64; - -#[derive(Debug, Clone, PartialEq)] -pub struct DictEntry { - pub id: Option, - pub text: String, - pub metadata: HashMap, -} - -impl DictEntry { - pub fn new(id: Option, text: String) -> Self { - DictEntry { - id, - text, - metadata: HashMap::new(), - } - } -} - -#[derive(Debug, Clone)] -pub struct Dict { - pub name: String, - pub entries: HashMap, -} - -impl Dict { - pub fn new(name: String) -> Self { - Dict { - name, - entries: HashMap::new(), - } - } - - pub fn add_entry(&mut self, entry: DictEntry) { - self.entries.insert(entry.id.unwrap(), entry); - } -} 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/common/traits.rs b/lib/src/common/traits.rs index 5322dee..85a68f6 100644 --- a/lib/src/common/traits.rs +++ b/lib/src/common/traits.rs @@ -1,8 +1,6 @@ -use futures::stream::BoxStream; - use crate::common::{ - entities::{DecodedValue, Dict, DictEntry, EncodedValue}, - errors::{CodecError, RepositoryError}, + entities::{DecodedValue, EncodedValue}, + errors::CodecError, }; pub trait SystemDecoder: Send + Sync { @@ -13,29 +11,3 @@ pub trait SystemEncoder: Send + Sync { fn initialize(&self) -> Result<(), CodecError>; fn encode(&self, word: &str) -> Result; } - -#[async_trait::async_trait] -pub trait DictRepository: Send + Sync { - fn use_dict(&mut self, name: &str); - async fn create_dict(&self) -> Result<(), RepositoryError>; - - /// "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>; -} - -pub trait DictSource { - fn next_entry(&mut self) -> Option>; -} diff --git a/lib/src/dictionary.rs b/lib/src/dictionary.rs index fedb896..3427bbb 100644 --- a/lib/src/dictionary.rs +++ b/lib/src/dictionary.rs @@ -1,6 +1,80 @@ mod dict_importer; mod infrastructure; +pub mod service; + +use futures::stream::BoxStream; + +use crate::common::errors::RepositoryError; pub use self::dict_importer::DictImporter; pub use self::infrastructure::json_file_dict_source::JsonFileDictSource; pub use self::infrastructure::sqlite_dict_repository::SqliteDictRepository; + +use std::collections::HashMap; + +pub type DictEntryId = u64; + +#[derive(Debug, Clone, PartialEq)] +pub struct DictEntry { + pub id: Option, + pub text: String, + pub metadata: HashMap, +} + +impl DictEntry { + pub fn new(id: Option, text: String) -> Self { + DictEntry { + id, + text, + metadata: HashMap::new(), + } + } +} + +#[derive(Debug, Clone)] +pub struct Dict { + pub name: String, + pub entries: HashMap, +} + +impl Dict { + pub fn new(name: String) -> Self { + Dict { + name, + entries: HashMap::new(), + } + } + + pub fn add_entry(&mut self, entry: DictEntry) { + self.entries.insert(entry.id.unwrap(), entry); + } +} + +#[async_trait::async_trait] +pub trait DictRepository: Send + Sync { + fn use_dict(&mut self, name: &str); + async fn create_dict(&self) -> Result<(), RepositoryError>; + + async fn fetch_dicts(&self) -> Result, RepositoryError>; + + async fn count_entries(&self) -> Result; + + async fn save_entries(&self, entries: &[DictEntry]) -> Result<(), RepositoryError>; + + async fn fetch_many(&self, limit: usize, offset: usize) -> Result; + + 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/dict_importer.rs b/lib/src/dictionary/dict_importer.rs index 5bec64b..db20b7d 100644 --- a/lib/src/dictionary/dict_importer.rs +++ b/lib/src/dictionary/dict_importer.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use crate::common::traits::{DictRepository, DictSource}; +use super::{DictRepository, DictSource}; pub struct DictImporter { repo: Arc, diff --git a/lib/src/dictionary/infrastructure/json_file_dict_source.rs b/lib/src/dictionary/infrastructure/json_file_dict_source.rs index b7b3dcd..356c14b 100644 --- a/lib/src/dictionary/infrastructure/json_file_dict_source.rs +++ b/lib/src/dictionary/infrastructure/json_file_dict_source.rs @@ -1,5 +1,5 @@ -use crate::common::entities::DictEntry; -use crate::common::traits::DictSource; +use crate::dictionary::DictEntry; +use crate::dictionary::DictSource; use serde::Deserialize; use std::collections::HashMap; use std::fs::File; diff --git a/lib/src/dictionary/infrastructure/sqlite_dict_repository.rs b/lib/src/dictionary/infrastructure/sqlite_dict_repository.rs index 48f0e93..c75bc0c 100644 --- a/lib/src/dictionary/infrastructure/sqlite_dict_repository.rs +++ b/lib/src/dictionary/infrastructure/sqlite_dict_repository.rs @@ -1,6 +1,5 @@ -use crate::common::entities::{Dict, DictEntry}; use crate::common::errors::RepositoryError; -use crate::common::traits::DictRepository; +use crate::dictionary::{Dict, DictEntry, DictRepository, DictRepositoryFactory}; use futures::TryStreamExt; use futures::stream::BoxStream; @@ -12,24 +11,19 @@ use std::str::FromStr; 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, @@ -46,7 +40,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 ( @@ -62,7 +55,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) ); "#, @@ -72,12 +64,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) @@ -94,6 +85,10 @@ impl SqliteDictRepository { #[async_trait::async_trait] impl DictRepository for SqliteDictRepository { + fn use_dict(&mut self, name: &str) { + self.dict_name = name.to_string(); + } + async fn create_dict(&self) -> Result<(), RepositoryError> { sqlx::query("INSERT OR IGNORE INTO dictionaries (name) VALUES (?)") .bind(&self.dict_name) @@ -103,8 +98,30 @@ impl DictRepository for SqliteDictRepository { Ok(()) } - fn use_dict(&mut self, name: &str) { - self.dict_name = name.to_string(); + async fn fetch_dicts(&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()))?; + + let dicts = rows.iter().map(|row| row.get("name")).collect(); + + Ok(dicts) + } + + async fn count_entries(&self) -> Result { + let dict_id = self.get_dict_id().await?; + + let row = sqlx::query("SELECT COUNT(*) as count FROM entries WHERE dictionary_id = ?") + .bind(dict_id) + .fetch_optional(&self.pool) + .await + .map_err(|e| RepositoryError::StorageError(e.to_string()))?; + + match row { + Some(r) => Ok(r.get::("count") as u64), + None => Ok(0), + } } async fn save_entries(&self, entries: &[DictEntry]) -> Result<(), RepositoryError> { @@ -225,7 +242,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 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 new file mode 100644 index 0000000..377e1af --- /dev/null +++ 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 33fc17d..63e25e3 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -1,20 +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::DictRepository; pub use self::common::SystemDecoder; pub use self::common::SystemEncoder; +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) + } +} diff --git a/tavern-tests/export.sh b/tavern-tests/export.sh new file mode 100644 index 0000000..ebb1ce2 --- /dev/null +++ b/tavern-tests/export.sh @@ -0,0 +1,15 @@ + +if [ -d .venv ]; then + source .venv/bin/activate +else + python3 -m venv .venv + source .venv/bin/activate + pip install -r requirements.txt +fi + +export TEST_SERVER_ADDRESS="127.0.0.1:3000" +export TEST_API_BASE="api/v1" + +export TEST_API_KEY="test-api-key" +export TEST_USER_ID="test-user-id" +export TEST_VALID_TOKEN="test-api-key" \ No newline at end of file diff --git a/tavern-tests/requirements.txt b/tavern-tests/requirements.txt new file mode 100644 index 0000000..166ce3f --- /dev/null +++ b/tavern-tests/requirements.txt @@ -0,0 +1,3 @@ +pyyaml +tavern +allure-pytest diff --git a/tavern-tests/tavern-run-all.sh b/tavern-tests/tavern-run-all.sh new file mode 100755 index 0000000..ab0ae63 --- /dev/null +++ b/tavern-tests/tavern-run-all.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash + +SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) +cd "$SCRIPT_DIR" + +if [ -z "$TEST_SERVER_ADDRESS" ]; then + source export.sh +fi + +tavern-ci --alluredir=reports test_plans/version_test.tavern.yaml +# tavern-ci --alluredir=reports test_plans/auth_test.tavern.yaml +tavern-ci --alluredir=reports test_plans/decode_test.tavern.yaml +tavern-ci --alluredir=reports test_plans/dictionary_test.tavern.yaml +tavern-ci --alluredir=reports test_plans/encode_test.tavern.yaml + +# if command -v allure > /dev/null; then +# allure generate --clean --single-file --output /tmp/vm-allure-report --name index.html reports +# fi + +# allure package: https://github.com/allure-framework/allure2/releases/download/2.34.0/allure_2.34.0-1_all.deb diff --git a/tavern-tests/tavern-run-single.sh b/tavern-tests/tavern-run-single.sh new file mode 100755 index 0000000..1d3cd13 --- /dev/null +++ b/tavern-tests/tavern-run-single.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash + +SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) +cd "$SCRIPT_DIR" + +if [ -z "$1" ]; then + echo "Usage: $0 " + exit 1 +fi + +if [ -z "$TEST_SERVER_ADDRESS" ]; then + source export.sh +fi + +tavern-ci --log-cli-level=DEBUG --alluredir=reports $1 + +# allure generate --clean --single-file --output /tmp/vm-allure-report --name index.html reports + +# allure package: https://github.com/allure-framework/allure2/releases/download/2.34.0/allure_2.34.0-1_all.deb diff --git a/tavern-tests/test_plans/common_stages.yaml b/tavern-tests/test_plans/common_stages.yaml new file mode 100644 index 0000000..0e4d102 --- /dev/null +++ b/tavern-tests/test_plans/common_stages.yaml @@ -0,0 +1,29 @@ +--- + +stages: + - id: clear_test_user + name: "Purge test user data" + request: + url: "http://{server_address}/{api_base}/user/{test_user_id}/clear" + method: POST + headers: + Content-Type: application/json + Authorization: Bearer {api_key} + response: + strict: False + status_code: 200 + + - id: register_test_user + name: "Register test user" + request: + url: "http://{server_address}/{api_base}/user" + method: POST + headers: + Content-Type: application/json + Authorization: Bearer {api_key} + body: + username: {test_user_id} + password: password + response: + strict: False + status_code: 201 diff --git a/tavern-tests/test_plans/decode_test.tavern.yaml b/tavern-tests/test_plans/decode_test.tavern.yaml new file mode 100644 index 0000000..13ebdf3 --- /dev/null +++ b/tavern-tests/test_plans/decode_test.tavern.yaml @@ -0,0 +1,29 @@ +test_name: "Test major decode endpoint" + +includes: + - !include includes.yaml + +stages: + + - name: "Successful decode with valid encoded input" + request: + url: "http://{server_address}/{api_base}/major/decode/pl/test" + method: GET + headers: + X-API-Key: "{api_key}" + response: + strict: True + status_code: 200 + json: + input: "test" + result: "101" + + - name: "Missing authentication returns 401 error" + request: + url: "http://{server_address}/{api_base}/major/decode/pl/hello" + method: GET + response: + strict: False + status_code: 401 + json: + error: !anystr diff --git a/tavern-tests/test_plans/dictionary_test.tavern.yaml b/tavern-tests/test_plans/dictionary_test.tavern.yaml new file mode 100644 index 0000000..7669179 --- /dev/null +++ b/tavern-tests/test_plans/dictionary_test.tavern.yaml @@ -0,0 +1,30 @@ +test_name: "Test dictionary API endpoint" + +includes: + - !include includes.yaml + +stages: + + - name: "Successful list dictionaries with valid authentication" + request: + url: "http://{server_address}/{api_base}/dicts" + method: GET + headers: + X-API-Key: "{api_key}" + response: + strict: False + status_code: 200 + json: + dictionaries: + - name: !anystr + entry_count: !anyint + + - name: "Missing authentication returns 401 error" + request: + url: "http://{server_address}/{api_base}/dicts" + method: GET + response: + strict: True + status_code: 401 + json: + error: !anystr diff --git a/tavern-tests/test_plans/encode_test.tavern.yaml b/tavern-tests/test_plans/encode_test.tavern.yaml new file mode 100644 index 0000000..14aec64 --- /dev/null +++ b/tavern-tests/test_plans/encode_test.tavern.yaml @@ -0,0 +1,44 @@ +test_name: "Test major encode endpoint" + +includes: + - !include includes.yaml + +stages: + + - name: "Successful encode with default dictionary" + request: + url: "http://{server_address}/{api_base}/major/encode/pl/hello" + method: GET + headers: + X-API-Key: "{api_key}" + response: + strict: False + status_code: 200 + json: + input: "hello" + dict: "demo_pl" + result: !anylist + + - name: "Successful encode with custom dictionary" + request: + url: "http://{server_address}/{api_base}/major/encode/pl/test?dict=demo_pl" + method: GET + headers: + X-API-Key: "{api_key}" + response: + strict: False + status_code: 200 + json: + input: "test" + dict: "demo_pl" + result: !anylist + + - name: "Missing authentication returns 401 error" + request: + url: "http://{server_address}/{api_base}/major/encode/pl/hello" + method: GET + response: + strict: False + status_code: 401 + json: + error: !anystr diff --git a/tavern-tests/test_plans/includes.yaml b/tavern-tests/test_plans/includes.yaml new file mode 100644 index 0000000..7ea61a6 --- /dev/null +++ b/tavern-tests/test_plans/includes.yaml @@ -0,0 +1,6 @@ +variables: + server_address: "{tavern.env_vars.TEST_SERVER_ADDRESS}" + api_base: "{tavern.env_vars.TEST_API_BASE}" + api_key: "{tavern.env_vars.TEST_API_KEY}" + user_id: "{tavern.env_vars.TEST_USER_ID}" + diff --git a/tavern-tests/test_plans/version_test.tavern.yaml b/tavern-tests/test_plans/version_test.tavern.yaml new file mode 100644 index 0000000..ef3408f --- /dev/null +++ b/tavern-tests/test_plans/version_test.tavern.yaml @@ -0,0 +1,20 @@ +test_name: "Test version endpoint" + +includes: + - !include includes.yaml + +stages: + - name: "Successful version test - valid authentication returns version info" + request: + url: "http://{server_address}/api/v1/info/version" + method: GET + headers: + X-API-Key: "{api_key}" + response: + strict: True + status_code: 200 + json: + name: "phomnemic-server" + version: !anystr + +