Compare commits

...

12 Commits

  1. 152
      README.md
  2. 8
      apps/app_api/config.toml.example
  3. 9
      apps/app_api/src/api.rs
  4. 63
      apps/app_api/src/api/v1.rs
  5. 39
      apps/app_api/src/api/v1/auth.rs
  6. 47
      apps/app_api/src/api/v1/dictionary.rs
  7. 46
      apps/app_api/src/api/v1/info.rs
  8. 84
      apps/app_api/src/api/v1/major.rs
  9. 7
      apps/app_api/src/app.rs
  10. 7
      apps/app_api/src/commands.rs
  11. 8
      apps/app_api/src/commands/listen.rs
  12. 15
      apps/app_api/src/config.rs
  13. 18
      apps/app_api/src/config/auth.rs
  14. 14
      apps/app_api/src/config/database.rs
  15. 31
      apps/app_api/src/container.rs
  16. 52
      apps/app_api/src/dependencies.rs
  17. 100
      apps/app_api/src/error.rs
  18. 5
      apps/app_api/src/main.rs
  19. 31
      apps/app_api/src/router.rs
  20. 25
      apps/app_api/src/router/handlers.rs
  21. 34
      apps/app_api/src/router/responses.rs
  22. 12
      apps/app_api/src/state.rs
  23. 5
      apps/app_cli/src/commands.rs
  24. 77
      apps/app_cli/src/commands/list_dicts.rs
  25. 3
      apps/app_cli/src/config.rs
  26. 15
      config.toml
  27. 3
      lib/Cargo.toml
  28. 13
      lib/src/auth.rs
  29. 72
      lib/src/auth/domain.rs
  30. 47
      lib/src/auth/infrastructure/api_token.rs
  31. 288
      lib/src/auth/infrastructure/jwt.rs
  32. 58
      lib/src/auth/infrastructure/store.rs
  33. 59
      lib/src/auth/service.rs
  34. 15
      lib/src/auth/traits.rs
  35. 1
      lib/src/common.rs
  36. 42
      lib/src/common/entities.rs
  37. 54
      lib/src/common/errors.rs
  38. 32
      lib/src/common/traits.rs
  39. 74
      lib/src/dictionary.rs
  40. 2
      lib/src/dictionary/dict_importer.rs
  41. 4
      lib/src/dictionary/infrastructure/json_file_dict_source.rs
  42. 65
      lib/src/dictionary/infrastructure/sqlite_dict_repository.rs
  43. 66
      lib/src/dictionary/service.rs
  44. 23
      lib/src/lib.rs
  45. 8
      lib/src/sys_major.rs
  46. 36
      lib/src/sys_major/service.rs
  47. 14
      tavern-tests/export.sh
  48. 3
      tavern-tests/requirements.txt
  49. 13
      tavern-tests/tavern-run-all.sh
  50. 16
      tavern-tests/tavern-run-single.sh
  51. 29
      tavern-tests/test_plans/common_stages.yaml
  52. 5
      tavern-tests/test_plans/includes.yaml
  53. 19
      tavern-tests/test_plans/info_test.tavern.yaml

152
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 <your-firebase-token>"
```
### 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

8
apps/app_api/config.toml.example

@ -3,3 +3,11 @@ host = "0.0.0.0"
port = 3000 port = 3000
log_level = "info" 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"

9
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<AppState>) -> Router<Arc<AppState>> {
Router::new().nest("/api/v1", v1::routes(state))
}

63
apps/app_api/src/api/v1.rs

@ -0,0 +1,63 @@
pub mod auth;
pub mod dictionary;
pub mod info;
pub mod major;
use crate::state::AppState;
use axum::{
Router, extract::Request, extract::State, http::StatusCode, middleware::Next,
response::Response,
};
use std::sync::Arc;
pub fn routes(state: Arc<AppState>) -> Router<Arc<AppState>> {
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<Arc<AppState>>, request: Request, next: Next| async move {
auth_middleware_inner(state, request, next).await
},
))
}
async fn auth_middleware_inner(
state: State<Arc<AppState>>,
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 {
return Response::builder()
.status(StatusCode::UNAUTHORIZED)
.body("Missing authorization header or API key".into())
.unwrap();
};
match state.0.dependencies.auth_service.authenticate(&token).await {
Ok(claims) => {
request.extensions_mut().insert(claims);
next.run(request).await
}
Err(_) => Response::builder()
.status(StatusCode::UNAUTHORIZED)
.body("Unauthorized".into())
.unwrap(),
}
}

39
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<String>,
pub roles: Vec<String>,
}
pub async fn login_handler(
State(state): State<Arc<AppState>>,
Json(req): Json<LoginRequest>,
) -> Result<Json<LoginResponse>, 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<Arc<AppState>> {
Router::new().route("/login", post(login_handler))
}

47
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<DictListEntryResponse>,
}
#[derive(Debug, Serialize)]
pub struct DictListEntryResponse {
pub name: String,
pub entry_count: u64,
}
impl From<DictionarySummary> 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<Arc<AppState>>,
) -> Result<Json<DictListResponse>, ErrorResponse> {
let dictionaries = state
.dependencies
.dictionary_service
.list_dictionaries()
.await?;
let entries: Vec<DictListEntryResponse> = dictionaries.into_iter().map(Into::into).collect();
Ok(Json(DictListResponse {
dictionaries: entries,
}))
}
pub fn routes() -> Router<Arc<AppState>> {
Router::new().route("/", get(list_dicts_handler))
}

46
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<Arc<AppState>>,
Json(payload): Json<Value>,
) -> Json<EchoResponse> {
Json(EchoResponse {
data: payload,
timestamp: Utc::now().to_rfc3339(),
})
}
pub async fn version_handler(State(state): State<Arc<AppState>>) -> Json<VersionResponse> {
Json(VersionResponse {
name: state.name.clone(),
version: state.version.clone(),
})
}
pub fn routes() -> Router<Arc<AppState>> {
Router::new()
.route("/echo", post(echo_handler))
.route("/version", get(version_handler))
}

84
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<String>,
}
#[derive(Debug, Deserialize)]
pub struct DecodeQuery {}
#[derive(Debug, Serialize)]
pub struct EncodeResponse {
pub input: String,
pub dict: String,
pub result: Vec<Vec<EncodePart>>,
}
#[derive(Debug, Serialize)]
pub struct EncodePart {
pub value: u64,
pub words: Vec<String>,
}
#[derive(Debug, Serialize)]
pub struct DecodeResponse {
pub input: String,
pub result: String,
}
pub async fn encode_handler(
State(state): State<Arc<AppState>>,
Path(input): Path<String>,
Query(params): Query<EncodeQuery>,
) -> Result<Json<EncodeResponse>, 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<Vec<EncodePart>> = 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<Arc<AppState>>,
Path(input): Path<String>,
Query(_params): Query<DecodeQuery>,
) -> Result<Json<DecodeResponse>, ErrorResponse> {
let result = state.dependencies.major_system_service.decode(&input)?;
Ok(Json(DecodeResponse {
input,
result: result.as_str().to_string(),
}))
}
pub fn routes() -> Router<Arc<AppState>> {
Router::new()
.route("/encode/pl/{input}", get(encode_handler))
.route("/decode/pl/{input}", get(decode_handler))
}

7
apps/app_api/src/app.rs

@ -1,13 +1,11 @@
use crate::commands::{AppCommand, CliArgs}; use crate::commands::{AppCommand, CliArgs};
use crate::config::AppConfig; use crate::config::AppConfig;
use crate::container::Container;
use anyhow::Result; use anyhow::Result;
use clap::Parser; use clap::Parser;
use tracing::debug; use tracing::debug;
pub struct Application { pub struct Application {
config: AppConfig, config: AppConfig,
container: Container,
command: Box<dyn AppCommand>, command: Box<dyn AppCommand>,
} }
@ -27,16 +25,13 @@ impl Application {
debug!("Bootstrapping application..."); debug!("Bootstrapping application...");
let container = Container::new().await?;
Ok(Self { Ok(Self {
config, config,
container,
command: app_cmd, command: app_cmd,
}) })
} }
pub async fn run(self) -> Result<()> { pub async fn run(self) -> Result<()> {
self.command.execute(&self.config, &self.container).await self.command.execute(&self.config).await
} }
} }

7
apps/app_api/src/commands.rs

@ -1,7 +1,6 @@
pub mod listen; pub mod listen;
use crate::config::AppConfig; use crate::config::AppConfig;
use crate::container::Container;
use anyhow::Result; use anyhow::Result;
use async_trait::async_trait; use async_trait::async_trait;
use clap::Subcommand; use clap::Subcommand;
@ -12,7 +11,6 @@ use std::path::PathBuf;
#[derive(Subcommand, Debug, Clone)] #[derive(Subcommand, Debug, Clone)]
pub enum Command { pub enum Command {
/// Decode a word using given system
Listen(listen::ListenCmd), Listen(listen::ListenCmd),
} }
@ -36,7 +34,6 @@ pub struct CliArgs {
#[derive(ClapArgs, Debug)] #[derive(ClapArgs, Debug)]
pub struct GlobalArgs { pub struct GlobalArgs {
/// Path to config file
#[arg(short, long, default_value = "config.toml")] #[arg(short, long, default_value = "config.toml")]
pub config: PathBuf, pub config: PathBuf,
@ -57,11 +54,9 @@ pub trait Configurable {
#[async_trait] #[async_trait]
pub trait Executable { 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 {} pub trait AppCommand: Configurable + Executable {}
impl<T: Configurable + Executable> AppCommand for T {} impl<T: Configurable + Executable> AppCommand for T {}

8
apps/app_api/src/commands/listen.rs

@ -1,6 +1,5 @@
use crate::commands::{ClapArgs, Configurable, Executable}; use crate::commands::{ClapArgs, Configurable, Executable};
use crate::config::AppConfig; use crate::config::AppConfig;
use crate::container::Container;
use crate::router; use crate::router;
use anyhow::Result; use anyhow::Result;
@ -32,6 +31,7 @@ impl Configurable for ListenCmd {
builder: ConfigBuilder<DefaultState>, builder: ConfigBuilder<DefaultState>,
) -> Result<ConfigBuilder<DefaultState>> { ) -> Result<ConfigBuilder<DefaultState>> {
builder builder
.set_default("log_level", defaults::LOG_LEVEL)?
.set_default("listen.host", defaults::LISTEN_HOST)? .set_default("listen.host", defaults::LISTEN_HOST)?
.set_default("listen.port", defaults::LISTEN_PORT) .set_default("listen.port", defaults::LISTEN_PORT)
.map_err(Into::into) .map_err(Into::into)
@ -55,18 +55,19 @@ impl Configurable for ListenCmd {
#[async_trait] #[async_trait]
impl Executable for ListenCmd { impl Executable for ListenCmd {
async fn execute(&self, config: &AppConfig, _container: &Container) -> Result<()> { async fn execute(&self, config: &AppConfig) -> Result<()> {
let listen_config = config let listen_config = config
.listen .listen
.as_ref() .as_ref()
.ok_or_else(|| anyhow::anyhow!("Listen config missing"))?; .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 addr = format!("{}:{}", listen_config.host, listen_config.port);
let listener = TcpListener::bind(&addr).await?; let listener = TcpListener::bind(&addr).await?;
info!("Starting server on {}", addr); info!("Starting server on {}", addr);
// axum::serve(listener, app.into_make_service()).await?;
axum::serve(listener, app) axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal()) .with_graceful_shutdown(shutdown_signal())
.await?; .await?;
@ -104,6 +105,7 @@ async fn shutdown_signal() {
mod defaults { mod defaults {
use const_format::formatcp; use const_format::formatcp;
pub const LOG_LEVEL: &str = "info";
pub const LISTEN_HOST: &str = "0.0.0.0"; pub const LISTEN_HOST: &str = "0.0.0.0";
pub const LISTEN_PORT: u16 = 3000; pub const LISTEN_PORT: u16 = 3000;
pub const HELP_LISTEN_HOST: &str = formatcp!("Host address [default: {}]", LISTEN_HOST); pub const HELP_LISTEN_HOST: &str = formatcp!("Host address [default: {}]", LISTEN_HOST);

15
apps/app_api/src/config.rs

@ -1,37 +1,42 @@
pub mod auth;
pub mod database;
use crate::commands::*; use crate::commands::*;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use config::{Config, Environment, File}; use config::{Config, Environment, File};
use serde::Deserialize; use serde::Deserialize;
pub use self::auth::AuthConfig;
pub use self::database::DatabaseConfig;
#[derive(Debug, Deserialize, Clone)] #[derive(Debug, Deserialize, Clone)]
pub struct AppConfig { pub struct AppConfig {
#[serde(default)] #[serde(default)]
pub listen: Option<listen::Config>, pub listen: Option<listen::Config>,
pub log_level: String, pub log_level: String,
#[serde(default)]
pub auth: AuthConfig,
#[serde(default)]
pub database: DatabaseConfig,
} }
impl AppConfig { impl AppConfig {
pub fn build(args: &GlobalArgs, handler: &dyn Configurable) -> Result<Self> { pub fn build(args: &GlobalArgs, handler: &dyn Configurable) -> Result<Self> {
let mut builder = Config::builder(); let mut builder = Config::builder();
// Command-specific defaults via Trait
builder = handler.apply_defaults(builder)?; builder = handler.apply_defaults(builder)?;
// File Layer
let config_path = &args.config; let config_path = &args.config;
let is_default_path = config_path.to_str() == Some("config.toml"); 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)); 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("_")); builder = builder.add_source(Environment::with_prefix("APP").separator("_"));
// Global log level override
if let Some(ref level) = args.log_level { if let Some(ref level) = args.log_level {
builder = builder.set_override("log_level", level.clone())?; builder = builder.set_override("log_level", level.clone())?;
} }
// Command-specific overrides via Trait
builder = handler.apply_overrides(builder)?; builder = handler.apply_overrides(builder)?;
builder builder

18
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<String>,
pub firebase_emulator_url: Option<String>,
pub api_tokens: Vec<String>,
}
impl Default for AuthConfig {
fn default() -> Self {
Self {
firebase_project_id: None,
firebase_emulator_url: None,
api_tokens: Vec::new(),
}
}
}

14
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(),
}
}
}

31
apps/app_api/src/container.rs

@ -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<Self> {
Ok(Self)
}
// pub async fn create_dict_importer(&self, dict_name: &str) -> anyhow::Result<DictImporter> {
// 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<Arc<dyn DictRepository>> {
// let mut dict_repo = SqliteDictRepository::new("sqlite:app.db").await?;
// dict_repo.use_dict(dict_name);
// Ok(Arc::new(dict_repo))
// }
}

52
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<DictionaryService>,
pub auth_service: Arc<AuthService>,
pub major_system_service: Arc<MajorSystemService>,
}
impl AppDependencies {
pub async fn new(
database_url: &str,
firebase_project_id: Option<String>,
firebase_emulator_url: Option<String>,
api_tokens: Vec<String>,
) -> anyhow::Result<Self> {
let repo_factory = Arc::new(SqliteDictRepository::new(database_url).await?);
let dictionary_service = Arc::new(DictionaryService::new(repo_factory.clone()));
let jwt_auth: Arc<dyn Authenticator> = 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<dyn Authenticator> = Arc::new(api_token_authenticator);
let token_store: Arc<dyn TokenStore> = Arc::new(InMemoryTokenStore::new());
let auth_service = Arc::new(AuthService::new(jwt_auth, api_token_auth, token_store));
let decoder: Arc<dyn SystemDecoder> = Arc::new(Decoder::new(rules_pl::get_rules()));
let encoder: Arc<dyn SystemEncoder> = 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,
})
}
}

100
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<String>,
}
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<ServiceError> 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<AuthError> 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<anyhow::Error> for ErrorResponse {
fn from(err: anyhow::Error) -> Self {
ErrorResponse {
error: "Internal server error".to_string(),
message: Some(err.to_string()),
}
}
}

5
apps/app_api/src/main.rs

@ -1,8 +1,11 @@
mod api;
mod app; mod app;
mod commands; mod commands;
mod config; mod config;
mod container; mod dependencies;
mod error;
mod router; mod router;
mod state;
use anyhow::Result; use anyhow::Result;
use app::Application; use app::Application;

31
apps/app_api/src/router.rs

@ -1,23 +1,26 @@
use axum::{
Router,
routing::{get, post},
};
use std::sync::Arc; use std::sync::Arc;
use tower_http::{cors::CorsLayer, trace::TraceLayer}; use tower_http::{cors::CorsLayer, trace::TraceLayer};
mod handlers; use crate::api;
mod responses; use crate::config::AppConfig;
mod state; use crate::dependencies::AppDependencies;
use crate::state::AppState;
pub use state::AppState; pub async fn create_router(config: &AppConfig) -> anyhow::Result<axum::Router> {
let database_url = &config.database.url;
pub fn create_router() -> Router { let dependencies = AppDependencies::new(
let state = Arc::new(AppState::new()); database_url,
config.auth.firebase_project_id.clone(),
config.auth.firebase_emulator_url.clone(),
config.auth.api_tokens.clone(),
)
.await?;
Router::new() let state = Arc::new(AppState::new(dependencies).await);
.route("/api/echo", post(handlers::echo_handler))
.route("/api/version", get(handlers::version_handler)) Ok(api::routes(state.clone())
.with_state(state) .with_state(state)
.layer(TraceLayer::new_for_http()) .layer(TraceLayer::new_for_http())
.layer(CorsLayer::permissive()) .layer(CorsLayer::permissive()))
} }

25
apps/app_api/src/router/handlers.rs

@ -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<Arc<AppState>>,
Json(payload): Json<Value>,
) -> Result<Json<EchoResponse>, ErrorResponse> {
let response = EchoResponse {
data: payload,
timestamp: Utc::now().to_rfc3339(),
};
Ok(Json(response))
}
pub async fn version_handler(State(state): State<Arc<AppState>>) -> Json<VersionResponse> {
Json(VersionResponse {
name: state.name.clone(),
version: state.version.clone(),
})
}

34
apps/app_api/src/router/responses.rs

@ -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<E: std::error::Error> From<E> for ErrorResponse {
fn from(err: E) -> Self {
Self {
error: err.to_string(),
}
}
}

12
apps/app_api/src/router/state.rs → apps/app_api/src/state.rs

@ -1,23 +1,21 @@
pub const APP_NAME: &str = env!("CARGO_PKG_NAME"); pub const APP_NAME: &str = env!("CARGO_PKG_NAME");
pub const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); pub const APP_VERSION: &str = env!("CARGO_PKG_VERSION");
use crate::dependencies::AppDependencies;
#[derive(Clone)] #[derive(Clone)]
pub struct AppState { pub struct AppState {
pub name: String, pub name: String,
pub version: String, pub version: String,
pub dependencies: AppDependencies,
} }
impl AppState { impl AppState {
pub fn new() -> Self { pub async fn new(dependencies: AppDependencies) -> Self {
Self { Self {
name: APP_NAME.to_string(), name: APP_NAME.to_string(),
version: APP_VERSION.to_string(), version: APP_VERSION.to_string(),
dependencies,
} }
} }
} }
impl Default for AppState {
fn default() -> Self {
Self::new()
}
}

5
apps/app_cli/src/commands.rs

@ -1,6 +1,7 @@
pub mod decode; pub mod decode;
pub mod encode; pub mod encode;
pub mod import_dict; pub mod import_dict;
pub mod list_dicts;
use crate::config::AppConfig; use crate::config::AppConfig;
use crate::container::Container; use crate::container::Container;
@ -22,6 +23,9 @@ pub enum Command {
/// Import dictionary /// Import dictionary
ImportDict(import_dict::ImportDictCmd), ImportDict(import_dict::ImportDictCmd),
/// List all dictionaries
ListDicts(list_dicts::ListDictsCmd),
} }
impl Command { impl Command {
@ -30,6 +34,7 @@ impl Command {
Command::Decode(cmd) => Box::new(cmd), Command::Decode(cmd) => Box::new(cmd),
Command::Encode(cmd) => Box::new(cmd), Command::Encode(cmd) => Box::new(cmd),
Command::ImportDict(cmd) => Box::new(cmd), Command::ImportDict(cmd) => Box::new(cmd),
Command::ListDicts(cmd) => Box::new(cmd),
} }
} }
} }

77
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<DefaultState>,
) -> Result<ConfigBuilder<DefaultState>> {
builder
.set_default("list_dicts.show_counts", defaults::SHOW_COUNTS)
.map_err(Into::into)
}
fn apply_overrides(
&self,
builder: ConfigBuilder<DefaultState>,
) -> Result<ConfigBuilder<DefaultState>> {
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");
}

3
apps/app_cli/src/config.rs

@ -1,5 +1,4 @@
use crate::commands::*; use crate::commands::*;
// use crate::commands::{Configurable, GlobalArgs};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use config::{Config, Environment, File}; use config::{Config, Environment, File};
use serde::Deserialize; use serde::Deserialize;
@ -12,6 +11,8 @@ pub struct AppConfig {
pub encode: Option<encode::Config>, pub encode: Option<encode::Config>,
#[serde(default)] #[serde(default)]
pub import_dict: Option<import_dict::Config>, pub import_dict: Option<import_dict::Config>,
#[serde(default)]
pub list_dicts: Option<list_dicts::Config>,
pub log_level: String, pub log_level: String,
} }

15
config.toml

@ -1,4 +1,13 @@
log_level = "info" log_level = "trace"
[decode] [listen]
input = "CONFIGTEST" 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"

3
lib/Cargo.toml

@ -19,6 +19,9 @@ async-trait = "0.1"
parking_lot = "0.12" parking_lot = "0.12"
sqlx = { version = "0.8.6", features = ["runtime-tokio", "sqlite", "chrono", "migrate"] } sqlx = { version = "0.8.6", features = ["runtime-tokio", "sqlite", "chrono", "migrate"] }
futures = "0.3.31" futures = "0.3.31"
jsonwebtoken = "9.3"
reqwest = { version = "0.12", features = ["json"] }
base64 = "0.22"
[dev-dependencies] [dev-dependencies]
mockall = "0.14.0" mockall = "0.14.0"

13
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};

72
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<String>,
pub display_name: Option<String>,
pub roles: Vec<String>,
pub metadata: HashMap<String, String>,
}
impl User {
pub fn new(user_id: impl Into<String>) -> 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<String>,
pub exp: Option<i64>,
pub iat: i64,
pub roles: Vec<String>,
}
impl AuthClaims {
pub fn new(user_id: impl Into<String>) -> 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<String>) -> 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<String>) -> 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
}
}
}

47
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<parking_lot::RwLock<Vec<String>>>,
}
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<String>) {
let mut tokens = self.valid_tokens.write();
tokens.push(token.into());
}
pub fn with_tokens(tokens: Vec<String>) -> 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<AuthClaims, AuthError> {
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)
}
}
}

288
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<String>,
email_verified: Option<bool>,
firebase: Option<FirebaseData>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct FirebaseData {
identities: Option<serde_json::Value>,
sign_in_provider: Option<String>,
tenant: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
struct PublicKey {
n: String,
e: String,
kid: String,
}
#[derive(Debug, Clone, Deserialize)]
struct JwksResponse {
keys: Vec<PublicKey>,
}
#[derive(Clone)]
struct KeyCache {
keys: HashMap<String, DecodingKey>,
expires_at: i64,
}
pub struct JwtAuthenticator {
firebase_project_id: String,
issuer: String,
audience: String,
emulator_url: Option<String>,
key_cache: Arc<RwLock<Option<KeyCache>>>,
}
impl JwtAuthenticator {
pub fn new(firebase_project_id: impl Into<String>) -> 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<String>) -> 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<AuthClaims, AuthError> {
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<HashMap<String, DecodingKey>, 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<AuthClaims, AuthError> {
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::<FirebaseClaims>(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
}
}

58
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<parking_lot::RwLock<HashMap<String, AuthClaims>>>,
revoked: Arc<parking_lot::RwLock<HashMap<String, i64>>>,
}
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<Option<AuthClaims>, 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<bool, AuthError> {
self.cleanup_expired();
let revoked = self.revoked.read();
Ok(revoked.contains_key(token))
}
}

59
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<dyn Authenticator>,
api_token_authenticator: Arc<dyn Authenticator>,
token_store: Arc<dyn TokenStore>,
}
impl AuthService {
pub fn new(
jwt_authenticator: Arc<dyn Authenticator>,
api_token_authenticator: Arc<dyn Authenticator>,
token_store: Arc<dyn TokenStore>,
) -> Self {
Self {
jwt_authenticator,
api_token_authenticator,
token_store,
}
}
pub async fn authenticate_jwt(&self, token: &str) -> Result<AuthClaims, AuthError> {
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<AuthClaims, AuthError> {
self.api_token_authenticator.authenticate(token).await
}
pub async fn authenticate(&self, token: &str) -> Result<AuthClaims, AuthError> {
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
}
}

15
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<AuthClaims, AuthError>;
}
#[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<Option<AuthClaims>, AuthError>;
async fn revoke_token(&self, token: &str) -> Result<(), AuthError>;
async fn is_revoked(&self, token: &str) -> Result<bool, AuthError>;
}

1
lib/src/common.rs

@ -2,6 +2,5 @@ pub mod entities;
pub mod errors; pub mod errors;
pub mod traits; pub mod traits;
pub use self::traits::DictRepository;
pub use self::traits::SystemDecoder; pub use self::traits::SystemDecoder;
pub use self::traits::SystemEncoder; pub use self::traits::SystemEncoder;

42
lib/src/common/entities.rs

@ -2,7 +2,7 @@ use super::errors::CodecError;
use serde::Serialize; use serde::Serialize;
use std::num::ParseIntError; use std::num::ParseIntError;
use std::ops::Deref; use std::ops::Deref;
use std::{collections::HashMap, u64}; use std::u64;
/// A number encoded as a sequence of words /// A number encoded as a sequence of words
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
@ -104,43 +104,3 @@ impl TryFrom<usize> for DecodedLength {
} }
} }
} }
// --- Dictionary ---
pub type DictEntryId = u64;
#[derive(Debug, Clone, PartialEq)]
pub struct DictEntry {
pub id: Option<DictEntryId>,
pub text: String,
pub metadata: HashMap<String, String>,
}
impl DictEntry {
pub fn new(id: Option<DictEntryId>, text: String) -> Self {
DictEntry {
id,
text,
metadata: HashMap::new(),
}
}
}
#[derive(Debug, Clone)]
pub struct Dict {
pub name: String,
pub entries: HashMap<DictEntryId, DictEntry>,
}
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);
}
}

54
lib/src/common/errors.rs

@ -5,7 +5,7 @@ pub enum RepositoryError {
#[error("Data source connection failed")] #[error("Data source connection failed")]
ConnectionFailed, ConnectionFailed,
#[error("'{0}' not found")] #[error("\'{0}\' not found")]
NotFound(String), NotFound(String),
#[error("Storage error: {0}")] #[error("Storage error: {0}")]
@ -29,3 +29,55 @@ pub enum CodecError {
#[error("unexpected error: {0}")] #[error("unexpected error: {0}")]
UnexpectedError(String), 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<crate::sys_major::lvmap::LenValueMapError> 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),
}

32
lib/src/common/traits.rs

@ -1,8 +1,6 @@
use futures::stream::BoxStream;
use crate::common::{ use crate::common::{
entities::{DecodedValue, Dict, DictEntry, EncodedValue}, entities::{DecodedValue, EncodedValue},
errors::{CodecError, RepositoryError}, errors::CodecError,
}; };
pub trait SystemDecoder: Send + Sync { pub trait SystemDecoder: Send + Sync {
@ -13,29 +11,3 @@ pub trait SystemEncoder: Send + Sync {
fn initialize(&self) -> Result<(), CodecError>; fn initialize(&self) -> Result<(), CodecError>;
fn encode(&self, word: &str) -> Result<EncodedValue, CodecError>; fn encode(&self, word: &str) -> Result<EncodedValue, CodecError>;
} }
#[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<Dict, RepositoryError>;
/// Returns a cold stream that fetches strings in chunks.
/// The stream yields `Result<Vec<String>, RepositoryError>`.
async fn stream_batches(
&self,
batch_size: usize,
) -> Result<BoxStream<'_, Result<Vec<String>, RepositoryError>>, RepositoryError>;
}
pub trait DictSource {
fn next_entry(&mut self) -> Option<Result<DictEntry, anyhow::Error>>;
}

74
lib/src/dictionary.rs

@ -1,6 +1,80 @@
mod dict_importer; mod dict_importer;
mod infrastructure; mod infrastructure;
pub mod service;
use futures::stream::BoxStream;
use crate::common::errors::RepositoryError;
pub use self::dict_importer::DictImporter; pub use self::dict_importer::DictImporter;
pub use self::infrastructure::json_file_dict_source::JsonFileDictSource; pub use self::infrastructure::json_file_dict_source::JsonFileDictSource;
pub use self::infrastructure::sqlite_dict_repository::SqliteDictRepository; 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<DictEntryId>,
pub text: String,
pub metadata: HashMap<String, String>,
}
impl DictEntry {
pub fn new(id: Option<DictEntryId>, text: String) -> Self {
DictEntry {
id,
text,
metadata: HashMap::new(),
}
}
}
#[derive(Debug, Clone)]
pub struct Dict {
pub name: String,
pub entries: HashMap<DictEntryId, DictEntry>,
}
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<Vec<String>, RepositoryError>;
async fn count_entries(&self) -> Result<u64, RepositoryError>;
async fn save_entries(&self, entries: &[DictEntry]) -> Result<(), RepositoryError>;
async fn fetch_many(&self, limit: usize, offset: usize) -> Result<Dict, RepositoryError>;
async fn stream_batches(
&self,
batch_size: usize,
) -> Result<BoxStream<'_, Result<Vec<String>, RepositoryError>>, RepositoryError>;
}
#[async_trait::async_trait]
pub trait DictRepositoryFactory: Send + Sync {
async fn create(&self, dict_name: &str) -> Result<Box<dyn DictRepository>, RepositoryError>;
async fn list_all(&self) -> Result<Vec<String>, RepositoryError>;
}
pub trait DictSource {
fn next_entry(&mut self) -> Option<Result<DictEntry, anyhow::Error>>;
}

2
lib/src/dictionary/dict_importer.rs

@ -1,6 +1,6 @@
use std::sync::Arc; use std::sync::Arc;
use crate::common::traits::{DictRepository, DictSource}; use super::{DictRepository, DictSource};
pub struct DictImporter { pub struct DictImporter {
repo: Arc<dyn DictRepository>, repo: Arc<dyn DictRepository>,

4
lib/src/dictionary/infrastructure/json_file_dict_source.rs

@ -1,5 +1,5 @@
use crate::common::entities::DictEntry; use crate::dictionary::DictEntry;
use crate::common::traits::DictSource; use crate::dictionary::DictSource;
use serde::Deserialize; use serde::Deserialize;
use std::collections::HashMap; use std::collections::HashMap;
use std::fs::File; use std::fs::File;

65
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::errors::RepositoryError;
use crate::common::traits::DictRepository; use crate::dictionary::{Dict, DictEntry, DictRepository, DictRepositoryFactory};
use futures::TryStreamExt; use futures::TryStreamExt;
use futures::stream::BoxStream; use futures::stream::BoxStream;
@ -12,24 +11,19 @@ use std::str::FromStr;
struct SqliteEntryDto { struct SqliteEntryDto {
id: i64, id: i64,
text: String, text: String,
// sqlx reads the DB column into this specific wrapper
metadata: sqlx::types::Json<HashMap<String, String>>, metadata: sqlx::types::Json<HashMap<String, String>>,
} }
// Mapper: DTO -> Domain Entity
impl From<SqliteEntryDto> for DictEntry { impl From<SqliteEntryDto> for DictEntry {
fn from(dto: SqliteEntryDto) -> Self { fn from(dto: SqliteEntryDto) -> Self {
Self { Self {
id: Some(dto.id as u64), id: Some(dto.id as u64),
text: dto.text, text: dto.text,
// Unwrap the sqlx wrapper to get the inner HashMap
metadata: dto.metadata.0, metadata: dto.metadata.0,
} }
} }
} }
// --- REPOSITORY IMPLEMENTATION ---
#[derive(Clone)] #[derive(Clone)]
pub struct SqliteDictRepository { pub struct SqliteDictRepository {
pool: SqlitePool, pool: SqlitePool,
@ -46,7 +40,6 @@ impl SqliteDictRepository {
.await .await
.map_err(|_| RepositoryError::ConnectionFailed)?; .map_err(|_| RepositoryError::ConnectionFailed)?;
// Ensure tables exist with proper Normalization and Constraints
sqlx::query( sqlx::query(
r#" r#"
CREATE TABLE IF NOT EXISTS dictionaries ( CREATE TABLE IF NOT EXISTS dictionaries (
@ -62,7 +55,6 @@ impl SqliteDictRepository {
metadata TEXT, metadata TEXT,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(dictionary_id) REFERENCES dictionaries(id) ON DELETE CASCADE, 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) UNIQUE(dictionary_id, text)
); );
"#, "#,
@ -72,12 +64,11 @@ impl SqliteDictRepository {
.map_err(|e| RepositoryError::StorageError(e.to_string()))?; .map_err(|e| RepositoryError::StorageError(e.to_string()))?;
Ok(Self { Ok(Self {
pool: pool, pool,
dict_name: "default_dict".into(), dict_name: "default_dict".into(),
}) })
} }
// Helper: Resolve dictionary name to ID
async fn get_dict_id(&self) -> Result<i64, RepositoryError> { async fn get_dict_id(&self) -> Result<i64, RepositoryError> {
let row = sqlx::query("SELECT id FROM dictionaries WHERE name = ?") let row = sqlx::query("SELECT id FROM dictionaries WHERE name = ?")
.bind(&self.dict_name) .bind(&self.dict_name)
@ -94,6 +85,10 @@ impl SqliteDictRepository {
#[async_trait::async_trait] #[async_trait::async_trait]
impl DictRepository for SqliteDictRepository { impl DictRepository for SqliteDictRepository {
fn use_dict(&mut self, name: &str) {
self.dict_name = name.to_string();
}
async fn create_dict(&self) -> Result<(), RepositoryError> { async fn create_dict(&self) -> Result<(), RepositoryError> {
sqlx::query("INSERT OR IGNORE INTO dictionaries (name) VALUES (?)") sqlx::query("INSERT OR IGNORE INTO dictionaries (name) VALUES (?)")
.bind(&self.dict_name) .bind(&self.dict_name)
@ -103,8 +98,30 @@ impl DictRepository for SqliteDictRepository {
Ok(()) Ok(())
} }
fn use_dict(&mut self, name: &str) { async fn fetch_dicts(&self) -> Result<Vec<String>, RepositoryError> {
self.dict_name = name.to_string(); 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<u64, RepositoryError> {
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::<i64, _>("count") as u64),
None => Ok(0),
}
} }
async fn save_entries(&self, entries: &[DictEntry]) -> Result<(), RepositoryError> { async fn save_entries(&self, entries: &[DictEntry]) -> Result<(), RepositoryError> {
@ -225,7 +242,27 @@ impl DictRepository for SqliteDictRepository {
RepositoryError::StorageError(e.to_string()) RepositoryError::StorageError(e.to_string())
}); });
// 4. Box the stream to erase the complex iterator type (Type Erasure)
Ok(Box::pin(stream)) Ok(Box::pin(stream))
} }
} }
#[async_trait::async_trait]
impl DictRepositoryFactory for SqliteDictRepository {
async fn create(&self, dict_name: &str) -> Result<Box<dyn DictRepository>, 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<Vec<String>, 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())
}
}

66
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<dyn DictRepositoryFactory>,
}
impl DictionaryService {
pub fn new(repo_factory: Arc<dyn DictRepositoryFactory>) -> Self {
Self { repo_factory }
}
pub async fn list_dictionaries(&self) -> Result<Vec<DictionarySummary>, 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<Dict, ServiceError> {
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<u64, ServiceError> {
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)
}
}

23
lib/src/lib.rs

@ -1,20 +1,21 @@
// pub mod application; pub mod auth;
// 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;
mod common; mod common;
mod dictionary; mod dictionary;
pub mod sys_major; pub mod sys_major;
pub use self::common::DictRepository;
pub use self::common::SystemDecoder; pub use self::common::SystemDecoder;
pub use self::common::SystemEncoder; pub use self::common::SystemEncoder;
pub use self::common::errors::{AuthError, RepositoryError, ServiceError};
pub use self::dictionary::DictImporter; pub use self::dictionary::DictImporter;
pub use self::dictionary::DictRepository;
pub use self::dictionary::DictRepositoryFactory;
pub use self::dictionary::JsonFileDictSource; pub use self::dictionary::JsonFileDictSource;
pub use self::dictionary::SqliteDictRepository; 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};

8
lib/src/sys_major.rs

@ -1,12 +1,14 @@
pub mod decoder; pub mod decoder;
pub mod encoder; pub mod encoder;
pub mod lvmap; pub mod lvmap;
pub mod rules_en; // TODO: pub? pub mod rules_en;
pub mod rules_pl; // TODO: pub? pub mod rules_pl;
pub mod service;
pub use self::decoder::Decoder; pub use self::decoder::Decoder;
pub use self::encoder::Encoder; pub use self::encoder::Encoder;
pub use self::lvmap::LenValueMap; // TODO: pub? pub use self::lvmap::LenValueMap;
pub use self::service::MajorSystemService;
#[cfg(test)] #[cfg(test)]
mod decoder_tests; mod decoder_tests;

36
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<dyn SystemDecoder>,
encoder: Option<Arc<dyn SystemEncoder>>,
}
impl MajorSystemService {
pub fn new(decoder: Arc<dyn SystemDecoder>) -> Self {
Self {
decoder,
encoder: None,
}
}
pub fn with_encoder(mut self, encoder: Arc<dyn SystemEncoder>) -> Self {
self.encoder = Some(encoder);
self
}
pub fn decode(&self, input: &str) -> Result<DecodedValue, ServiceError> {
self.decoder.decode(input).map_err(Into::into)
}
pub fn encode(&self, input: &str) -> Result<EncodedValue, ServiceError> {
let encoder = self
.encoder
.as_ref()
.ok_or_else(|| ServiceError::Unavailable("Encoder not initialized".to_string()))?;
encoder.encode(input).map_err(Into::into)
}
}

14
tavern-tests/export.sh

@ -0,0 +1,14 @@
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"

3
tavern-tests/requirements.txt

@ -0,0 +1,3 @@
pyyaml
tavern
allure-pytest

13
tavern-tests/tavern-run-all.sh

@ -0,0 +1,13 @@
#!/usr/bin/env bash
if [ -z "$TEST_SERVER_ADDRESS" ]; then
source export.sh
fi
tavern-ci --alluredir=reports test_plans/info_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

16
tavern-tests/tavern-run-single.sh

@ -0,0 +1,16 @@
#!/usr/bin/env bash
if [ -z "$1" ]; then
echo "Usage: $0 <test plan>"
exit 1
fi
if [ -z "$TEST_SERVER_ADDRESS" ]; then
source export.sh
fi
tavern-ci --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

29
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

5
tavern-tests/test_plans/includes.yaml

@ -0,0 +1,5 @@
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}"

19
tavern-tests/test_plans/info_test.tavern.yaml

@ -0,0 +1,19 @@
test_name: "Test server API info endpoint"
includes:
- !include includes.yaml
stages:
- name: "Check version"
request:
url: "http://{server_address}/{api_base}/info/version"
method: GET
headers:
X-API-Key: "{api_key}"
response:
strict: False
status_code: 200
json:
name: !anystr
version: !anystr
Loading…
Cancel
Save