Compare commits

...

5 Commits

  1. 37
      .devcontainer/Dockerfile
  2. 45
      .devcontainer/devcontainer.json
  3. 15
      Cargo.toml
  4. 152
      README.md
  5. 29
      apps/app_api/Cargo.toml
  6. 13
      apps/app_api/config.toml.example
  7. 9
      apps/app_api/src/api.rs
  8. 75
      apps/app_api/src/api/v1.rs
  9. 40
      apps/app_api/src/api/v1/auth.rs
  10. 47
      apps/app_api/src/api/v1/dictionary.rs
  11. 46
      apps/app_api/src/api/v1/info.rs
  12. 84
      apps/app_api/src/api/v1/major.rs
  13. 37
      apps/app_api/src/app.rs
  14. 69
      apps/app_api/src/commands.rs
  15. 113
      apps/app_api/src/commands/listen.rs
  16. 48
      apps/app_api/src/config.rs
  17. 18
      apps/app_api/src/config/auth.rs
  18. 14
      apps/app_api/src/config/database.rs
  19. 52
      apps/app_api/src/dependencies.rs
  20. 100
      apps/app_api/src/error.rs
  21. 18
      apps/app_api/src/main.rs
  22. 26
      apps/app_api/src/router.rs
  23. 21
      apps/app_api/src/state.rs
  24. 24
      apps/app_cli/Cargo.toml
  25. 42
      apps/app_cli/src/app.rs
  26. 105
      apps/app_cli/src/commands.rs
  27. 77
      apps/app_cli/src/commands/decode.rs
  28. 90
      apps/app_cli/src/commands/encode.rs
  29. 71
      apps/app_cli/src/commands/import_dict.rs
  30. 77
      apps/app_cli/src/commands/list_dicts.rs
  31. 70
      apps/app_cli/src/config.rs
  32. 68
      apps/app_cli/src/container.rs
  33. 14
      apps/app_cli/src/main.rs
  34. 13
      config.toml
  35. 27
      lib/Cargo.toml
  36. 13
      lib/src/auth.rs
  37. 72
      lib/src/auth/domain.rs
  38. 47
      lib/src/auth/infrastructure/api_token.rs
  39. 288
      lib/src/auth/infrastructure/jwt.rs
  40. 58
      lib/src/auth/infrastructure/store.rs
  41. 59
      lib/src/auth/service.rs
  42. 15
      lib/src/auth/traits.rs
  43. 6
      lib/src/common.rs
  44. 106
      lib/src/common/entities.rs
  45. 83
      lib/src/common/errors.rs
  46. 13
      lib/src/common/traits.rs
  47. 80
      lib/src/dictionary.rs
  48. 59
      lib/src/dictionary/dict_importer.rs
  49. 2
      lib/src/dictionary/infrastructure.rs
  50. 85
      lib/src/dictionary/infrastructure/json_file_dict_source.rs
  51. 268
      lib/src/dictionary/infrastructure/sqlite_dict_repository.rs
  52. 66
      lib/src/dictionary/service.rs
  53. 21
      lib/src/lib.rs
  54. 14
      lib/src/sys_major.rs
  55. 122
      lib/src/sys_major/decoder.rs
  56. 134
      lib/src/sys_major/decoder_tests.rs
  57. 179
      lib/src/sys_major/encoder.rs
  58. 351
      lib/src/sys_major/lvmap.rs
  59. 15
      lib/src/sys_major/rules_en.rs
  60. 222
      lib/src/sys_major/rules_pl.rs
  61. 36
      lib/src/sys_major/service.rs
  62. 8612
      resources/dsr1ll70_pl_demo.json
  63. 12
      resources/example_dict.json
  64. 15
      tavern-tests/export.sh
  65. 3
      tavern-tests/requirements.txt
  66. 20
      tavern-tests/tavern-run-all.sh
  67. 19
      tavern-tests/tavern-run-single.sh
  68. 29
      tavern-tests/test_plans/common_stages.yaml
  69. 29
      tavern-tests/test_plans/decode_test.tavern.yaml
  70. 30
      tavern-tests/test_plans/dictionary_test.tavern.yaml
  71. 44
      tavern-tests/test_plans/encode_test.tavern.yaml
  72. 6
      tavern-tests/test_plans/includes.yaml
  73. 20
      tavern-tests/test_plans/version_test.tavern.yaml

37
.devcontainer/Dockerfile

@ -0,0 +1,37 @@
FROM rust:1.94.1-trixie
# Install basic development tools
RUN apt-get update && apt-get install -y \
build-essential \
gdb \
git \
procps \
sudo \
&& rm -rf /var/lib/apt/lists/*
# Create developer user with host UID/GID
ARG USER_UID=1000
ARG USER_GID=1000
ENV USER_UID=${USER_UID:-1000}
ENV USER_GID=${USER_GID:-1000}
RUN groupadd -g $USER_GID developer \
&& useradd -u $USER_UID -g $USER_GID -m developer \
&& mkdir -p /workspace \
&& chown developer:developer /workspace
RUN echo 'developer ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers.d/developer \
&& chmod 0440 /etc/sudoers.d/developer
RUN mkdir -p /home/developer/.vscode-server \
&& chown -R developer:developer /home/developer/.vscode-server
# Switch to developer user
USER developer
RUN rustup component add rustfmt
# Set up cargo path
ENV PATH="/home/developer/.cargo/bin:${PATH}"
# Set default workspace directory
WORKDIR /workspace

45
.devcontainer/devcontainer.json

@ -0,0 +1,45 @@
{
"name": "Rust Development",
"build": {
"dockerfile": "Dockerfile",
"args": {
"USER_UID": "${localEnv:UID:1000}",
"USER_GID": "${localEnv:GID:1000}"
}
},
"remoteUser": "developer",
"customizations": {
"vscode": {
"extensions": [
"rust-lang.rust-analyzer",
"fill-labs.dependi",
"jalalalizz.cargo-toolset",
"tamasfe.even-better-toml",
"vadimcn.vscode-lldb",
"frosticless.monokai-one-darker",
"ms-vscode.cpptools"
],
"settings": {
"workbench.colorTheme": "Monokai One Darker",
"terminal.integrated.defaultProfile.linux": "bash",
"rust-analyzer.checkOnSave": true,
"rust-analyzer.cargo.loadOutDirsFromCheck": true,
"rust-analyzer.procMacro.enable": true,
"[rust]": {
"editor.defaultFormatter": "rust-lang.rust-analyzer",
"editor.formatOnSave": true
}
}
}
},
"forwardPorts": [8080, 3000],
"runArgs": [
"--name",
"${localEnv:USER}-${localWorkspaceFolderBasename}-rust-dev",
"--mount",
"type=volume,source=vscode-extensions,target=/home/developer/.vscode-server/extensions"
],
"postStartCommand": "sudo chown -R $(id -u):$(id -g) ~/.vscode-server || true",
"workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind",
"workspaceFolder": "/workspace"
}

15
Cargo.toml

@ -0,0 +1,15 @@
[workspace]
members = [
"apps/app_api",
"apps/app_cli",
"lib",
]
resolver = "3"
[workspace.package]
edition = "2024"
authors = ["chodak166 <chodak166@op.pl>"]
[profile.release]
lto = true # Link Time Optimization: Analyzes entire program for optimizations
codegen-units = 1 # Forces single-threaded compilation (slower build, but smaller/faster binary)

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

29
apps/app_api/Cargo.toml

@ -0,0 +1,29 @@
[package]
name = "phomnemic-server"
version = "0.1.0"
edition = "2024"
[dependencies]
# Internal
applib = { path = "../../lib" }
# Runtime & Async
tokio = { version = "1.48", features = ["full"] }
anyhow = "1.0"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
async-trait = "0.1"
# Configuration & Inputs
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
toml = "0.9.8"
clap = { version = "4.5", features = ["derive", "env"] }
config = "0.15.19"
const_format = "0.2.35"
chrono = { version = "0.4", features = ["serde"] }
# Web Framework
axum = "0.8"
tower = "0.5"
tower-http = { version = "0.6", features = ["trace", "cors"] }

13
apps/app_api/config.toml.example

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

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))
}

75
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<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 {
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()
}
}
}

40
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<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 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<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))
}

37
apps/app_api/src/app.rs

@ -0,0 +1,37 @@
use crate::commands::{AppCommand, CliArgs};
use crate::config::AppConfig;
use anyhow::Result;
use clap::Parser;
use tracing::debug;
pub struct Application {
config: AppConfig,
command: Box<dyn AppCommand>,
}
impl Application {
pub async fn build() -> Result<Self> {
let args = CliArgs::parse();
let app_cmd = args.command.into_app_command();
let config = AppConfig::build(&args.global, app_cmd.as_ref())?;
tracing_subscriber::fmt()
.compact()
.with_env_filter(&config.log_level)
.with_target(false)
.init();
debug!("Bootstrapping application...");
Ok(Self {
config,
command: app_cmd,
})
}
pub async fn run(self) -> Result<()> {
self.command.execute(&self.config).await
}
}

69
apps/app_api/src/commands.rs

@ -0,0 +1,69 @@
pub mod listen;
use crate::config::AppConfig;
use anyhow::Result;
use async_trait::async_trait;
use clap::Subcommand;
use clap::{Args as ClapArgs, Parser};
use config::ConfigBuilder;
use config::builder::DefaultState;
use std::path::PathBuf;
#[derive(Subcommand, Debug, Clone)]
pub enum Command {
Listen(listen::ListenCmd),
}
impl Command {
pub fn into_app_command(self) -> Box<dyn AppCommand> {
match self {
Command::Listen(cmd) => Box::new(cmd),
}
}
}
#[derive(Parser, Debug)]
#[command(author, version, about)]
pub struct CliArgs {
#[command(flatten)]
pub global: GlobalArgs,
#[command(subcommand)]
pub command: Command,
}
#[derive(ClapArgs, Debug)]
pub struct GlobalArgs {
#[arg(short, long, default_value = "config.toml")]
pub config: PathBuf,
#[arg(long, help = defaults::HELP_LOG)]
pub log_level: Option<String>,
}
pub trait Configurable {
fn apply_defaults(
&self,
builder: ConfigBuilder<DefaultState>,
) -> Result<ConfigBuilder<DefaultState>>;
fn apply_overrides(
&self,
builder: ConfigBuilder<DefaultState>,
) -> Result<ConfigBuilder<DefaultState>>;
}
#[async_trait]
pub trait Executable {
async fn execute(&self, config: &AppConfig) -> Result<()>;
}
pub trait AppCommand: Configurable + Executable {}
impl<T: Configurable + Executable> AppCommand for T {}
mod defaults {
use const_format::formatcp;
pub const LOG_LEVEL: &str = "info";
pub const HELP_LOG: &str = formatcp!("Override Log Level [default: {}]", LOG_LEVEL);
}

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

@ -0,0 +1,113 @@
use crate::commands::{ClapArgs, Configurable, Executable};
use crate::config::AppConfig;
use crate::router;
use anyhow::Result;
use async_trait::async_trait;
use config::ConfigBuilder;
use config::builder::DefaultState;
use serde::Deserialize;
use tokio::net::TcpListener;
use tracing::info;
#[derive(Debug, Deserialize, Clone)]
pub struct Config {
pub host: String,
pub port: u16,
}
#[derive(ClapArgs, Debug, Clone)]
pub struct ListenCmd {
#[arg(short = 'H', long, help = defaults::HELP_LISTEN_HOST)]
pub host: Option<String>,
#[arg(short, long, help = defaults::HELP_LISTEN_PORT)]
pub port: Option<u16>,
}
impl Configurable for ListenCmd {
fn apply_defaults(
&self,
builder: ConfigBuilder<DefaultState>,
) -> Result<ConfigBuilder<DefaultState>> {
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)
}
fn apply_overrides(
&self,
builder: ConfigBuilder<DefaultState>,
) -> Result<ConfigBuilder<DefaultState>> {
let mut builder = builder;
if let Some(host) = &self.host {
builder = builder.set_override("listen.host", host.clone())?;
}
if let Some(port) = &self.port {
builder = builder.set_override("listen.port", port.clone())?;
}
Ok(builder)
}
}
#[async_trait]
impl Executable for ListenCmd {
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(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?;
info!("Server shut down gracefully");
Ok(())
}
}
async fn shutdown_signal() {
let ctrl_c = async {
tokio::signal::ctrl_c()
.await
.expect("Failed to install Ctrl+C handler");
};
#[cfg(unix)]
let terminate = async {
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("Failed to install signal handler")
.recv()
.await;
info!("Received SIGTERM signal");
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
}
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);
pub const HELP_LISTEN_PORT: &str = formatcp!("Port to listen on [default: {}]", LISTEN_PORT);
}

48
apps/app_api/src/config.rs

@ -0,0 +1,48 @@
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<listen::Config>,
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<Self> {
let mut builder = Config::builder();
builder = handler.apply_defaults(builder)?;
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));
builder = builder.add_source(Environment::with_prefix("APP").separator("_"));
if let Some(ref level) = args.log_level {
builder = builder.set_override("log_level", level.clone())?;
}
builder = handler.apply_overrides(builder)?;
builder
.build()
.context("Failed to build configuration layers")?
.try_deserialize()
.context("Failed to deserialize Config")
}
}

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

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

18
apps/app_api/src/main.rs

@ -0,0 +1,18 @@
mod api;
mod app;
mod commands;
mod config;
mod dependencies;
mod error;
mod router;
mod state;
use anyhow::Result;
use app::Application;
#[tokio::main]
async fn main() -> Result<()> {
let app = Application::build().await?;
app.run().await?;
Ok(())
}

26
apps/app_api/src/router.rs

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

21
apps/app_api/src/state.rs

@ -0,0 +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 async fn new(dependencies: AppDependencies) -> Self {
Self {
name: APP_NAME.to_string(),
version: APP_VERSION.to_string(),
dependencies,
}
}
}

24
apps/app_cli/Cargo.toml

@ -0,0 +1,24 @@
[package]
name = "phomnemic-cli"
version = "0.1.0"
edition = "2024"
[dependencies]
# Internal
applib = { path = "../../lib" }
# Runtime & Async
tokio = { version = "1.48", features = ["full"] }
anyhow = "1.0"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
async-trait = "0.1"
# Configuration & Inputs
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
toml = "0.9.8"
clap = { version = "4.5", features = ["derive", "env"] }
config = "0.15.19"
const_format = "0.2.35"

42
apps/app_cli/src/app.rs

@ -0,0 +1,42 @@
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<dyn AppCommand>,
}
impl Application {
pub async fn build() -> Result<Self> {
let args = CliArgs::parse();
let app_cmd = args.command.into_app_command();
let config = AppConfig::build(&args.global, app_cmd.as_ref())?;
tracing_subscriber::fmt()
.compact()
.with_env_filter(&config.log_level)
.with_target(false)
.init();
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
}
}

105
apps/app_cli/src/commands.rs

@ -0,0 +1,105 @@
pub mod decode;
pub mod encode;
pub mod import_dict;
pub mod list_dicts;
use crate::config::AppConfig;
use crate::container::Container;
use anyhow::Result;
use async_trait::async_trait;
use clap::Subcommand;
use clap::{Args as ClapArgs, Parser};
use config::ConfigBuilder;
use config::builder::DefaultState;
use std::path::PathBuf;
#[derive(Subcommand, Debug, Clone)]
pub enum Command {
/// Decode a word using given system
Decode(decode::DecodeCmd),
/// Encode a number using given system
Encode(encode::EncodeCmd),
/// Import dictionary
ImportDict(import_dict::ImportDictCmd),
/// List all dictionaries
ListDicts(list_dicts::ListDictsCmd),
}
impl Command {
pub fn into_app_command(self) -> Box<dyn AppCommand> {
match self {
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<dyn AppCommand> {
// 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 {
#[command(flatten)]
pub global: GlobalArgs,
#[command(subcommand)]
pub command: Command,
}
#[derive(ClapArgs, Debug)]
pub struct GlobalArgs {
/// Path to config file
#[arg(short, long, default_value = "config.toml")]
pub config: PathBuf,
#[arg(long, help = defaults::HELP_LOG)]
pub log_level: Option<String>,
}
pub trait Configurable {
fn apply_defaults(
&self,
builder: ConfigBuilder<DefaultState>,
) -> Result<ConfigBuilder<DefaultState>>;
fn apply_overrides(
&self,
builder: ConfigBuilder<DefaultState>,
) -> Result<ConfigBuilder<DefaultState>>;
}
#[async_trait]
pub trait Executable {
async fn execute(&self, config: &AppConfig, container: &Container) -> 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<T: Configurable + Executable> AppCommand for T {}
mod defaults {
use const_format::formatcp;
pub const LOG_LEVEL: &str = "info";
pub const HELP_LOG: &str = formatcp!("Override Log Level [default: {}]", LOG_LEVEL);
}

77
apps/app_cli/src/commands/decode.rs

@ -0,0 +1,77 @@
use crate::commands::{ClapArgs, Configurable, Executable};
use crate::config::AppConfig;
use crate::config::System;
use crate::container::Container;
use anyhow::{Context, Result};
use async_trait::async_trait;
use config::ConfigBuilder;
use config::builder::DefaultState;
use serde::Deserialize;
#[derive(Debug, Deserialize, Clone)]
pub struct Config {
pub system: System,
pub input: String,
}
#[derive(ClapArgs, Debug, Clone)]
pub struct DecodeCmd {
#[arg(short, long, help = defaults::HELP_DEC_SYSTEM)]
pub system: Option<String>,
#[arg(short, long, help = defaults::HELP_DEC_INPUT)]
pub input: String,
}
impl Configurable for DecodeCmd {
fn apply_defaults(
&self,
builder: ConfigBuilder<DefaultState>,
) -> Result<ConfigBuilder<DefaultState>> {
builder
.set_default("decode.system", defaults::DEC_SYSTEM_NAME)?
.set_default("decode.input", "")
.map_err(Into::into)
}
fn apply_overrides(
&self,
builder: ConfigBuilder<DefaultState>,
) -> Result<ConfigBuilder<DefaultState>> {
let mut builder = builder;
if let Some(ref system) = self.system {
builder = builder.set_override("decode.system", system.clone())?;
}
builder = builder.set_override("decode.input", self.input.clone())?;
Ok(builder)
}
}
#[async_trait]
impl Executable for DecodeCmd {
async fn execute(&self, config: &AppConfig, container: &Container) -> Result<()> {
let config = config
.decode
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Decoder config missing"))?;
let decoder = container.create_decoder(&config)?;
let result = decoder
.decode(&config.input)
.with_context(|| format!("Failed to decode input: {}", config.input))?;
let json = serde_json::to_string_pretty(&result).expect("JSON serialization failed");
println!("{}", json);
Ok(())
}
}
mod defaults {
use const_format::formatcp;
pub const DEC_SYSTEM_NAME: &str = "major_pl";
pub const HELP_DEC_SYSTEM: &str = formatcp!("System to use [default: {}]", DEC_SYSTEM_NAME);
pub const HELP_DEC_INPUT: &str = formatcp!("Text to decode");
}

90
apps/app_cli/src/commands/encode.rs

@ -0,0 +1,90 @@
use serde::Deserialize;
use crate::commands::{ClapArgs, Configurable, Executable};
use crate::config::{AppConfig, System};
use crate::container::Container;
use anyhow::Result;
use async_trait::async_trait;
use config::ConfigBuilder;
use config::builder::DefaultState;
#[derive(Debug, Deserialize, Clone)]
pub struct Config {
pub system: System,
pub input: String,
pub dict_name: String,
}
#[derive(ClapArgs, Debug, Clone)]
pub struct EncodeCmd {
#[arg(short, long, help = defaults::HELP_ENC_SYSTEM)]
pub system: Option<String>,
#[arg(short, long, help = defaults::HELP_ENC_DICT)]
pub dict_name: Option<String>,
#[arg(short, long, help = defaults::HELP_ENC_INPUT)]
pub input: String,
}
impl Configurable for EncodeCmd {
fn apply_defaults(
&self,
builder: ConfigBuilder<DefaultState>,
) -> Result<ConfigBuilder<DefaultState>> {
builder
.set_default("encode.system", defaults::ENC_SYSTEM_NAME)?
.set_default("encode.dict_name", defaults::ENC_DICT_NAME)?
.set_default("encode.input", "")
.map_err(Into::into)
}
fn apply_overrides(
&self,
builder: ConfigBuilder<DefaultState>,
) -> Result<ConfigBuilder<DefaultState>> {
let mut builder = builder;
if let Some(system) = &self.system {
builder = builder.set_override("encode.system", system.clone())?;
}
if let Some(dict_name) = &self.dict_name {
builder = builder.set_override("encode.dict_name", dict_name.clone())?;
}
builder = builder.set_override("encode.input", self.input.clone())?;
Ok(builder)
}
}
#[async_trait]
impl Executable for EncodeCmd {
async fn execute(&self, config: &AppConfig, container: &Container) -> Result<()> {
let config = config
.encode
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Encoder config not set"))?;
let encoder = container.create_encoder(&config).await?;
let result = encoder.encode(&config.input);
match result {
Ok(res) => {
let json = serde_json::to_string_pretty(&res).expect("JSON serialization failed");
println!("{}", json);
}
Err(e) => eprintln!("Error encoding: {:?}", e),
}
Ok(())
}
}
mod defaults {
use const_format::formatcp;
pub const ENC_SYSTEM_NAME: &str = "major_pl";
pub const ENC_DICT_NAME: &str = "demo_pl";
pub const HELP_ENC_SYSTEM: &str = formatcp!("System to use [default: {}]", ENC_SYSTEM_NAME);
pub const HELP_ENC_INPUT: &str = formatcp!("Number to encode");
pub const HELP_ENC_DICT: &str = formatcp!("Dictionary to use [default: {}]", ENC_DICT_NAME);
}

71
apps/app_cli/src/commands/import_dict.rs

@ -0,0 +1,71 @@
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 name: String,
pub path: String,
}
#[derive(ClapArgs, Debug, Clone)]
pub struct ImportDictCmd {
#[arg(long, help = defaults::HELP_IMPORT_DICT_NAME)]
pub name: String,
#[arg(long, help = defaults::HELP_IMPORT_DICT_INPUT)]
pub path: String,
}
impl Configurable for ImportDictCmd {
fn apply_defaults(
&self,
builder: ConfigBuilder<DefaultState>,
) -> Result<ConfigBuilder<DefaultState>> {
builder
.set_default("import_dict.name", defaults::IMPORT_DICT_NAME)?
.set_default("import_dict.path", defaults::IMPORT_DICT_PATH)
.map_err(Into::into)
}
fn apply_overrides(
&self,
builder: ConfigBuilder<DefaultState>,
) -> Result<ConfigBuilder<DefaultState>> {
builder
.set_override("import_dict.name", self.name.clone())?
.set_override("import_dict.path", self.path.clone())
.map_err(Into::into)
}
}
#[async_trait]
impl Executable for ImportDictCmd {
async fn execute(&self, config: &AppConfig, container: &Container) -> Result<()> {
let config = config
.import_dict
.as_ref()
.expect("ImportDict config not set");
let importer = container.create_dict_importer(&config.name).await?;
// Importer expects an impl DictSource
// We need to create a DictSource from the path
use applib::JsonFileDictSource;
let source = JsonFileDictSource::new(&config.path)?;
importer.import(source).await?;
Ok(())
}
}
mod defaults {
use const_format::formatcp;
pub const IMPORT_DICT_NAME: &str = "";
pub const IMPORT_DICT_PATH: &str = "";
pub const HELP_IMPORT_DICT_NAME: &str = formatcp!("Dictionary name");
pub const HELP_IMPORT_DICT_INPUT: &str = formatcp!("Dictionary file path");
}

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");
}

70
apps/app_cli/src/config.rs

@ -0,0 +1,70 @@
use crate::commands::*;
use anyhow::{Context, Result};
use config::{Config, Environment, File};
use serde::Deserialize;
#[derive(Debug, Deserialize, Clone)]
pub struct AppConfig {
#[serde(default)]
pub decode: Option<decode::Config>,
#[serde(default)]
pub encode: Option<encode::Config>,
#[serde(default)]
pub import_dict: Option<import_dict::Config>,
#[serde(default)]
pub list_dicts: Option<list_dicts::Config>,
pub log_level: String,
}
impl AppConfig {
pub fn build(args: &GlobalArgs, handler: &dyn Configurable) -> Result<Self> {
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 (APP_SERVER_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
.build()
.context("Failed to build configuration layers")?
.try_deserialize()
.context("Failed to deserialize Config")
}
}
// TODO: move?
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
pub enum System {
#[serde(rename = "major_en")]
MajorEn,
#[serde(rename = "major_pl")]
MajorPl,
}
// from:
impl From<&str> for System {
fn from(s: &str) -> Self {
match s {
"major_en" => System::MajorEn,
"major_pl" => System::MajorPl,
_ => panic!("Unknown system: {}", s),
}
}
}

68
apps/app_cli/src/container.rs

@ -0,0 +1,68 @@
use std::sync::Arc;
// use crate::config::AppConfig;
use applib::DictImporter;
use applib::DictRepository;
use applib::SqliteDictRepository;
use applib::SystemDecoder;
use applib::SystemEncoder;
use applib::sys_major as major;
use crate::commands::decode;
use crate::commands::encode;
use crate::config::System;
#[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))
}
pub fn create_decoder(
&self,
config: &decode::Config,
) -> anyhow::Result<Box<dyn SystemDecoder>> {
Ok(match config.system {
System::MajorPl => Box::new(major::Decoder::new(major::rules_pl::get_rules())),
System::MajorEn => Box::new(major::Decoder::new(major::rules_en::get_rules())),
})
}
pub async fn create_encoder(
&self,
config: &encode::Config,
) -> anyhow::Result<Box<dyn SystemEncoder>> {
let dict = self.create_dict_repo(&config.dict_name).await?;
let dec_config = decode::Config {
system: config.system.clone(),
input: String::new(),
};
let decoder = self.create_decoder(&dec_config)?;
let words_stream = dict.stream_batches(1000).await.unwrap();
let lvmap = major::LenValueMap::from_stream(words_stream, &(*decoder))
.await
.unwrap();
let encoder = major::Encoder::new(lvmap);
Ok(Box::new(encoder))
}
}

14
apps/app_cli/src/main.rs

@ -0,0 +1,14 @@
mod app;
mod commands;
mod config;
mod container;
use anyhow::Result;
use app::Application;
#[tokio::main]
async fn main() -> Result<()> {
let app = Application::build().await?;
app.run().await?;
Ok(())
}

13
config.toml

@ -0,0 +1,13 @@
log_level = "trace"
[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"

27
lib/Cargo.toml

@ -0,0 +1,27 @@
[package]
name = "applib"
version = "0.1.0"
edition = "2024"
[dependencies]
once_cell = "1.21.3"
# clap = { version = "4.5", features = ["derive", "env"] } # Removed
const_format = "0.2.35"
config = "0.15.19"
tracing = "0.1"
tokio = { version = "1.48", features = ["full"] }
anyhow = "1.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
chrono = { version = "0.4", features = ["serde"] }
thiserror = "2.0"
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"

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

6
lib/src/common.rs

@ -0,0 +1,6 @@
pub mod entities;
pub mod errors;
pub mod traits;
pub use self::traits::SystemDecoder;
pub use self::traits::SystemEncoder;

106
lib/src/common/entities.rs

@ -0,0 +1,106 @@
use super::errors::CodecError;
use serde::Serialize;
use std::num::ParseIntError;
use std::ops::Deref;
use std::u64;
/// A number encoded as a sequence of words
#[derive(Debug, Clone, Serialize)]
pub struct EncodedPart {
pub value: u64,
pub words: Vec<String>,
}
/// A way (variant) to split input number
pub type EncodedSplit = Vec<EncodedPart>;
/// A number encoded as words, split in multiple ways
#[derive(Debug, Clone, Serialize)]
pub struct EncodedValue(Vec<EncodedSplit>);
impl EncodedValue {
pub fn new(data: Vec<EncodedSplit>) -> Self {
EncodedValue(data)
}
}
impl Deref for EncodedValue {
type Target = Vec<EncodedSplit>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
/// The number value can be encoded as many word sets,
/// but decoded as one number. For partial values
/// and dictionary words (reasonable length), we can use
/// u64 (20-digit number), but the whole input text can
/// be longer than 20 digits, so we operate on String (<= 255).
#[derive(Debug, Serialize, Clone, PartialEq, Eq)]
pub struct DecodedValue(String);
impl DecodedValue {
pub fn new(value: String) -> Result<Self, CodecError> {
if value.len() > u8::MAX as usize {
Err(CodecError::TextTooLong(value.len()))
} else {
Ok(Self(value))
}
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn parse(&self) -> Result<u64, ParseIntError> {
self.0.parse()
}
pub fn len(&self) -> usize {
self.0.len()
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn value_len(&self) -> Result<DecodedLength, CodecError> {
if self.len() == 0 {
return Err(CodecError::EmptyValue);
}
DecodedLength::try_from(self.len())
}
}
impl PartialEq<&str> for DecodedValue {
fn eq(&self, other: &&str) -> bool {
&self.0 == *other
}
}
impl PartialEq<DecodedValue> for &str {
fn eq(&self, other: &DecodedValue) -> bool {
*self == &other.0
}
}
#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
pub struct DecodedLength(u8);
impl DecodedLength {
pub const fn from(value: u8) -> Self {
Self(value)
}
}
impl TryFrom<usize> for DecodedLength {
type Error = CodecError;
fn try_from(value: usize) -> Result<Self, CodecError> {
if value > u8::MAX as usize {
Err(CodecError::ValueLimitExceeded(value))
} else {
Ok(Self(value as u8))
}
}
}

83
lib/src/common/errors.rs

@ -0,0 +1,83 @@
use thiserror::Error;
#[derive(Error, Debug)]
pub enum RepositoryError {
#[error("Data source connection failed")]
ConnectionFailed,
#[error("\'{0}\' not found")]
NotFound(String),
#[error("Storage error: {0}")]
StorageError(String),
}
#[derive(Debug, Error)]
pub enum CodecError {
#[error("text too long: {0} bytes")]
TextTooLong(usize),
#[error("value too large: {0}/255")]
ValueLimitExceeded(usize),
#[error("operation not allowed on empty value")]
EmptyValue,
#[error("initialization failed")]
InitializationFailed,
#[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<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),
}

13
lib/src/common/traits.rs

@ -0,0 +1,13 @@
use crate::common::{
entities::{DecodedValue, EncodedValue},
errors::CodecError,
};
pub trait SystemDecoder: Send + Sync {
fn decode(&self, word: &str) -> Result<DecodedValue, CodecError>;
}
pub trait SystemEncoder: Send + Sync {
fn initialize(&self) -> Result<(), CodecError>;
fn encode(&self, word: &str) -> Result<EncodedValue, CodecError>;
}

80
lib/src/dictionary.rs

@ -0,0 +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<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>>;
}

59
lib/src/dictionary/dict_importer.rs

@ -0,0 +1,59 @@
use std::sync::Arc;
use super::{DictRepository, DictSource};
pub struct DictImporter {
repo: Arc<dyn DictRepository>,
batch_size: usize,
}
impl DictImporter {
pub fn new(repo: Arc<dyn DictRepository>) -> Self {
Self {
repo,
batch_size: 1000, // reasonable default
}
}
pub fn with_batch_size(mut self, batch_size: usize) -> Self {
self.batch_size = batch_size;
self
}
pub async fn import(&self, mut source: impl DictSource) -> Result<(), anyhow::Error> {
// 1. Ensure Dict exists (Logic: Create if new, or maybe clear existing?)
self.repo.create_dict().await?;
let mut batch = Vec::with_capacity(self.batch_size);
// 2. Stream data
while let Some(result) = source.next_entry() {
match result {
Ok(entry) => {
// Optional: Domain Validation logic could go here
// if entry.text.is_empty() { continue; }
batch.push(entry);
// 3. Batch Write
if batch.len() >= self.batch_size {
self.repo.save_entries(&batch).await?;
batch.clear();
}
}
Err(e) => {
// Logic: Do we abort on malformed JSON or log and continue?
// Here we abort for safety.
return Err(e);
}
}
}
// 4. Flush remaining
if !batch.is_empty() {
self.repo.save_entries(&batch).await?;
}
Ok(())
}
}

2
lib/src/dictionary/infrastructure.rs

@ -0,0 +1,2 @@
pub mod json_file_dict_source;
pub mod sqlite_dict_repository;

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

@ -0,0 +1,85 @@
use crate::dictionary::DictEntry;
use crate::dictionary::DictSource;
use serde::Deserialize;
use std::collections::HashMap;
use std::fs::File;
use std::io::BufReader;
use std::path::Path;
// The "Wire Format".
// It exists ONLY here to map external JSON names to internal Entity names.
#[derive(Deserialize)]
struct JsonEntry {
word: String,
metadata: Option<HashMap<String, serde_json::Value>>,
}
pub struct JsonFileDictSource {
entries: Vec<DictEntry>,
current_index: usize,
next_id: u32,
}
impl JsonFileDictSource {
pub fn new<P: AsRef<Path>>(path: P) -> anyhow::Result<Self> {
let file = File::open(path)?;
let reader = BufReader::new(file);
// Parse as JSON array
let json_entries: Vec<JsonEntry> = serde_json::from_reader(reader)?;
// Convert to DictEntry with auto-generated IDs
let mut entries = Vec::new();
for (index, json_entry) in json_entries.into_iter().enumerate() {
let id = (index + 1) as u64; // Auto-generate ID starting from 1
// Convert metadata from serde_json::Value to HashMap<String, String>
let metadata = if let Some(meta) = json_entry.metadata {
meta.into_iter()
.map(|(k, v)| {
(
k,
match v {
serde_json::Value::String(s) => s,
_ => v.to_string(),
},
)
})
.collect()
} else {
HashMap::new()
};
entries.push(DictEntry {
id: Some(id),
text: json_entry.word,
metadata,
});
}
let entries_len = entries.len();
Ok(Self {
entries,
current_index: 0,
next_id: (entries_len + 1) as u32,
})
}
pub fn new_with_existing_ids<P: AsRef<Path>>(path: P, start_id: u32) -> anyhow::Result<Self> {
let mut source = Self::new(path)?;
source.next_id = start_id;
Ok(source)
}
}
impl DictSource for JsonFileDictSource {
fn next_entry(&mut self) -> Option<Result<DictEntry, anyhow::Error>> {
if self.current_index < self.entries.len() {
let entry = self.entries[self.current_index].clone();
self.current_index += 1;
Some(Ok(entry))
} else {
None
}
}
}

268
lib/src/dictionary/infrastructure/sqlite_dict_repository.rs

@ -0,0 +1,268 @@
use crate::common::errors::RepositoryError;
use crate::dictionary::{Dict, DictEntry, DictRepository, DictRepositoryFactory};
use futures::TryStreamExt;
use futures::stream::BoxStream;
use sqlx::{Row, SqlitePool, sqlite::SqliteConnectOptions};
use std::collections::HashMap;
use std::str::FromStr;
#[derive(sqlx::FromRow)]
struct SqliteEntryDto {
id: i64,
text: String,
metadata: sqlx::types::Json<HashMap<String, String>>,
}
impl From<SqliteEntryDto> for DictEntry {
fn from(dto: SqliteEntryDto) -> Self {
Self {
id: Some(dto.id as u64),
text: dto.text,
metadata: dto.metadata.0,
}
}
}
#[derive(Clone)]
pub struct SqliteDictRepository {
pool: SqlitePool,
dict_name: String,
}
impl SqliteDictRepository {
pub async fn new(database_url: &str) -> Result<Self, RepositoryError> {
let options = SqliteConnectOptions::from_str(database_url)
.map_err(|_| RepositoryError::ConnectionFailed)?
.create_if_missing(true);
let pool = SqlitePool::connect_with(options)
.await
.map_err(|_| RepositoryError::ConnectionFailed)?;
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS dictionaries (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS entries (
id INTEGER PRIMARY KEY,
dictionary_id INTEGER NOT NULL,
text TEXT NOT NULL,
metadata TEXT,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(dictionary_id) REFERENCES dictionaries(id) ON DELETE CASCADE,
UNIQUE(dictionary_id, text)
);
"#,
)
.execute(&pool)
.await
.map_err(|e| RepositoryError::StorageError(e.to_string()))?;
Ok(Self {
pool,
dict_name: "default_dict".into(),
})
}
async fn get_dict_id(&self) -> Result<i64, RepositoryError> {
let row = sqlx::query("SELECT id FROM dictionaries WHERE name = ?")
.bind(&self.dict_name)
.fetch_optional(&self.pool)
.await
.map_err(|e| RepositoryError::StorageError(e.to_string()))?;
match row {
Some(r) => Ok(r.get("id")),
None => Err(RepositoryError::NotFound(self.dict_name.clone())),
}
}
}
#[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)
.execute(&self.pool)
.await
.map_err(|e| RepositoryError::StorageError(e.to_string()))?;
Ok(())
}
async fn fetch_dicts(&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()))?;
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> {
let mut tx = self
.pool
.begin()
.await
.map_err(|_| RepositoryError::ConnectionFailed)?;
// 1. Get Dict ID
let dict_id_row = sqlx::query("SELECT id FROM dictionaries WHERE name = ?")
.bind(&self.dict_name)
.fetch_optional(&mut *tx)
.await
.map_err(|e| RepositoryError::StorageError(e.to_string()))?;
let dict_id: i64 = match dict_id_row {
Some(row) => row.get("id"),
None => return Err(RepositoryError::NotFound(self.dict_name.clone())),
};
// 2. Batch Upsert
for entry in entries {
// We must wrap the HashMap in sqlx::types::Json so SQLx knows how to serialize it
let meta_json = sqlx::types::Json(&entry.metadata);
sqlx::query(
r#"
INSERT INTO entries (dictionary_id, text, metadata)
VALUES (?, ?, ?)
ON CONFLICT(dictionary_id, text) DO UPDATE SET
metadata = excluded.metadata,
updated_at = CURRENT_TIMESTAMP
"#,
)
.bind(dict_id)
.bind(&entry.text)
.bind(meta_json)
.execute(&mut *tx)
.await
.map_err(|e| RepositoryError::StorageError(e.to_string()))?;
}
tx.commit()
.await
.map_err(|e| RepositoryError::StorageError(e.to_string()))?;
Ok(())
}
async fn fetch_many(&self, limit: usize, offset: usize) -> Result<Dict, RepositoryError> {
// Get Dict ID
let dict_id = self.get_dict_id().await?;
// Query (Reading into the DTO)
let dtos = sqlx::query_as::<_, SqliteEntryDto>(
r#"
SELECT id, text, metadata
FROM entries
WHERE dictionary_id = ?
LIMIT ? OFFSET ?
"#,
)
.bind(dict_id)
.bind(limit as u32)
.bind(offset as u32)
.fetch_all(&self.pool)
.await
.map_err(|e| RepositoryError::StorageError(e.to_string()))?;
// 4. Convert DTOs to Domain Dict
let mut entries_map = HashMap::new();
for dto in dtos {
let entry: DictEntry = dto.into(); // Converts DTO -> Entity
// We safely unwrap because the DB guarantees an ID exists
if let Some(id) = entry.id {
entries_map.insert(id, entry);
}
}
Ok(Dict {
name: self.dict_name.clone(),
entries: entries_map,
})
}
async fn stream_batches(
&self,
batch_size: usize,
) -> Result<BoxStream<'_, Result<Vec<String>, RepositoryError>>, RepositoryError> {
// 1. Resolve ID first
let dict_id = self.get_dict_id().await?;
// 2. Create the base query stream.
// We do NOT use limit/offset. We let the DB stream rows via a cursor.
let query_stream = sqlx::query("SELECT text FROM entries WHERE dictionary_id = ?")
.bind(dict_id)
.fetch(&self.pool);
// 3. Transform the stream using Functional combinators
let stream = query_stream
// Map SQLx errors to Domain errors
.map_err(|e| RepositoryError::StorageError(e.to_string()))
// Extract the String from the Row
.and_then(|row| async move {
// 'text' is the column name
let text: String = row
.try_get("text")
.map_err(|e| RepositoryError::StorageError(e.to_string()))?;
Ok(text)
})
// Group items into vectors of size `batch_size`
.try_chunks(batch_size)
// try_chunks returns a specific error type on failure, map it back
.map_err(|e| {
// logic to handle leftover elements if error occurs,
// but for simplicity, we treat stream errors as fatal here
RepositoryError::StorageError(e.to_string())
});
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)
}
}

21
lib/src/lib.rs

@ -0,0 +1,21 @@
pub mod auth;
mod common;
mod dictionary;
pub mod sys_major;
pub use self::common::SystemDecoder;
pub use self::common::SystemEncoder;
pub use self::common::errors::{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};

14
lib/src/sys_major.rs

@ -0,0 +1,14 @@
pub mod decoder;
pub mod encoder;
pub mod lvmap;
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;
pub use self::service::MajorSystemService;
#[cfg(test)]
mod decoder_tests;

122
lib/src/sys_major/decoder.rs

@ -0,0 +1,122 @@
use crate::common::{entities::DecodedValue, errors::CodecError, traits::SystemDecoder};
#[derive(Debug, Default, Clone)]
pub struct Rule {
pub phoneme_in: String,
pub phoneme_out: String,
pub not_before: Vec<String>,
pub not_after: Vec<String>,
pub only_before: Vec<String>,
pub only_after: Vec<String>,
}
impl Rule {
pub fn into_lowercase(self) -> Self {
Rule {
phoneme_in: self.phoneme_in.to_lowercase(),
phoneme_out: self.phoneme_out.to_lowercase(),
not_before: Self::lower_vec(self.not_before),
not_after: Self::lower_vec(self.not_after),
only_before: Self::lower_vec(self.only_before),
only_after: Self::lower_vec(self.only_after),
}
}
fn lower_vec(vec: Vec<String>) -> Vec<String> {
vec.into_iter().map(|s| s.to_lowercase()).collect()
}
}
pub type Rules = Vec<Rule>;
// pub struct rules {
// name: String,
// entries: Rules,
// }
/// (index, decoded value)
type RuleMatches = Vec<(usize, String)>;
pub struct Decoder {
rules: Rules,
}
impl Decoder {
pub fn new(rules: Rules) -> Self {
Decoder {
rules: Decoder::to_lower_rules(rules),
}
}
fn to_lower_rules(rules: Rules) -> Rules {
rules
.into_iter()
.map(|entry| entry.into_lowercase())
.collect()
}
fn match_entry(&self, entry: &Rule, word: &str) -> RuleMatches {
word.match_indices(&entry.phoneme_in)
.filter(|(index, _)| self.is_context_matched(&entry, &word, *index))
.map(|(index, _)| (index, entry.phoneme_out.clone()))
.collect()
}
fn is_context_matched(&self, entry: &Rule, word: &str, index: usize) -> bool {
let before_context = &word[..index];
let after_context = &word[index + entry.phoneme_in.len()..];
// dbg!(&before_context);
// dbg!(&after_context);
if entry
.not_after
.iter()
.any(|prefix| before_context.ends_with(prefix))
{
return false;
}
if entry
.not_before
.iter()
.any(|suffix| after_context.starts_with(suffix))
{
return false;
}
if !entry.only_after.is_empty()
&& entry
.only_after
.iter()
.all(|prefix| !before_context.ends_with(prefix))
{
return false;
}
if !entry.only_before.is_empty()
&& entry
.only_before
.iter()
.all(|suffix| !after_context.starts_with(suffix))
{
return false;
}
true
}
}
impl SystemDecoder for Decoder {
fn decode(&self, word: &str) -> Result<DecodedValue, CodecError> {
let mut matches: RuleMatches = self
.rules
.iter()
.flat_map(|entry| self.match_entry(&entry, &word.to_lowercase()))
.collect();
matches.sort_by_key(|&(pos, _)| pos);
let num_str: String = matches.into_iter().map(|(_, value)| value).collect();
DecodedValue::new(num_str)
}
}

134
lib/src/sys_major/decoder_tests.rs

@ -0,0 +1,134 @@
use super::decoder::{Decoder, Rule, Rules};
use crate::common::traits::SystemDecoder;
#[cfg(test)]
mod tests {
use super::*;
fn create_single_rules() -> Rules {
vec![Rule {
phoneme_in: "B".to_string(),
phoneme_out: "2".to_string(),
not_after: vec!["Y".to_string()],
not_before: vec!["X".to_string()],
only_after: vec!["A".to_string()],
only_before: vec!["C".to_string()],
}]
}
fn create_single_rules_min() -> Rules {
vec![Rule {
phoneme_in: "B".to_string(),
phoneme_out: "2".to_string(),
..Default::default()
}]
}
fn create_double_rules() -> Rules {
vec![
Rule {
phoneme_in: "CD".to_string(),
phoneme_out: "2".to_string(),
not_after: vec!["00".to_string(), "YZ".to_string()],
not_before: vec!["11".to_string(), "WX".to_string()],
only_after: vec!["22".to_string(), "AB".to_string()],
only_before: vec!["33".to_string(), "EF".to_string()],
},
Rule {
phoneme_in: "MN".to_string(),
phoneme_out: "3".to_string(),
..Default::default()
},
]
}
#[test]
fn test_single_symbol_encoding_only_before_only_after_matched() {
let decoder = Decoder::new(create_single_rules());
let output = decoder.decode("ABC").unwrap();
assert_eq!(output, "2")
}
#[test]
fn test_double_symbol_encoding_only_before_only_after_matched() {
let decoder = Decoder::new(create_double_rules());
let output = decoder.decode("ABCDEF").unwrap();
assert_eq!(output, "2")
}
#[test]
fn test_single_symbol_encoding_only_before_not_matched_with_other() {
let decoder = Decoder::new(create_single_rules());
let output = decoder.decode("DBC").unwrap();
assert_eq!(output, "")
}
#[test]
fn test_double_symbol_encoding_only_before_not_matched_with_other() {
let decoder = Decoder::new(create_double_rules());
let output = decoder.decode("AACDEE").unwrap();
assert_eq!(output, "")
}
#[test]
fn test_case_insensitivity() {
let decoder = Decoder::new(create_double_rules());
let output = decoder.decode("abcdef").unwrap();
assert_eq!(output, "2")
}
#[test]
fn test_single_symbol_encoding_only_before_not_matched_with_empty() {
let decoder = Decoder::new(create_single_rules());
let output = decoder.decode("BC").unwrap();
assert_eq!(output, "")
}
#[test]
fn test_single_symbol_encoding_only_before_not_matched_with_not_before() {
let decoder = Decoder::new(create_single_rules());
let output = decoder.decode("XBC").unwrap();
assert_eq!(output, "")
}
#[test]
fn test_single_symbol_encoding_only_after_not_matched_with_other() {
let decoder = Decoder::new(create_single_rules());
let output = decoder.decode("ABD").unwrap();
assert_eq!(output, "")
}
#[test]
fn test_single_symbol_encoding_only_after_not_matched_with_empty() {
let decoder = Decoder::new(create_single_rules());
let output = decoder.decode("AB").unwrap();
assert_eq!(output, "")
}
#[test]
fn test_single_symbol_encoding_only_after_not_matched_with_not_after() {
let decoder = Decoder::new(create_single_rules());
let output = decoder.decode("ABY").unwrap();
assert_eq!(output, "")
}
#[test]
fn test_single_symbol_encoding_empty_before_after_matched_with_empty() {
let decoder = Decoder::new(create_single_rules_min());
let output = decoder.decode("B").unwrap();
assert_eq!(output, "2")
}
#[test]
fn test_single_symbol_encoding_empty_before_after_matched_with_others() {
let decoder = Decoder::new(create_single_rules_min());
let output = decoder.decode("AXBYC").unwrap();
assert_eq!(output, "2")
}
#[test]
fn test_encoding_multiple_phonemes() {
let decoder = Decoder::new(create_double_rules());
let output = decoder.decode("VvmNabCd33mn00CD22cdefmn").unwrap();
assert_eq!(output, "32323")
}
}

179
lib/src/sys_major/encoder.rs

@ -0,0 +1,179 @@
use crate::common::{
entities::{EncodedPart, EncodedSplit, EncodedValue},
errors::CodecError,
traits::*,
};
use super::lvmap::LenValueMap;
#[derive(Debug)]
pub struct Encoder {
lv_map: LenValueMap,
}
impl Encoder {
pub fn new(lv_map: LenValueMap) -> Self {
Encoder { lv_map }
}
}
impl SystemEncoder for Encoder {
fn initialize(&self) -> Result<(), CodecError> {
Ok(())
}
fn encode(&self, input: &str) -> Result<EncodedValue, CodecError> {
let size = input.chars().count();
let max_mask: usize = (1 << (size - 1)) - 1;
let indices: Vec<usize> = input.char_indices().map(|(i, _)| i).collect();
let mut results = Vec::with_capacity(max_mask);
for mask in 0..=max_mask {
let mut parts: Vec<String> = Vec::new();
let mut last_split = input.char_indices().count(); // we go from right to left to start with the longest parts
// Iterate through the mask bits to find where to split
for i in 0..size - 1 {
// Check if the i-th bit is set
if (mask >> i) & 1 == 1 {
// The split corresponds to the byte index of the (i+1)-th character
let split_idx = indices[indices.len() - i - 1];
parts.push(input[split_idx..last_split].to_string());
last_split = split_idx;
}
}
// Push the remaining part of the string
parts.push(input[..last_split].to_string());
let mut all_matched = true;
let mut split = EncodedSplit::new();
parts.reverse();
for part in &parts {
let Ok(num_part) = part.parse::<u64>() else {
all_matched = false;
break;
};
let Some(words) = self.lv_map.get(part.len() as u8, num_part) else {
all_matched = false;
break;
};
split.push(EncodedPart {
value: num_part,
words: words.clone(),
});
}
if all_matched {
results.push(Partition {
value: split,
// To find the "most equal" size, we minimize the sum of squared lengths.
// (This mathematically minimizes variance without needing floating point math)
sum_sq_len: parts.iter().map(|p| p.chars().count().pow(2)).sum(),
});
}
// Calculate metrics for sorting
// let num_parts = parts.len();
// // To find the "most equal" size, we minimize the sum of squared lengths.
// // (This mathematically minimizes variance without needing floating point math)
// let sum_sq_len: usize = parts.iter().map(|p| p.chars().count().pow(2)).sum();
// if let Some(words) = self.lv_map.get(size as u8, input.parse().unwrap()) {
// results.push(Partition {
// parts: words.clone(),
// sum_sq_len,
// });
// }
}
// Ok(EncodedValue::new(words))
// Sort by:
// 1. Fewer parts first (1 part, then 2 parts...)
// 2. Most equal lengths (lower sum of squared lengths is more balanced)
// 3. TODO: Lexicographically (for deterministic stability)?
results.sort_by(|a, b| {
a.value
.len()
.cmp(&b.value.len())
.then(a.sum_sq_len.cmp(&b.sum_sq_len))
});
// Extract just the strings
let split_results = results.into_iter().map(|p| p.value).collect();
Ok(EncodedValue::new(split_results))
}
}
// A helper struct to keep the split variant and its sort metrics together
struct Partition {
value: EncodedSplit,
sum_sq_len: usize,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_encode_as_single_length_result() {
let mut lvmap = LenValueMap::new();
lvmap.push(3, 123, "test_123");
lvmap.push(3, 345, "test_345_1");
lvmap.push(3, 345, "test_345_2");
lvmap.push(3, 678, "test_678");
let encoder = Encoder::new(lvmap);
let result = encoder.encode("345").unwrap();
assert_eq!(result.len(), 1); // single split
assert_eq!(result[0].len(), 1); // single part
assert_eq!(result[0][0].value, 345);
assert_eq!(result[0][0].words.len(), 2); // two words
assert_eq!(result[0][0].words[0], "test_345_1");
assert_eq!(result[0][0].words[1], "test_345_2");
}
#[test]
fn test_encode_as_all_lengths() {
let mut lvmap = LenValueMap::new();
lvmap.push(1, 0, "test_0");
lvmap.push(1, 9, "test_9");
lvmap.push(1, 8, "test_8");
lvmap.push(1, 7, "test_7");
lvmap.push(2, 98, "test_98");
lvmap.push(2, 87, "test_87");
lvmap.push(3, 987, "test_987");
lvmap.push(3, 876, "test_876");
let encoder = Encoder::new(lvmap);
let result = encoder.encode("987").unwrap();
assert_eq!(result.len(), 4); // 987, 98|7, 9|87, 9|8|7
assert_eq!(result[0].len(), 1); // 987
assert_eq!(result[0][0].words.len(), 1);
assert_eq!(result[0][0].words[0], "test_987");
assert_eq!(result[1].len(), 2); // 98|7
assert_eq!(result[1][0].words.len(), 1);
assert_eq!(result[1][0].words[0], "test_98");
assert_eq!(result[1][1].words.len(), 1);
assert_eq!(result[1][1].words[0], "test_7");
assert_eq!(result[2].len(), 2); // 9|87
assert_eq!(result[2][0].words.len(), 1);
assert_eq!(result[2][0].words[0], "test_9");
assert_eq!(result[2][1].words.len(), 1);
assert_eq!(result[2][1].words[0], "test_87");
assert_eq!(result[3].len(), 3); // 9|8|7
assert_eq!(result[3][0].words.len(), 1);
assert_eq!(result[3][0].words[0], "test_9");
assert_eq!(result[3][1].words.len(), 1);
assert_eq!(result[3][1].words[0], "test_8");
assert_eq!(result[3][2].words.len(), 1);
assert_eq!(result[3][2].words[0], "test_7");
}
}

351
lib/src/sys_major/lvmap.rs

@ -0,0 +1,351 @@
use crate::common::{
SystemDecoder,
entities::DecodedLength,
errors::{CodecError, RepositoryError},
};
use futures::{Stream, StreamExt};
use std::{collections::HashMap, num::ParseIntError};
use thiserror::Error;
// We store words by encoded number length, then encoded value
// Example:
// root:
// - 3:
// - 750:
// - word: klasa
// - word: gilza
// - 849:
// - word: farba
// - 2:
// - 45:
// - word: oral
#[derive(Error, Debug)]
pub enum LenValueMapError {
#[error("value parsing error: {0}")]
Parse(#[from] ParseIntError),
#[error(transparent)]
Codec(#[from] CodecError),
#[error(transparent)]
Repository(#[from] RepositoryError),
#[error("unable to build encoder data: {0}")]
Build(String),
}
type DecodedNumber = u64;
pub type LenValueData = HashMap<DecodedLength, HashMap<DecodedNumber, Vec<String>>>;
#[derive(Debug, Default, Clone)]
pub struct LenValueMap {
data: LenValueData,
}
impl LenValueMap {
pub fn new() -> Self {
Self::default()
}
pub fn is_empty(&self) -> bool {
self.data.is_empty()
}
pub fn into_data(self) -> LenValueData {
self.data
}
pub fn push(&mut self, len: u8, num: DecodedNumber, word: impl Into<String>) -> &mut Self {
self.data
.entry(DecodedLength::from(len))
.or_insert_with(HashMap::new)
.entry(num)
.or_insert_with(Vec::new)
.push(word.into());
self
}
pub fn get(&self, len: u8, num: DecodedNumber) -> Option<&Vec<String>> {
self.data.get(&DecodedLength::from(len))?.get(&num)
}
pub fn insert_words<I>(
&mut self,
words: I,
decoder: &dyn SystemDecoder,
) -> Result<(), LenValueMapError>
where
I: IntoIterator<Item = String>,
{
for word in words {
if word.is_empty() {
continue;
}
let decoded = decoder.decode(&word)?;
if decoded.is_empty() {
continue;
}
self.data
.entry(decoded.value_len()?)
.or_default()
.entry(decoded.parse()?)
.or_default()
.push(word);
}
Ok(())
}
pub fn from_data(data: LenValueData) -> Self {
Self { data: data }
}
pub async fn from_stream<S>(
stream: S,
decoder: &dyn SystemDecoder,
) -> Result<Self, LenValueMapError>
where
// S is a stream of "Result<Vec<String>, Error>"
S: Stream<Item = Result<Vec<String>, RepositoryError>>,
{
let mut map = LenValueMap::new();
let mut stream = Box::pin(stream);
// We stream the batches one by one.
// This ensures only one batch is in memory at a time.
while let Some(batch_result) = stream.next().await {
match batch_result {
Ok(batch) => {
// We delegate to the synchronous logic for the heavy lifting
map.insert_words(batch, decoder)?;
}
Err(e) => {
// Convert RepositoryError to LenValueMapError::Build
return Err(e.into());
}
}
}
Ok(map)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::common::{entities::*, errors::*};
use futures::stream;
use std::collections::HashMap;
use mockall::{mock, predicate::*};
const TEST_WORD_1: &str = "test_word_1";
const TEST_WORD_2: &str = "test_word_2";
const TEST_WORD_3: &str = "test_word_3";
const TEST_WORD_4: &str = "test_word_4";
const TEST_NUM_1: u64 = 12;
const TEST_NUM_2: u64 = 34;
const TEST_NUM_3: u64 = 9876;
const TEST_NUM_1_LEN: DecodedLength = DecodedLength::from(2);
const TEST_NUM_3_LEN: DecodedLength = DecodedLength::from(4);
fn decoded_value(n: u64) -> DecodedValue {
DecodedValue::new(n.to_string()).unwrap()
}
fn get_test_dec_map() -> HashMap<String, DecodedValue> {
HashMap::from([
(TEST_WORD_1.to_string(), decoded_value(TEST_NUM_1)),
(TEST_WORD_2.to_string(), decoded_value(TEST_NUM_2)),
(TEST_WORD_3.to_string(), decoded_value(TEST_NUM_3)),
(TEST_WORD_4.to_string(), decoded_value(TEST_NUM_3)),
])
}
fn mock_decoding(word: &str) -> Result<DecodedValue, CodecError> {
get_test_dec_map()
.remove(word)
.ok_or_else(|| CodecError::UnexpectedError("".to_string()))
}
fn get_test_words() -> Vec<String> {
vec![
TEST_WORD_1.to_string(),
TEST_WORD_2.to_string(),
TEST_WORD_3.to_string(),
TEST_WORD_4.to_string(),
]
}
mock! {
pub Decoder {}
impl SystemDecoder for Decoder {
fn decode(&self, word: &str) -> Result<DecodedValue, CodecError>;
}
}
#[test]
fn test_single_word() {
let words = vec![TEST_WORD_1.to_string()];
let mut decoder = MockDecoder::new();
decoder
.expect_decode()
.returning(|word| mock_decoding(word));
let mut lv_map = LenValueMap::new();
lv_map.insert_words(words, &decoder).unwrap();
let data = lv_map.into_data();
assert_eq!(data.len(), 1);
assert!(data.contains_key(&TEST_NUM_1_LEN));
let data = data.get(&TEST_NUM_1_LEN).unwrap();
assert!(data.contains_key(&TEST_NUM_1));
let words = data.get(&TEST_NUM_1).unwrap();
assert_eq!(words.len(), 1);
assert_eq!(words[0], TEST_WORD_1);
}
#[test]
fn test_multiple_words() {
let words = get_test_words();
let mut decoder = MockDecoder::new();
decoder
.expect_decode()
.returning(|word| mock_decoding(word));
let mut lv_map = LenValueMap::new();
lv_map.insert_words(words, &decoder).unwrap();
let data = lv_map.into_data();
assert_eq!(data.len(), 2); // two different lengths
assert!(data.contains_key(&TEST_NUM_1_LEN));
assert!(data.contains_key(&TEST_NUM_3_LEN));
let l2 = data.get(&TEST_NUM_1_LEN).unwrap();
let l4 = data.get(&TEST_NUM_3_LEN).unwrap();
assert_eq!(l2.len(), 2); // two numbers
assert_eq!(l4.len(), 1); // one number
assert!(l2.contains_key(&TEST_NUM_1));
assert!(l2.contains_key(&TEST_NUM_2));
assert!(l4.contains_key(&TEST_NUM_3));
let words = l2.get(&TEST_NUM_1).unwrap();
assert_eq!(words.len(), 1);
assert_eq!(words[0], TEST_WORD_1);
let words = l2.get(&TEST_NUM_2).unwrap();
assert_eq!(words.len(), 1);
assert_eq!(words[0], TEST_WORD_2);
let words = l4.get(&TEST_NUM_3).unwrap();
assert_eq!(words.len(), 2);
assert!(words.contains(&TEST_WORD_3.to_string()));
assert!(words.contains(&TEST_WORD_4.to_string()));
}
#[test]
fn test_skip_empty_decodes() {
let words = vec![TEST_WORD_1.to_string(), TEST_WORD_2.to_string()];
let mut decoder = MockDecoder::new();
decoder.expect_decode().returning(|word| {
if word == TEST_WORD_1 {
DecodedValue::new("".to_string())
} else {
DecodedValue::new(TEST_NUM_2.to_string())
}
});
let mut lv_map = LenValueMap::new();
lv_map.insert_words(words, &decoder).unwrap();
let data = lv_map.into_data();
assert_eq!(data.len(), 1);
assert!(data.contains_key(&TEST_NUM_1_LEN));
let data = data.get(&TEST_NUM_1_LEN).unwrap();
assert!(data.contains_key(&TEST_NUM_2));
let words = data.get(&TEST_NUM_2).unwrap();
assert_eq!(words.len(), 1);
assert_eq!(words[0], TEST_WORD_2);
}
#[test]
fn test_decoder_error_propagates() {
let mut decoder = MockDecoder::new();
decoder
.expect_decode()
.returning(|_| Err(CodecError::UnexpectedError("boom".into())));
let mut map = LenValueMap::new();
let result = map.insert_words(vec!["x".into()], &decoder);
assert!(result.is_err());
}
// --- build ---
#[tokio::test]
async fn test_from_stream_success() {
// 1. Setup Mocks (Same as before)
let mut decoder = MockDecoder::new();
decoder
.expect_decode()
.returning(|word| mock_decoding(word));
// 2. Prepare Data
// We wrap the inner Vecs in Ok() because the stream expects Result<Vec<String>, RepositoryError>
let batches = vec![
Ok(vec![TEST_WORD_1.into(), TEST_WORD_2.into()]),
Ok(vec![TEST_WORD_3.into(), TEST_WORD_4.into()]),
];
// 3. Create a Stream from the Vec
// stream::iter converts an IntoIterator into a Stream
let stream = stream::iter(batches);
// 4. Inject the stream (Dependency Injection)
let map = LenValueMap::from_stream(stream, &decoder)
.await
.expect("Should build map successfully");
// 5. Assertions
let data = map.into_data();
assert_eq!(data.len(), 2);
assert!(data.contains_key(&TEST_NUM_1_LEN));
assert!(data.contains_key(&TEST_NUM_3_LEN));
}
#[tokio::test]
async fn test_from_stream_failure() {
let mut decoder = MockDecoder::new();
decoder
.expect_decode()
.returning(|word| mock_decoding(word));
let batches = vec![
Ok(vec![TEST_WORD_1.into()]),
Err(RepositoryError::ConnectionFailed),
Ok(vec![TEST_WORD_3.into()]),
];
let stream = stream::iter(batches);
let result = LenValueMap::from_stream(stream, &decoder).await;
match result {
// We match specifically on the Repository variant and the ConnectionFailed inner error
Err(LenValueMapError::Repository(RepositoryError::ConnectionFailed)) => {
// Success! The correct error type propagated up.
}
// If it's any other error (including a stringified one), we fail
_ => panic!(
"Expected LenValueMapError::Repository(ConnectionFailed), got {:?}",
result
),
}
}
}

15
lib/src/sys_major/rules_en.rs

@ -0,0 +1,15 @@
use super::decoder::{Rule, Rules};
pub fn get_rules() -> Rules {
vec![
Rule {
phoneme_in: "EN".to_string(),
phoneme_out: "2".to_string(),
not_after: vec!["Y".to_string()],
not_before: vec!["X".to_string()],
only_after: vec!["A".to_string()],
only_before: vec!["C".to_string()],
},
// ...more entries...
]
}

222
lib/src/sys_major/rules_pl.rs

@ -0,0 +1,222 @@
use super::decoder::{Rule, Rules};
pub fn get_rules() -> Rules {
vec![
Rule {
not_after: vec![],
only_after: vec![],
phoneme_in: "S".to_string(),
phoneme_out: "0".to_string(),
not_before: vec!["I".to_string(), "Z".to_string()],
only_before: vec![],
},
Rule {
not_after: vec![
"C".to_string(),
"D".to_string(),
"R".to_string(),
"S".to_string(),
],
only_after: vec![],
phoneme_in: "Z".to_string(),
phoneme_out: "0".to_string(),
not_before: vec!["I".to_string()],
only_before: vec![],
},
Rule {
not_after: vec![],
only_after: vec![],
phoneme_in: "T".to_string(),
phoneme_out: "1".to_string(),
not_before: vec![],
only_before: vec![],
},
Rule {
only_after: vec![],
not_after: vec![],
phoneme_in: "D".to_string(),
phoneme_out: "1".to_string(),
not_before: vec!["Z".to_string(), "Ź".to_string(), "Ż".to_string()],
only_before: vec![],
},
Rule {
not_after: vec![],
only_after: vec![],
phoneme_in: "N".to_string(),
phoneme_out: "2".to_string(),
not_before: vec!["I".to_string()],
only_before: vec![],
},
Rule {
not_after: vec![],
only_after: vec![],
phoneme_in: "M".to_string(),
phoneme_out: "3".to_string(),
not_before: vec![],
only_before: vec![],
},
Rule {
not_after: vec![],
only_after: vec![],
phoneme_in: "R".to_string(),
phoneme_out: "4".to_string(),
not_before: vec!["Z".to_string()],
only_before: vec![],
},
Rule {
not_after: vec![],
only_after: vec![],
phoneme_in: "L".to_string(),
phoneme_out: "5".to_string(),
not_before: vec![],
only_before: vec![],
},
Rule {
not_after: vec![],
only_after: vec![],
phoneme_in: "J".to_string(),
phoneme_out: "6".to_string(),
not_before: vec![],
only_before: vec![],
},
Rule {
not_after: vec![],
only_after: vec![],
phoneme_in: "K".to_string(),
phoneme_out: "7".to_string(),
not_before: vec![],
only_before: vec![],
},
Rule {
not_after: vec![],
only_after: vec![],
phoneme_in: "G".to_string(),
phoneme_out: "7".to_string(),
not_before: vec![],
only_before: vec![],
},
Rule {
not_after: vec![],
only_after: vec![],
phoneme_in: "F".to_string(),
phoneme_out: "8".to_string(),
not_before: vec![],
only_before: vec![],
},
Rule {
not_after: vec![],
only_after: vec![],
phoneme_in: "W".to_string(),
phoneme_out: "8".to_string(),
not_before: vec![],
only_before: vec![],
},
Rule {
not_after: vec![],
only_after: vec![],
phoneme_in: "P".to_string(),
phoneme_out: "9".to_string(),
not_before: vec![],
only_before: vec![],
},
Rule {
not_after: vec![],
only_after: vec![],
phoneme_in: "B".to_string(),
phoneme_out: "9".to_string(),
not_before: vec![],
only_before: vec![],
},
]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::SystemDecoder;
use crate::sys_major::Decoder;
#[test]
fn test_major_dict_pl_decode_0_1() {
let decoder = Decoder::new(get_rules());
let output = decoder.decode("SZSCZ").unwrap();
assert_eq!(output, "0")
}
#[test]
fn test_major_dict_pl_decode_0_2() {
let decoder = Decoder::new(get_rules());
let output = decoder.decode("SZSICZ").unwrap();
assert_eq!(output, "")
}
#[test]
fn test_major_dict_pl_decode_0_3() {
let decoder = Decoder::new(get_rules());
let output = decoder.decode("SZCZRZZCZDZSZ").unwrap();
assert_eq!(output, "0")
}
#[test]
fn test_major_dict_pl_decode_0_4() {
let decoder = Decoder::new(get_rules());
let output = decoder.decode("SZCZRZZICZDZSZ").unwrap();
assert_eq!(output, "")
}
#[test]
fn test_major_dict_pl_decode_1_1() {
let decoder = Decoder::new(get_rules());
let output = decoder.decode("SZTCZ").unwrap();
assert_eq!(output, "1")
}
#[test]
fn test_major_dict_pl_decode_1_2() {
let decoder = Decoder::new(get_rules());
let output = decoder.decode("DZDŻDŹDDZDŻDŹ").unwrap();
assert_eq!(output, "1")
}
#[test]
fn test_major_dict_pl_decode_1_3() {
let decoder = Decoder::new(get_rules());
let output = decoder.decode("DZDŻDŹDZDZDŻDŹ").unwrap();
assert_eq!(output, "")
}
#[test]
fn test_major_dict_pl_decode_2_1() {
let decoder = Decoder::new(get_rules());
let output = decoder.decode("NINNI").unwrap();
assert_eq!(output, "2")
}
#[test]
fn test_major_dict_pl_decode_2_2() {
let decoder = Decoder::new(get_rules());
let output = decoder.decode("NININI").unwrap();
assert_eq!(output, "")
}
#[test]
fn test_major_dict_pl_decode_4_1() {
let decoder = Decoder::new(get_rules());
let output = decoder.decode("RZRRZ").unwrap();
assert_eq!(output, "4")
}
#[test]
fn test_major_dict_pl_decode_4_2() {
let decoder = Decoder::new(get_rules());
let output = decoder.decode("RZRZRZ").unwrap();
assert_eq!(output, "")
}
#[test]
fn test_major_dict_pl_decode_full_1() {
let decoder = Decoder::new(get_rules());
let output = decoder.decode("ATADANAMARALAJAKAGAFAWAPABA").unwrap();
assert_eq!(output, "1123456778899")
}
}

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)
}
}

8612
resources/dsr1ll70_pl_demo.json

File diff suppressed because it is too large Load Diff

12
resources/example_dict.json

@ -0,0 +1,12 @@
[
{"word": "hello", "metadata": {"type": "greeting", "language": "english"}},
{"word": "world", "metadata": {"type": "noun", "language": "english"}},
{"word": "rust", "metadata": {"type": "programming_language", "paradigm": "systems"}},
{"word": "programming", "metadata": {"type": "verb", "context": "computing"}},
{"word": "database", "metadata": {"type": "noun", "context": "data_storage"}},
{"word": "sqlite", "metadata": {"type": "database_engine", "features": ["embedded", "sql"]}},
{"word": "json", "metadata": {"type": "data_format", "standard": "RFC 8259"}},
{"word": "import", "metadata": {"type": "verb", "context": "data_operations"}},
{"word": "dictionary", "metadata": {"type": "noun", "context": "reference"}},
{"word": "example", "metadata": {"type": "noun", "usage": "demonstration"}}
]

15
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"

3
tavern-tests/requirements.txt

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

20
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

19
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 <test plan>"
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

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

29
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

30
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

44
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

6
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}"

20
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
Loading…
Cancel
Save