Compare commits
5 Commits
develop-re
...
master
| Author | SHA1 | Date |
|---|---|---|
|
|
059a54bcfb | 1 month ago |
|
|
b44526f5cb | 1 month ago |
|
|
623ea2edf2 | 4 months ago |
|
|
dbf77e9876 | 4 months ago |
|
|
596463ff1f | 4 months ago |
73 changed files with 13038 additions and 1 deletions
@ -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 |
||||||
@ -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" |
||||||
|
} |
||||||
@ -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) |
||||||
@ -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 |
||||||
|
|||||||
@ -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"] } |
||||||
@ -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" |
||||||
@ -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)) |
||||||
|
} |
||||||
@ -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() |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -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)) |
||||||
|
} |
||||||
@ -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)) |
||||||
|
} |
||||||
@ -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)) |
||||||
|
} |
||||||
@ -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)) |
||||||
|
} |
||||||
@ -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 |
||||||
|
} |
||||||
|
} |
||||||
@ -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); |
||||||
|
} |
||||||
@ -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); |
||||||
|
} |
||||||
@ -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") |
||||||
|
} |
||||||
|
} |
||||||
@ -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(), |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -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(), |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -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, |
||||||
|
}) |
||||||
|
} |
||||||
|
} |
||||||
@ -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()), |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -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(()) |
||||||
|
} |
||||||
@ -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())) |
||||||
|
} |
||||||
@ -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, |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -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" |
||||||
@ -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 |
||||||
|
} |
||||||
|
} |
||||||
@ -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); |
||||||
|
} |
||||||
@ -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"); |
||||||
|
} |
||||||
@ -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); |
||||||
|
} |
||||||
@ -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"); |
||||||
|
} |
||||||
@ -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"); |
||||||
|
} |
||||||
@ -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), |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -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)) |
||||||
|
} |
||||||
|
} |
||||||
@ -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(()) |
||||||
|
} |
||||||
@ -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" |
||||||
@ -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" |
||||||
@ -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}; |
||||||
@ -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 |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -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) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -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 |
||||||
|
} |
||||||
|
} |
||||||
@ -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)) |
||||||
|
} |
||||||
|
} |
||||||
@ -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 |
||||||
|
} |
||||||
|
} |
||||||
@ -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>; |
||||||
|
} |
||||||
@ -0,0 +1,6 @@ |
|||||||
|
pub mod entities; |
||||||
|
pub mod errors; |
||||||
|
pub mod traits; |
||||||
|
|
||||||
|
pub use self::traits::SystemDecoder; |
||||||
|
pub use self::traits::SystemEncoder; |
||||||
@ -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)) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -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), |
||||||
|
} |
||||||
@ -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>; |
||||||
|
} |
||||||
@ -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>>; |
||||||
|
} |
||||||
@ -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(()) |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,2 @@ |
|||||||
|
pub mod json_file_dict_source; |
||||||
|
pub mod sqlite_dict_repository; |
||||||
@ -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 |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -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()) |
||||||
|
} |
||||||
|
} |
||||||
@ -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) |
||||||
|
} |
||||||
|
} |
||||||
@ -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}; |
||||||
@ -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; |
||||||
@ -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) |
||||||
|
} |
||||||
|
} |
||||||
@ -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") |
||||||
|
} |
||||||
|
} |
||||||
@ -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"); |
||||||
|
} |
||||||
|
} |
||||||
@ -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 |
||||||
|
), |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -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...
|
||||||
|
] |
||||||
|
} |
||||||
@ -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") |
||||||
|
} |
||||||
|
} |
||||||
@ -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) |
||||||
|
} |
||||||
|
} |
||||||
File diff suppressed because it is too large
Load Diff
@ -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"}} |
||||||
|
] |
||||||
@ -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" |
||||||
@ -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 |
||||||
@ -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 |
||||||
@ -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 |
||||||
@ -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 |
||||||
@ -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 |
||||||
@ -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 |
||||||
@ -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}" |
||||||
|
|
||||||
@ -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…
Reference in new issue