Browse Source

WIP: basic API

develop-api-fb
chodak166 4 months ago
parent
commit
fecfb02071
  1. 150
      README.md
  2. 8
      apps/app_api/config.toml.example
  3. 4
      apps/app_api/src/api.rs
  4. 53
      apps/app_api/src/api/v1.rs
  5. 5
      apps/app_api/src/commands/listen.rs
  6. 2
      apps/app_api/src/config/auth.rs
  7. 9
      apps/app_api/src/dependencies.rs
  8. 1
      apps/app_api/src/error.rs
  9. 1
      apps/app_api/src/main.rs
  10. 38
      apps/app_api/src/middleware.rs
  11. 8
      apps/app_api/src/middleware/auth.rs
  12. 17
      apps/app_api/src/router.rs
  13. 14
      config.toml
  14. 2
      lib/Cargo.toml
  15. 198
      lib/src/auth/infrastructure/jwt.rs
  16. 1
      lib/src/common.rs
  17. 3
      lib/src/dictionary/infrastructure/sqlite_dict_repository.rs

150
README.md

@ -1,2 +1,152 @@
# Phomnemic # 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/health" \
-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/health" \
-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
#### Health Check
```bash
GET /api/v1/health
```
#### Dictionary Management
```bash
GET /api/v1/dicts
GET /api/v1/dicts/:name
```
#### Major System Encoding/Decoding
```bash
POST /api/v1/major/encode
POST /api/v1/major/decode
```
#### Authentication
```bash
POST /api/v1/auth/login
```
### Development
Run in development mode with auto-reload:
```bash
cargo run -- listen
```
### Testing
Run tests:
```bash
cargo test
```
### License
MIT

8
apps/app_api/config.toml.example

@ -3,3 +3,11 @@ host = "0.0.0.0"
port = 3000 port = 3000
log_level = "info" log_level = "info"
[auth]
firebase_project_id = "your-firebase-project-id"
firebase_emulator_url = "http://localhost:9099"
api_tokens = ["your-api-key-1", "your-api-key-2"]
[database]
url = "sqlite:app.db"

4
apps/app_api/src/api.rs

@ -4,6 +4,6 @@ use crate::state::AppState;
use axum::Router; use axum::Router;
use std::sync::Arc; use std::sync::Arc;
pub fn routes() -> Router<Arc<AppState>> { pub fn routes(state: Arc<AppState>) -> Router<Arc<AppState>> {
Router::new().nest("/api/v1", v1::routes()) Router::new().nest("/api/v1", v1::routes(state))
} }

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

@ -4,13 +4,60 @@ pub mod health;
pub mod major; pub mod major;
use crate::state::AppState; use crate::state::AppState;
use axum::Router; use axum::{
Router, extract::Request, extract::State, http::StatusCode, middleware::Next,
response::Response,
};
use std::sync::Arc; use std::sync::Arc;
pub fn routes() -> Router<Arc<AppState>> { pub fn routes(state: Arc<AppState>) -> Router<Arc<AppState>> {
Router::new() Router::new()
.nest("/health", health::routes()) .nest("/health", health::routes())
.nest("/auth", auth::routes())
.nest("/dicts", dictionary::routes()) .nest("/dicts", dictionary::routes())
.nest("/major", major::routes()) .nest("/major", major::routes())
.nest("/auth", auth::routes()) .route_layer(axum::middleware::from_fn_with_state(
state,
|state: State<Arc<AppState>>, request: Request, next: Next| async move {
auth_middleware_inner(state, request, next).await
},
))
}
async fn auth_middleware_inner(
state: State<Arc<AppState>>,
mut request: Request,
next: Next,
) -> Response {
let auth_header = request
.headers()
.get("Authorization")
.and_then(|h| h.to_str().ok());
let api_key_header = request
.headers()
.get("X-API-Key")
.and_then(|h| h.to_str().ok());
let token = if let Some(header) = auth_header {
header.to_string()
} else if let Some(key) = api_key_header {
key.to_string()
} else {
return Response::builder()
.status(StatusCode::UNAUTHORIZED)
.body("Missing authorization header or API key".into())
.unwrap();
};
match state.0.dependencies.auth_service.authenticate(&token).await {
Ok(claims) => {
request.extensions_mut().insert(claims);
next.run(request).await
}
Err(_) => Response::builder()
.status(StatusCode::UNAUTHORIZED)
.body("Unauthorized".into())
.unwrap(),
}
} }

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

@ -31,6 +31,7 @@ impl Configurable for ListenCmd {
builder: ConfigBuilder<DefaultState>, builder: ConfigBuilder<DefaultState>,
) -> Result<ConfigBuilder<DefaultState>> { ) -> Result<ConfigBuilder<DefaultState>> {
builder builder
.set_default("log_level", defaults::LOG_LEVEL)?
.set_default("listen.host", defaults::LISTEN_HOST)? .set_default("listen.host", defaults::LISTEN_HOST)?
.set_default("listen.port", defaults::LISTEN_PORT) .set_default("listen.port", defaults::LISTEN_PORT)
.map_err(Into::into) .map_err(Into::into)
@ -60,12 +61,13 @@ impl Executable for ListenCmd {
.as_ref() .as_ref()
.ok_or_else(|| anyhow::anyhow!("Listen config missing"))?; .ok_or_else(|| anyhow::anyhow!("Listen config missing"))?;
let app = router::create_router().await?; let app = router::create_router(config).await?;
let addr = format!("{}:{}", listen_config.host, listen_config.port); let addr = format!("{}:{}", listen_config.host, listen_config.port);
let listener = TcpListener::bind(&addr).await?; let listener = TcpListener::bind(&addr).await?;
info!("Starting server on {}", addr); info!("Starting server on {}", addr);
// axum::serve(listener, app.into_make_service()).await?;
axum::serve(listener, app) axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal()) .with_graceful_shutdown(shutdown_signal())
.await?; .await?;
@ -103,6 +105,7 @@ async fn shutdown_signal() {
mod defaults { mod defaults {
use const_format::formatcp; use const_format::formatcp;
pub const LOG_LEVEL: &str = "info";
pub const LISTEN_HOST: &str = "0.0.0.0"; pub const LISTEN_HOST: &str = "0.0.0.0";
pub const LISTEN_PORT: u16 = 3000; pub const LISTEN_PORT: u16 = 3000;
pub const HELP_LISTEN_HOST: &str = formatcp!("Host address [default: {}]", LISTEN_HOST); pub const HELP_LISTEN_HOST: &str = formatcp!("Host address [default: {}]", LISTEN_HOST);

2
apps/app_api/src/config/auth.rs

@ -3,6 +3,7 @@ use serde::Deserialize;
#[derive(Debug, Deserialize, Clone)] #[derive(Debug, Deserialize, Clone)]
pub struct AuthConfig { pub struct AuthConfig {
pub firebase_project_id: Option<String>, pub firebase_project_id: Option<String>,
pub firebase_emulator_url: Option<String>,
pub api_tokens: Vec<String>, pub api_tokens: Vec<String>,
} }
@ -10,6 +11,7 @@ impl Default for AuthConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
firebase_project_id: None, firebase_project_id: None,
firebase_emulator_url: None,
api_tokens: Vec::new(), api_tokens: Vec::new(),
} }
} }

9
apps/app_api/src/dependencies.rs

@ -18,18 +18,23 @@ impl AppDependencies {
pub async fn new( pub async fn new(
database_url: &str, database_url: &str,
firebase_project_id: Option<String>, firebase_project_id: Option<String>,
firebase_emulator_url: Option<String>,
api_tokens: Vec<String>,
) -> anyhow::Result<Self> { ) -> anyhow::Result<Self> {
let repo_factory = Arc::new(SqliteDictRepository::new(database_url).await?); let repo_factory = Arc::new(SqliteDictRepository::new(database_url).await?);
let dictionary_service = Arc::new(DictionaryService::new(repo_factory.clone())); let dictionary_service = Arc::new(DictionaryService::new(repo_factory.clone()));
let jwt_auth: Arc<dyn Authenticator> = if let Some(project_id) = firebase_project_id { 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)) Arc::new(JwtAuthenticator::new(project_id))
} else { } else {
Arc::new(JwtAuthenticator::with_placeholder()) Arc::new(JwtAuthenticator::with_placeholder())
}; };
let api_token_auth: Arc<dyn Authenticator> = Arc::new(ApiTokenAuthenticator::new()); 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 token_store: Arc<dyn TokenStore> = Arc::new(InMemoryTokenStore::new());
let auth_service = Arc::new(AuthService::new(jwt_auth, api_token_auth, token_store)); let auth_service = Arc::new(AuthService::new(jwt_auth, api_token_auth, token_store));

1
apps/app_api/src/error.rs

@ -34,7 +34,6 @@ impl ErrorResponse {
impl From<ServiceError> for ErrorResponse { impl From<ServiceError> for ErrorResponse {
fn from(err: ServiceError) -> Self { fn from(err: ServiceError) -> Self {
let message = err.to_string();
match &err { match &err {
ServiceError::Repository(e) => ErrorResponse { ServiceError::Repository(e) => ErrorResponse {
error: "Repository error".to_string(), error: "Repository error".to_string(),

1
apps/app_api/src/main.rs

@ -4,7 +4,6 @@ mod commands;
mod config; mod config;
mod dependencies; mod dependencies;
mod error; mod error;
mod middleware;
mod router; mod router;
mod state; mod state;

38
apps/app_api/src/middleware.rs

@ -1,38 +0,0 @@
pub mod auth;
use crate::state::AppState;
use axum::{
extract::{Request, State},
middleware::Next,
response::Response,
};
use std::sync::Arc;
pub async fn auth_middleware(
State(state): State<Arc<AppState>>,
mut request: Request,
next: Next,
) -> Result<Response, Response> {
let auth_header = request
.headers()
.get("Authorization")
.and_then(|h| h.to_str().ok());
if let Some(token) = auth_header {
match state.dependencies.auth_service.authenticate(token).await {
Ok(claims) => {
request.extensions_mut().insert(claims);
Ok(next.run(request).await)
}
Err(_) => Err(Response::builder()
.status(401)
.body("Unauthorized".into())
.unwrap()),
}
} else {
Err(Response::builder()
.status(401)
.body("Missing authorization header".into())
.unwrap())
}
}

8
apps/app_api/src/middleware/auth.rs

@ -1,8 +0,0 @@
use axum::http::HeaderMap;
pub fn extract_token(headers: &HeaderMap) -> Option<String> {
headers
.get("Authorization")
.and_then(|h| h.to_str().ok())
.map(|s| s.to_string())
}

17
apps/app_api/src/router.rs

@ -1,16 +1,25 @@
use axum::Router;
use std::sync::Arc; use std::sync::Arc;
use tower_http::{cors::CorsLayer, trace::TraceLayer}; use tower_http::{cors::CorsLayer, trace::TraceLayer};
use crate::api; use crate::api;
use crate::config::AppConfig;
use crate::dependencies::AppDependencies; use crate::dependencies::AppDependencies;
use crate::state::AppState; use crate::state::AppState;
pub async fn create_router() -> anyhow::Result<Router> { pub async fn create_router(config: &AppConfig) -> anyhow::Result<axum::Router> {
let dependencies = AppDependencies::new("sqlite:app.db", None).await?; 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); let state = Arc::new(AppState::new(dependencies).await);
Ok(api::routes() Ok(api::routes(state.clone())
.with_state(state) .with_state(state)
.layer(TraceLayer::new_for_http()) .layer(TraceLayer::new_for_http())
.layer(CorsLayer::permissive())) .layer(CorsLayer::permissive()))

14
config.toml

@ -1 +1,13 @@
log_level = "info" [listen]
host = "0.0.0.0"
port = 3000
log_level = "trace"
[auth]
firebase_project_id = "test-project"
firebase_emulator_url = "http://192.168.1.23:9099"
api_tokens = ["dev-api-key"]
[database]
url = "sqlite:app.db"

2
lib/Cargo.toml

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

198
lib/src/auth/infrastructure/jwt.rs

@ -1,22 +1,153 @@
use crate::auth::domain::AuthClaims; use crate::auth::domain::AuthClaims;
use crate::auth::traits::Authenticator; use crate::auth::traits::Authenticator;
use crate::common::errors::AuthError; use crate::common::errors::AuthError;
use jsonwebtoken::{
Algorithm, DecodingKey, Validation, dangerous_insecure_decode, 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 { pub struct JwtAuthenticator {
firebase_project_id: String, firebase_project_id: String,
issuer: String,
audience: String,
emulator_url: Option<String>,
key_cache: Arc<RwLock<Option<KeyCache>>>,
} }
impl JwtAuthenticator { impl JwtAuthenticator {
pub fn new(firebase_project_id: impl Into<String>) -> Self { 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 { Self {
firebase_project_id: firebase_project_id.into(), 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 { pub fn with_placeholder() -> Self {
Self { Self {
firebase_project_id: "FIREBASE_PROJECT_ID_PLACEHOLDER".to_string(), 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)),
}
}
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/robot/v1/metadata/x509/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())
} }
} }
@ -29,40 +160,53 @@ impl Authenticator for JwtAuthenticator {
)); ));
} }
let parts: Vec<&str> = token.split('.').collect(); let header = decode_header(token).map_err(|e| {
if parts.len() != 3 { tracing::debug!("Failed to decode header: {}", e.to_string());
return Err(AuthError::InvalidToken); AuthError::InvalidToken
} })?;
let claims = AuthClaims::new("firebase_user_123") let kid = header
.with_email("user@example.com") .kid
.with_role("user"); .ok_or_else(|| AuthError::AuthenticationFailed("Token missing key ID".to_string()))?;
Ok(claims) let public_keys = self.get_public_keys().await?;
} 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;
pub struct JwtConfig { let token_data =
pub firebase_project_id: String, decode::<FirebaseClaims>(token, decoding_key, &validation).map_err(|e| {
pub issuer: String, AuthError::AuthenticationFailed(format!("Token validation failed: {}", e))
pub audience: String, })?;
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);
} }
impl JwtConfig { if let Some(ref firebase) = token_data.claims.firebase {
pub fn new(firebase_project_id: String) -> Self { if let Some(ref provider) = firebase.sign_in_provider {
let issuer = format!("https://securetoken.google.com/{}", firebase_project_id); claims = claims.with_role(format!("auth:{}", provider));
Self {
firebase_project_id: firebase_project_id.clone(),
issuer,
audience: firebase_project_id,
} }
} }
pub fn placeholder() -> Self { claims = claims.with_role("authenticated");
Self {
firebase_project_id: "FIREBASE_PROJECT_ID_PLACEHOLDER".to_string(), Ok(claims)
issuer: "https://securetoken.google.com/FIREBASE_PROJECT_ID_PLACEHOLDER".to_string(), }
audience: "FIREBASE_PROJECT_ID_PLACEHOLDER".to_string(),
} }
impl AuthClaims {
pub fn with_iat(mut self, iat: i64) -> Self {
self.iat = iat;
self
} }
} }

1
lib/src/common.rs

@ -2,6 +2,5 @@ pub mod entities;
pub mod errors; pub mod errors;
pub mod traits; pub mod traits;
pub use self::errors::{AuthError, ServiceError};
pub use self::traits::SystemDecoder; pub use self::traits::SystemDecoder;
pub use self::traits::SystemEncoder; pub use self::traits::SystemEncoder;

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

@ -6,7 +6,6 @@ use futures::stream::BoxStream;
use sqlx::{Row, SqlitePool, sqlite::SqliteConnectOptions}; use sqlx::{Row, SqlitePool, sqlite::SqliteConnectOptions};
use std::collections::HashMap; use std::collections::HashMap;
use std::str::FromStr; use std::str::FromStr;
use std::sync::Arc;
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
struct SqliteEntryDto { struct SqliteEntryDto {
@ -250,7 +249,7 @@ impl DictRepository for SqliteDictRepository {
#[async_trait::async_trait] #[async_trait::async_trait]
impl DictRepositoryFactory for SqliteDictRepository { impl DictRepositoryFactory for SqliteDictRepository {
async fn create(&self, dict_name: &str) -> Result<Box<dyn DictRepository>, RepositoryError> { async fn create(&self, dict_name: &str) -> Result<Box<dyn DictRepository>, RepositoryError> {
let mut repo = Self { let repo = Self {
pool: self.pool.clone(), pool: self.pool.clone(),
dict_name: dict_name.to_string(), dict_name: dict_name.to_string(),
}; };

Loading…
Cancel
Save