feat(api): implement user authentication and registration endpoints
- Add main application logic in `api/src/main.rs` to initialize server, database, and services. - Create authentication routes in `api/src/routes/auth.rs` for login, register, logout, and user info retrieval. - Implement configuration route in `api/src/routes/config.rs` to expose application settings. - Define application state management in `api/src/state.rs` to share user service and configuration. - Set up Docker Compose configuration in `compose.yml` for backend, worker, and database services. - Establish domain logic in `domain` crate with user entities, repositories, and services. - Implement SQLite user repository in `infra/src/user_repository.rs` for user data persistence. - Create database migration handling in `infra/src/db.rs` and session store in `infra/src/session_store.rs`. - Add necessary dependencies and features in `Cargo.toml` files for both `domain` and `infra` crates.
This commit is contained in:
72
api/Cargo.toml
Normal file
72
api/Cargo.toml
Normal file
@@ -0,0 +1,72 @@
|
||||
[package]
|
||||
name = "api"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
default-run = "api"
|
||||
|
||||
[features]
|
||||
default = ["sqlite"]
|
||||
sqlite = [
|
||||
"infra/sqlite",
|
||||
"tower-sessions-sqlx-store/sqlite",
|
||||
"sqlx/sqlite",
|
||||
]
|
||||
postgres = [
|
||||
"infra/postgres",
|
||||
"tower-sessions-sqlx-store/postgres",
|
||||
"sqlx/postgres",
|
||||
"k-core/postgres",
|
||||
]
|
||||
broker-nats = ["infra/broker-nats"]
|
||||
|
||||
[dependencies]
|
||||
k-core = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-core", features = [
|
||||
"logging",
|
||||
"db-sqlx",
|
||||
] }
|
||||
domain = { path = "../domain" }
|
||||
infra = { path = "../infra", default-features = false, features = [
|
||||
"sqlite",
|
||||
] }
|
||||
|
||||
# Web framework
|
||||
axum = { version = "0.8.8", features = ["macros"] }
|
||||
tower = "0.5.2"
|
||||
tower-http = { version = "0.6.2", features = ["cors", "trace"] }
|
||||
|
||||
# Authentication
|
||||
axum-login = "0.18"
|
||||
tower-sessions = "0.14"
|
||||
tower-sessions-sqlx-store = { version = "0.15", features = ["sqlite"] }
|
||||
password-auth = "1.0"
|
||||
time = "0.3"
|
||||
async-trait = "0.1.89"
|
||||
|
||||
# Async runtime
|
||||
tokio = { version = "1.48.0", features = ["full"] }
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
# Validation
|
||||
validator = { version = "0.20", features = ["derive"] }
|
||||
|
||||
# Error handling
|
||||
thiserror = "2.0.17"
|
||||
anyhow = "1.0"
|
||||
|
||||
# Utilities
|
||||
chrono = { version = "0.4.42", features = ["serde"] }
|
||||
uuid = { version = "1.19.0", features = ["v4", "serde"] }
|
||||
|
||||
# Logging
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3.22", features = ["env-filter"] }
|
||||
|
||||
# Database
|
||||
sqlx = { version = "0.8.6", features = ["sqlite", "runtime-tokio"] }
|
||||
dotenvy = "0.15.7"
|
||||
|
||||
# Configuration
|
||||
config = "0.15.9"
|
||||
101
api/src/auth.rs
Normal file
101
api/src/auth.rs
Normal file
@@ -0,0 +1,101 @@
|
||||
//! Authentication logic using axum-login
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum_login::{AuthnBackend, UserId};
|
||||
use infra::session_store::InfraSessionStore;
|
||||
use password_auth::verify_password;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tower_sessions::SessionManagerLayer;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::ApiError;
|
||||
use domain::{User, UserRepository};
|
||||
|
||||
/// Wrapper around domain User to implement AuthUser
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AuthUser(pub User);
|
||||
|
||||
impl axum_login::AuthUser for AuthUser {
|
||||
type Id = Uuid;
|
||||
|
||||
fn id(&self) -> Self::Id {
|
||||
self.0.id
|
||||
}
|
||||
|
||||
fn session_auth_hash(&self) -> &[u8] {
|
||||
// Use password hash to invalidate sessions if password changes
|
||||
self.0
|
||||
.password_hash
|
||||
.as_ref()
|
||||
.map(|s| s.as_bytes())
|
||||
.unwrap_or(&[])
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AuthBackend {
|
||||
pub user_repo: Arc<dyn UserRepository>,
|
||||
}
|
||||
|
||||
impl AuthBackend {
|
||||
pub fn new(user_repo: Arc<dyn UserRepository>) -> Self {
|
||||
Self { user_repo }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct Credentials {
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
impl AuthnBackend for AuthBackend {
|
||||
type User = AuthUser;
|
||||
type Credentials = Credentials;
|
||||
type Error = ApiError;
|
||||
|
||||
async fn authenticate(
|
||||
&self,
|
||||
creds: Self::Credentials,
|
||||
) -> Result<Option<Self::User>, Self::Error> {
|
||||
let user = self
|
||||
.user_repo
|
||||
.find_by_email(&creds.email)
|
||||
.await
|
||||
.map_err(|e| ApiError::internal(e.to_string()))?;
|
||||
|
||||
if let Some(user) = user {
|
||||
if let Some(hash) = &user.password_hash {
|
||||
// Verify password
|
||||
if verify_password(&creds.password, hash).is_ok() {
|
||||
return Ok(Some(AuthUser(user)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn get_user(&self, user_id: &UserId<Self>) -> Result<Option<Self::User>, Self::Error> {
|
||||
let user = self
|
||||
.user_repo
|
||||
.find_by_id(*user_id)
|
||||
.await
|
||||
.map_err(|e| ApiError::internal(e.to_string()))?;
|
||||
|
||||
Ok(user.map(AuthUser))
|
||||
}
|
||||
}
|
||||
|
||||
pub type AuthSession = axum_login::AuthSession<AuthBackend>;
|
||||
|
||||
pub async fn setup_auth_layer(
|
||||
session_layer: SessionManagerLayer<InfraSessionStore>,
|
||||
user_repo: Arc<dyn UserRepository>,
|
||||
) -> Result<axum_login::AuthManagerLayer<AuthBackend, InfraSessionStore>, ApiError> {
|
||||
let backend = AuthBackend::new(user_repo);
|
||||
|
||||
let auth_layer = axum_login::AuthManagerLayerBuilder::new(backend, session_layer).build();
|
||||
Ok(auth_layer)
|
||||
}
|
||||
35
api/src/config.rs
Normal file
35
api/src/config.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
//! Application Configuration
|
||||
//!
|
||||
//! Loads configuration from environment variables.
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Config {
|
||||
pub database_url: String,
|
||||
pub session_secret: String,
|
||||
|
||||
#[serde(default = "default_port")]
|
||||
pub port: u16,
|
||||
|
||||
#[serde(default = "default_host")]
|
||||
pub host: String,
|
||||
}
|
||||
|
||||
fn default_port() -> u16 {
|
||||
3000
|
||||
}
|
||||
|
||||
fn default_host() -> String {
|
||||
"127.0.0.1".to_string()
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn new() -> Result<Self, config::ConfigError> {
|
||||
config::Config::builder()
|
||||
.add_source(config::Environment::default())
|
||||
//.add_source(config::File::with_name(".env").required(false)) // Optional .env file
|
||||
.build()?
|
||||
.try_deserialize()
|
||||
}
|
||||
}
|
||||
42
api/src/dto.rs
Normal file
42
api/src/dto.rs
Normal file
@@ -0,0 +1,42 @@
|
||||
//! Request and Response DTOs
|
||||
//!
|
||||
//! Data Transfer Objects for the API.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
use validator::Validate;
|
||||
|
||||
/// Login request
|
||||
#[derive(Debug, Deserialize, Validate)]
|
||||
pub struct LoginRequest {
|
||||
#[validate(email(message = "Invalid email format"))]
|
||||
pub email: String,
|
||||
|
||||
#[validate(length(min = 6, message = "Password must be at least 6 characters"))]
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
/// Register request
|
||||
#[derive(Debug, Deserialize, Validate)]
|
||||
pub struct RegisterRequest {
|
||||
#[validate(email(message = "Invalid email format"))]
|
||||
pub email: String,
|
||||
|
||||
#[validate(length(min = 6, message = "Password must be at least 6 characters"))]
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
/// User response DTO
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct UserResponse {
|
||||
pub id: Uuid,
|
||||
pub email: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// System configuration response
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ConfigResponse {
|
||||
pub allow_registration: bool,
|
||||
}
|
||||
121
api/src/error.rs
Normal file
121
api/src/error.rs
Normal file
@@ -0,0 +1,121 @@
|
||||
//! API error handling
|
||||
//!
|
||||
//! Maps domain errors to HTTP responses
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::Serialize;
|
||||
use thiserror::Error;
|
||||
|
||||
use domain::DomainError;
|
||||
|
||||
/// API-level errors
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ApiError {
|
||||
#[error("{0}")]
|
||||
Domain(#[from] DomainError),
|
||||
|
||||
#[error("Validation error: {0}")]
|
||||
Validation(String),
|
||||
|
||||
#[error("Internal server error")]
|
||||
Internal(String),
|
||||
|
||||
#[error("Forbidden: {0}")]
|
||||
Forbidden(String),
|
||||
|
||||
#[error("Unauthorized: {0}")]
|
||||
Unauthorized(String),
|
||||
}
|
||||
|
||||
/// Error response body
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ErrorResponse {
|
||||
pub error: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub details: Option<String>,
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, error_response) = match &self {
|
||||
ApiError::Domain(domain_error) => {
|
||||
let status = match domain_error {
|
||||
DomainError::UserNotFound(_) => StatusCode::NOT_FOUND,
|
||||
|
||||
DomainError::UserAlreadyExists(_) => StatusCode::CONFLICT,
|
||||
|
||||
DomainError::ValidationError(_) => StatusCode::BAD_REQUEST,
|
||||
|
||||
DomainError::Unauthorized(_) => StatusCode::FORBIDDEN,
|
||||
|
||||
DomainError::RepositoryError(_) | DomainError::InfrastructureError(_) => {
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
}
|
||||
};
|
||||
|
||||
(
|
||||
status,
|
||||
ErrorResponse {
|
||||
error: domain_error.to_string(),
|
||||
details: None,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
ApiError::Validation(msg) => (
|
||||
StatusCode::BAD_REQUEST,
|
||||
ErrorResponse {
|
||||
error: "Validation error".to_string(),
|
||||
details: Some(msg.clone()),
|
||||
},
|
||||
),
|
||||
|
||||
ApiError::Internal(msg) => {
|
||||
// Log internal errors but don't expose details
|
||||
tracing::error!("Internal error: {}", msg);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ErrorResponse {
|
||||
error: "Internal server error".to_string(),
|
||||
details: None,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
ApiError::Forbidden(msg) => (
|
||||
StatusCode::FORBIDDEN,
|
||||
ErrorResponse {
|
||||
error: "Forbidden".to_string(),
|
||||
details: Some(msg.clone()),
|
||||
},
|
||||
),
|
||||
|
||||
ApiError::Unauthorized(msg) => (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
ErrorResponse {
|
||||
error: "Unauthorized".to_string(),
|
||||
details: Some(msg.clone()),
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
(status, Json(error_response)).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
impl ApiError {
|
||||
pub fn validation(msg: impl Into<String>) -> Self {
|
||||
Self::Validation(msg.into())
|
||||
}
|
||||
|
||||
pub fn internal(msg: impl Into<String>) -> Self {
|
||||
Self::Internal(msg.into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Result type alias for API handlers
|
||||
pub type ApiResult<T> = Result<T, ApiError>;
|
||||
75
api/src/main.rs
Normal file
75
api/src/main.rs
Normal file
@@ -0,0 +1,75 @@
|
||||
use std::net::SocketAddr;
|
||||
use std::time::Duration as StdDuration;
|
||||
|
||||
use domain::UserService;
|
||||
use infra::factory::build_session_store;
|
||||
use infra::factory::build_user_repository;
|
||||
use k_core::logging;
|
||||
use tokio::net::TcpListener;
|
||||
use tower_sessions::{Expiry, SessionManagerLayer};
|
||||
use tracing::info;
|
||||
|
||||
mod auth;
|
||||
mod config;
|
||||
mod dto;
|
||||
mod error;
|
||||
mod routes;
|
||||
mod state;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
// 1. Initialize logging
|
||||
logging::init("template-api");
|
||||
|
||||
// 2. Load configuration
|
||||
// We use dotenvy explicitly here since config crate might not pick up .env automatically reliably
|
||||
dotenvy::dotenv().ok();
|
||||
let config = Config::new().expect("Failed to load configuration");
|
||||
|
||||
info!("Starting server on {}:{}", config.host, config.port);
|
||||
|
||||
// 3. Connect to database
|
||||
// k-core handles the "Which DB are we using?" logic internally based on feature flags
|
||||
// and returns the correct Enum variant.
|
||||
let db_config = k_core::db::DatabaseConfig {
|
||||
url: config.database_url.clone(),
|
||||
max_connections: 5,
|
||||
acquire_timeout: StdDuration::from_secs(30),
|
||||
};
|
||||
|
||||
// Returns k_core::db::DatabasePool
|
||||
let db_pool = k_core::db::connect(&db_config).await?;
|
||||
|
||||
// 4. Run migrations (using the re-export if you kept it, or direct k_core)
|
||||
infra::db::run_migrations(&db_pool).await?;
|
||||
|
||||
// 5. Initialize Services
|
||||
let user_repo = build_user_repository(&db_pool).await?;
|
||||
let user_service = UserService::new(user_repo.clone());
|
||||
|
||||
// 6. Setup Session Store
|
||||
let session_store = build_session_store(&db_pool).await?;
|
||||
|
||||
let session_layer = SessionManagerLayer::new(session_store)
|
||||
.with_secure(false) // Set to true in production with HTTPS
|
||||
.with_expiry(Expiry::OnInactivity(time::Duration::hours(1)));
|
||||
|
||||
// 7. Setup Auth
|
||||
let auth_layer = auth::setup_auth_layer(session_layer, user_repo.clone()).await?;
|
||||
|
||||
// 8. Create App State
|
||||
let state = AppState::new(user_service, config.clone());
|
||||
|
||||
// 9. Build Router
|
||||
let app = routes::api_v1_router().layer(auth_layer).with_state(state);
|
||||
|
||||
// 10. Start Server
|
||||
let addr: SocketAddr = format!("{}:{}", config.host, config.port).parse()?;
|
||||
let listener = TcpListener::bind(addr).await?;
|
||||
axum::serve(listener, app).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
117
api/src/routes/auth.rs
Normal file
117
api/src/routes/auth.rs
Normal file
@@ -0,0 +1,117 @@
|
||||
use axum::http::StatusCode;
|
||||
use axum::{
|
||||
Router,
|
||||
extract::{Json, State},
|
||||
response::IntoResponse,
|
||||
routing::post,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
dto::{LoginRequest, RegisterRequest, UserResponse},
|
||||
error::ApiError,
|
||||
state::AppState,
|
||||
};
|
||||
use domain::{DomainError, Email};
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/login", post(login))
|
||||
.route("/register", post(register))
|
||||
.route("/logout", post(logout))
|
||||
.route("/me", post(me))
|
||||
}
|
||||
|
||||
async fn login(
|
||||
mut auth_session: crate::auth::AuthSession,
|
||||
Json(payload): Json<LoginRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let user = match auth_session
|
||||
.authenticate(crate::auth::Credentials {
|
||||
email: payload.email,
|
||||
password: payload.password,
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(Some(user)) => user,
|
||||
Ok(None) => return Err(ApiError::Validation("Invalid credentials".to_string())),
|
||||
Err(_) => return Err(ApiError::Internal("Authentication failed".to_string())),
|
||||
};
|
||||
|
||||
auth_session
|
||||
.login(&user)
|
||||
.await
|
||||
.map_err(|_| ApiError::Internal("Login failed".to_string()))?;
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(UserResponse {
|
||||
id: user.0.id,
|
||||
email: user.0.email.into_inner(),
|
||||
created_at: user.0.created_at,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
async fn register(
|
||||
State(state): State<AppState>,
|
||||
mut auth_session: crate::auth::AuthSession,
|
||||
Json(payload): Json<RegisterRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
if state
|
||||
.user_service
|
||||
.find_by_email(&payload.email)
|
||||
.await?
|
||||
.is_some()
|
||||
{
|
||||
return Err(ApiError::Domain(DomainError::UserAlreadyExists(
|
||||
payload.email,
|
||||
)));
|
||||
}
|
||||
|
||||
// Note: In a real app, you would hash the password here.
|
||||
// This template uses a simplified User::new which doesn't take password.
|
||||
// You should extend User to handle passwords or use an OIDC flow.
|
||||
let email = Email::try_from(payload.email).map_err(|e| ApiError::Validation(e.to_string()))?;
|
||||
|
||||
// Using email as subject for local auth for now
|
||||
let user = state
|
||||
.user_service
|
||||
.find_or_create(&email.as_ref().to_string(), email.as_ref())
|
||||
.await?;
|
||||
|
||||
// Log the user in
|
||||
let auth_user = crate::auth::AuthUser(user.clone());
|
||||
|
||||
auth_session
|
||||
.login(&auth_user)
|
||||
.await
|
||||
.map_err(|_| ApiError::Internal("Login failed".to_string()))?;
|
||||
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(UserResponse {
|
||||
id: user.id,
|
||||
email: user.email.into_inner(),
|
||||
created_at: user.created_at,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
async fn logout(mut auth_session: crate::auth::AuthSession) -> impl IntoResponse {
|
||||
match auth_session.logout().await {
|
||||
Ok(_) => StatusCode::OK,
|
||||
Err(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
|
||||
async fn me(auth_session: crate::auth::AuthSession) -> Result<impl IntoResponse, ApiError> {
|
||||
let user = auth_session
|
||||
.user
|
||||
.ok_or(ApiError::Unauthorized("Not logged in".to_string()))?;
|
||||
|
||||
Ok(Json(UserResponse {
|
||||
id: user.0.id,
|
||||
email: user.0.email.into_inner(),
|
||||
created_at: user.0.created_at,
|
||||
}))
|
||||
}
|
||||
13
api/src/routes/config.rs
Normal file
13
api/src/routes/config.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
use axum::{Json, Router, routing::get};
|
||||
use crate::dto::ConfigResponse;
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new().route("/", get(get_config))
|
||||
}
|
||||
|
||||
async fn get_config() -> Json<ConfigResponse> {
|
||||
Json(ConfigResponse {
|
||||
allow_registration: true, // Default to true for template
|
||||
})
|
||||
}
|
||||
16
api/src/routes/mod.rs
Normal file
16
api/src/routes/mod.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
//! API Routes
|
||||
//!
|
||||
//! Defines the API endpoints and maps them to handler functions.
|
||||
|
||||
use crate::state::AppState;
|
||||
use axum::Router;
|
||||
|
||||
pub mod auth;
|
||||
pub mod config;
|
||||
|
||||
/// Construct the API v1 router
|
||||
pub fn api_v1_router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.nest("/auth", auth::router())
|
||||
.nest("/config", config::router())
|
||||
}
|
||||
36
api/src/state.rs
Normal file
36
api/src/state.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
//! Application State
|
||||
//!
|
||||
//! Holds shared state for the application.
|
||||
|
||||
use axum::extract::FromRef;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::config::Config;
|
||||
use domain::UserService;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub user_service: Arc<UserService>,
|
||||
pub config: Arc<Config>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(user_service: UserService, config: Config) -> Self {
|
||||
Self {
|
||||
user_service: Arc::new(user_service),
|
||||
config: Arc::new(config),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromRef<AppState> for Arc<UserService> {
|
||||
fn from_ref(input: &AppState) -> Self {
|
||||
input.user_service.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl FromRef<AppState> for Arc<Config> {
|
||||
fn from_ref(input: &AppState) -> Self {
|
||||
input.config.clone()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user