Compare commits

..

28 Commits

Author SHA1 Message Date
chodak166 a1d5d6977a WIP: refactor 8 months ago
chodak166 7aa92ae6c1 WIP: refactor 8 months ago
chodak166 da43970a46 WIP: refactor 8 months ago
chodak166 239e26b08c WIP: refactor 9 months ago
chodak166 b37c0d4e66 WIP: refactor 9 months ago
chodak166 c8c6cfd57f WIP: refactor 9 months ago
chodak166 fa25f6c927 WIP: refactor 9 months ago
chodak166 307971e600 WIP: refactor 9 months ago
chodak166 21f3b09591 WIP: refactor 9 months ago
chodak166 d6a475a0ca WIP: encoder 9 months ago
chodak166 c5466baebb WIP: encoder 9 months ago
chodak166 dd1b3ddc99 WIP: encoder 9 months ago
chodak166 7e19771353 WIP: lv_map refactor & tests 9 months ago
chodak166 6189b4318b WIP: lv_map refactor & tests 9 months ago
chodak166 05be6054a2 WIP: lv_map tests 9 months ago
chodak166 c67bc5b2e9 WIP: lvmap + encoder 9 months ago
chodak166 c281ea2b2f Enc -> Dec rename 9 months ago
chodak166 b86a375b1c Added PL major system rules 9 months ago
chodak166 0be1288638 WIP: import/export 10 months ago
chodak166 091c317266 WIP: import/export 10 months ago
chodak166 6d481552d7 WIP: import/export 10 months ago
chodak166 19e80cb072 Added template app bootstrap modules with cli commands 10 months ago
chodak166 150020df89 Added template app bootstrap modules 10 months ago
chodak166 301eb8e3a0 WIP: app structure 10 months ago
chodak166 b7110d8d85 Added major system implementation and tests 10 months ago
chodak166 ff94981dde WIP project structure and encoder first impl 10 months ago
chodak166 03cd46441c WIP project structure 10 months ago
chodak166 93ea6c305d WIP project structure 10 months ago
  1. 2
      .devcontainer/Dockerfile
  2. 3
      Cargo.toml
  3. 152
      README.md
  4. 4
      app/Cargo.toml
  5. 0
      app/src/app.rs
  6. 5
      app/src/commands.rs
  7. 0
      app/src/commands/decode.rs
  8. 0
      app/src/commands/encode.rs
  9. 0
      app/src/commands/import_dict.rs
  10. 3
      app/src/config.rs
  11. 0
      app/src/container.rs
  12. 0
      app/src/main.rs
  13. 29
      apps/app_api/Cargo.toml
  14. 13
      apps/app_api/config.toml.example
  15. 9
      apps/app_api/src/api.rs
  16. 75
      apps/app_api/src/api/v1.rs
  17. 40
      apps/app_api/src/api/v1/auth.rs
  18. 47
      apps/app_api/src/api/v1/dictionary.rs
  19. 46
      apps/app_api/src/api/v1/info.rs
  20. 84
      apps/app_api/src/api/v1/major.rs
  21. 37
      apps/app_api/src/app.rs
  22. 69
      apps/app_api/src/commands.rs
  23. 113
      apps/app_api/src/commands/listen.rs
  24. 48
      apps/app_api/src/config.rs
  25. 18
      apps/app_api/src/config/auth.rs
  26. 14
      apps/app_api/src/config/database.rs
  27. 52
      apps/app_api/src/dependencies.rs
  28. 100
      apps/app_api/src/error.rs
  29. 18
      apps/app_api/src/main.rs
  30. 26
      apps/app_api/src/router.rs
  31. 21
      apps/app_api/src/state.rs
  32. 77
      apps/app_cli/src/commands/list_dicts.rs
  33. 17
      config.toml
  34. 3
      lib/Cargo.toml
  35. 13
      lib/src/auth.rs
  36. 72
      lib/src/auth/domain.rs
  37. 47
      lib/src/auth/infrastructure/api_token.rs
  38. 288
      lib/src/auth/infrastructure/jwt.rs
  39. 58
      lib/src/auth/infrastructure/store.rs
  40. 59
      lib/src/auth/service.rs
  41. 15
      lib/src/auth/traits.rs
  42. 1
      lib/src/common.rs
  43. 42
      lib/src/common/entities.rs
  44. 54
      lib/src/common/errors.rs
  45. 32
      lib/src/common/traits.rs
  46. 74
      lib/src/dictionary.rs
  47. 2
      lib/src/dictionary/dict_importer.rs
  48. 4
      lib/src/dictionary/infrastructure/json_file_dict_source.rs
  49. 65
      lib/src/dictionary/infrastructure/sqlite_dict_repository.rs
  50. 66
      lib/src/dictionary/service.rs
  51. 23
      lib/src/lib.rs
  52. 8
      lib/src/sys_major.rs
  53. 4
      lib/src/sys_major/rules_pl.rs
  54. 36
      lib/src/sys_major/service.rs
  55. 15
      tavern-tests/export.sh
  56. 3
      tavern-tests/requirements.txt
  57. 20
      tavern-tests/tavern-run-all.sh
  58. 19
      tavern-tests/tavern-run-single.sh
  59. 29
      tavern-tests/test_plans/common_stages.yaml
  60. 29
      tavern-tests/test_plans/decode_test.tavern.yaml
  61. 30
      tavern-tests/test_plans/dictionary_test.tavern.yaml
  62. 44
      tavern-tests/test_plans/encode_test.tavern.yaml
  63. 6
      tavern-tests/test_plans/includes.yaml
  64. 20
      tavern-tests/test_plans/version_test.tavern.yaml

2
.devcontainer/Dockerfile

@ -1,4 +1,4 @@
FROM rust:1.94.1-trixie
FROM rust:1.90.0
# Install basic development tools
RUN apt-get update && apt-get install -y \

3
Cargo.toml

@ -1,7 +1,6 @@
[workspace]
members = [
"apps/app_api",
"apps/app_cli",
"app",
"lib",
]
resolver = "3"

152
README.md

@ -1,152 +1,2 @@
# Phomnemic
# mnemo-r
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

4
apps/app_cli/Cargo.toml → app/Cargo.toml

@ -1,11 +1,11 @@
[package]
name = "phomnemic-cli"
name = "phomnemic"
version = "0.1.0"
edition = "2024"
[dependencies]
# Internal
applib = { path = "../../lib" }
applib = { path = "../lib" }
# Runtime & Async

0
apps/app_cli/src/app.rs → app/src/app.rs

5
apps/app_cli/src/commands.rs → app/src/commands.rs

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

0
apps/app_cli/src/commands/decode.rs → app/src/commands/decode.rs

0
apps/app_cli/src/commands/encode.rs → app/src/commands/encode.rs

0
apps/app_cli/src/commands/import_dict.rs → app/src/commands/import_dict.rs

3
apps/app_cli/src/config.rs → app/src/config.rs

@ -1,4 +1,5 @@
use crate::commands::*;
// use crate::commands::{Configurable, GlobalArgs};
use anyhow::{Context, Result};
use config::{Config, Environment, File};
use serde::Deserialize;
@ -11,8 +12,6 @@ pub struct AppConfig {
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,
}

0
apps/app_cli/src/container.rs → app/src/container.rs

0
apps/app_cli/src/main.rs → app/src/main.rs

29
apps/app_api/Cargo.toml

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -1,77 +0,0 @@
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");
}

17
config.toml

@ -1,13 +1,6 @@
log_level = "trace"
# Logging configuration
log_level = "info"
[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"
# Server configuration
[server]
port = 8080

3
lib/Cargo.toml

@ -19,9 +19,6 @@ 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

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

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

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

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

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

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

@ -1,15 +0,0 @@
use crate::auth::domain::AuthClaims;
use crate::common::errors::AuthError;
#[async_trait::async_trait]
pub trait Authenticator: Send + Sync {
async fn authenticate(&self, token: &str) -> Result<AuthClaims, AuthError>;
}
#[async_trait::async_trait]
pub trait TokenStore: Send + Sync {
async fn store_token(&self, token: &str, claims: &AuthClaims) -> Result<(), AuthError>;
async fn get_token(&self, token: &str) -> Result<Option<AuthClaims>, AuthError>;
async fn revoke_token(&self, token: &str) -> Result<(), AuthError>;
async fn is_revoked(&self, token: &str) -> Result<bool, AuthError>;
}

1
lib/src/common.rs

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

42
lib/src/common/entities.rs

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

54
lib/src/common/errors.rs

@ -5,7 +5,7 @@ pub enum RepositoryError {
#[error("Data source connection failed")]
ConnectionFailed,
#[error("\'{0}\' not found")]
#[error("'{0}' not found")]
NotFound(String),
#[error("Storage error: {0}")]
@ -29,55 +29,3 @@ pub enum CodecError {
#[error("unexpected error: {0}")]
UnexpectedError(String),
}
#[derive(Debug, Error)]
pub enum ServiceError {
#[error("Repository error: {0}")]
Repository(#[from] RepositoryError),
#[error("Codec error: {0}")]
Codec(#[from] CodecError),
#[error("Dictionary not found: {0}")]
DictionaryNotFound(String),
#[error("Invalid input: {0}")]
InvalidInput(String),
#[error("Service unavailable: {0}")]
Unavailable(String),
}
impl From<crate::sys_major::lvmap::LenValueMapError> for ServiceError {
fn from(err: crate::sys_major::lvmap::LenValueMapError) -> Self {
match err {
crate::sys_major::lvmap::LenValueMapError::Codec(e) => ServiceError::Codec(e),
crate::sys_major::lvmap::LenValueMapError::Repository(e) => ServiceError::Repository(e),
crate::sys_major::lvmap::LenValueMapError::Build(e) => ServiceError::Unavailable(e),
crate::sys_major::lvmap::LenValueMapError::Parse(e) => {
ServiceError::InvalidInput(e.to_string())
}
}
}
}
#[derive(Debug, Error)]
pub enum AuthError {
#[error("Invalid token")]
InvalidToken,
#[error("Token expired")]
TokenExpired,
#[error("Invalid credentials")]
InvalidCredentials,
#[error("Unauthorized access")]
Unauthorized,
#[error("Authentication failed: {0}")]
AuthenticationFailed(String),
#[error("Token store error: {0}")]
StoreError(String),
}

32
lib/src/common/traits.rs

@ -1,6 +1,8 @@
use futures::stream::BoxStream;
use crate::common::{
entities::{DecodedValue, EncodedValue},
errors::CodecError,
entities::{DecodedValue, Dict, DictEntry, EncodedValue},
errors::{CodecError, RepositoryError},
};
pub trait SystemDecoder: Send + Sync {
@ -11,3 +13,29 @@ pub trait SystemEncoder: Send + Sync {
fn initialize(&self) -> Result<(), CodecError>;
fn encode(&self, word: &str) -> Result<EncodedValue, CodecError>;
}
#[async_trait::async_trait]
pub trait DictRepository: Send + Sync {
fn use_dict(&mut self, name: &str);
async fn create_dict(&self) -> Result<(), RepositoryError>;
/// "Upsert" logic:
/// - If entry exists (by text), update metadata.
/// - If not, insert new.
/// - IDs are handled by the Database.
async fn save_entries(&self, entries: &[DictEntry]) -> Result<(), RepositoryError>;
/// Fetch a page of entries.
async fn fetch_many(&self, limit: usize, offset: usize) -> Result<Dict, RepositoryError>;
/// Returns a cold stream that fetches strings in chunks.
/// The stream yields `Result<Vec<String>, RepositoryError>`.
async fn stream_batches(
&self,
batch_size: usize,
) -> Result<BoxStream<'_, Result<Vec<String>, RepositoryError>>, RepositoryError>;
}
pub trait DictSource {
fn next_entry(&mut self) -> Option<Result<DictEntry, anyhow::Error>>;
}

74
lib/src/dictionary.rs

@ -1,80 +1,6 @@
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>>;
}

2
lib/src/dictionary/dict_importer.rs

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

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

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

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

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

@ -1,66 +0,0 @@
use crate::common::errors::ServiceError;
use crate::dictionary::{Dict, DictEntry, DictRepositoryFactory};
use std::sync::Arc;
#[derive(Debug, Clone)]
pub struct DictionarySummary {
pub name: String,
pub entry_count: u64,
}
pub struct DictionaryService {
repo_factory: Arc<dyn DictRepositoryFactory>,
}
impl DictionaryService {
pub fn new(repo_factory: Arc<dyn DictRepositoryFactory>) -> Self {
Self { repo_factory }
}
pub async fn list_dictionaries(&self) -> Result<Vec<DictionarySummary>, ServiceError> {
let dict_names = self.repo_factory.list_all().await?;
let mut summaries = Vec::with_capacity(dict_names.len());
for dict_name in dict_names {
let repo = self.repo_factory.create(&dict_name).await?;
let entry_count = repo.count_entries().await?;
summaries.push(DictionarySummary {
name: dict_name,
entry_count,
});
}
Ok(summaries)
}
pub async fn get_dictionary(
&self,
name: &str,
limit: usize,
offset: usize,
) -> Result<Dict, ServiceError> {
let repo = self.repo_factory.create(name).await?;
repo.fetch_many(limit, offset).await.map_err(Into::into)
}
pub async fn get_entry_count(&self, name: &str) -> Result<u64, ServiceError> {
let repo = self.repo_factory.create(name).await?;
repo.count_entries().await.map_err(Into::into)
}
pub async fn create_dictionary(&self, name: &str) -> Result<(), ServiceError> {
let repo = self.repo_factory.create(name).await?;
repo.create_dict().await.map_err(Into::into)
}
pub async fn save_entries(
&self,
name: &str,
entries: &[DictEntry],
) -> Result<(), ServiceError> {
let repo = self.repo_factory.create(name).await?;
repo.save_entries(entries).await.map_err(Into::into)
}
}

23
lib/src/lib.rs

@ -1,21 +1,20 @@
pub mod auth;
// pub mod application;
// pub mod core;
// pub mod infrastructure;
// pub mod presentation;
// pub use self::application::config;
// pub use self::application::services::DictImporter;
// pub use self::core::system;
// pub use self::core::traits;
mod common;
mod dictionary;
pub mod sys_major;
pub use self::common::DictRepository;
pub use self::common::SystemDecoder;
pub use self::common::SystemEncoder;
pub use self::common::errors::{AuthError, RepositoryError, ServiceError};
pub use self::dictionary::DictImporter;
pub use self::dictionary::DictRepository;
pub use self::dictionary::DictRepositoryFactory;
pub use self::dictionary::JsonFileDictSource;
pub use self::dictionary::SqliteDictRepository;
pub use self::dictionary::service::{DictionaryService, DictionarySummary};
pub use self::sys_major::MajorSystemService;
pub use self::auth::infrastructure::{
api_token::ApiTokenAuthenticator, jwt::JwtAuthenticator, store::InMemoryTokenStore,
};
pub use self::auth::{AuthClaims, AuthService, Authenticator, TokenStore, User};

8
lib/src/sys_major.rs

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

4
lib/src/sys_major/rules_pl.rs

@ -133,8 +133,8 @@ pub fn get_rules() -> Rules {
#[cfg(test)]
mod tests {
use super::*;
use crate::SystemDecoder;
use crate::sys_major::Decoder;
use crate::core::sys_major::Decoder;
use crate::traits::SystemDecoder;
#[test]
fn test_major_dict_pl_decode_0_1() {

36
lib/src/sys_major/service.rs

@ -1,36 +0,0 @@
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)
}
}

15
tavern-tests/export.sh

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

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

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

@ -1,20 +0,0 @@
#!/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

@ -1,19 +0,0 @@
#!/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

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

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

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

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

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

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