feat(backend): ayarlar, MongoDB bağlantısı, Türkçe hata yapısı ve DB'li health check
- config.rs: ortam değişkenleri ve doğrulama - db.rs: bağlantı + ping (5 sn zaman aşımı) - error.rs: AppError → Türkçe JSON hata yanıtı - /api/health: veritabanına erişilemezse 503 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Generated
+1792
-5
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,10 @@ edition = "2024"
|
||||
[dependencies]
|
||||
axum = "0.8.9"
|
||||
dotenvy = "0.15.7"
|
||||
mongodb = "3.10.0"
|
||||
serde = { version = "1.0.229", features = ["derive"] }
|
||||
serde_json = "1.0.154"
|
||||
thiserror = "2.0.21"
|
||||
tokio = { version = "1.54.1", features = ["full"] }
|
||||
tracing = "0.1.44"
|
||||
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
||||
|
||||
+1
-1
@@ -3,5 +3,5 @@
|
||||
|
||||
@baseUrl = http://localhost:8080/api
|
||||
|
||||
### Sağlık kontrolü
|
||||
### Sağlık kontrolü (DB erişilemezse 503 + "db": "error")
|
||||
GET {{baseUrl}}/health
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
//! Ortam değişkenlerinden uygulama ayarlarını okur.
|
||||
|
||||
use std::env;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Config {
|
||||
pub mongodb_uri: String,
|
||||
#[allow(dead_code)] // Faz 1 – Adım 3'te (JWT) kullanılacak
|
||||
pub jwt_secret: String,
|
||||
pub app_url: String,
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ConfigError {
|
||||
#[error("{0} ortam değişkeni tanımlı değil (.env dosyasını kontrol edin)")]
|
||||
Missing(&'static str),
|
||||
#[error("{0} geçersiz: {1}")]
|
||||
Invalid(&'static str, &'static str),
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_env() -> Result<Self, ConfigError> {
|
||||
let mongodb_uri = required("MONGODB_URI")?;
|
||||
|
||||
let jwt_secret = required("JWT_SECRET")?;
|
||||
if jwt_secret.len() < 32 {
|
||||
return Err(ConfigError::Invalid(
|
||||
"JWT_SECRET",
|
||||
"en az 32 karakter olmalı (openssl rand -hex 32)",
|
||||
));
|
||||
}
|
||||
|
||||
let app_url = env::var("APP_URL").unwrap_or_else(|_| "http://localhost:5173".into());
|
||||
|
||||
let port = match env::var("PORT") {
|
||||
Ok(p) => p
|
||||
.parse()
|
||||
.map_err(|_| ConfigError::Invalid("PORT", "sayı olmalı"))?,
|
||||
Err(_) => 8080,
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
mongodb_uri,
|
||||
jwt_secret,
|
||||
app_url,
|
||||
port,
|
||||
})
|
||||
}
|
||||
|
||||
/// Cookie'lerde `Secure` bayrağı yalnızca HTTPS'te açılır.
|
||||
#[allow(dead_code)] // Faz 1 – Adım 3'te (kimlik doğrulama) kullanılacak
|
||||
pub fn is_https(&self) -> bool {
|
||||
self.app_url.starts_with("https://")
|
||||
}
|
||||
}
|
||||
|
||||
fn required(key: &'static str) -> Result<String, ConfigError> {
|
||||
env::var(key)
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
.ok_or(ConfigError::Missing(key))
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
//! MongoDB bağlantısı.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use mongodb::{Client, Database, bson::doc, options::ClientOptions};
|
||||
|
||||
/// URI'de veritabanı adı yoksa kullanılacak ad.
|
||||
const DEFAULT_DB_NAME: &str = "kakeibo";
|
||||
|
||||
/// Bağlantıyı kurar ve bir `ping` ile sunucunun gerçekten erişilebilir olduğunu doğrular.
|
||||
pub async fn connect(uri: &str) -> mongodb::error::Result<Database> {
|
||||
let mut options = ClientOptions::parse(uri).await?;
|
||||
options.app_name = Some("kakeibo".into());
|
||||
// Sunucu yoksa 30 sn yerine 5 sn içinde hata ver.
|
||||
options.server_selection_timeout = Some(Duration::from_secs(5));
|
||||
|
||||
let db_name = options
|
||||
.default_database
|
||||
.clone()
|
||||
.unwrap_or_else(|| DEFAULT_DB_NAME.into());
|
||||
|
||||
let client = Client::with_options(options)?;
|
||||
let db = client.database(&db_name);
|
||||
ping(&db).await?;
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
pub async fn ping(db: &Database) -> mongodb::error::Result<()> {
|
||||
db.run_command(doc! { "ping": 1 }).await.map(|_| ())
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
//! Uygulama genelindeki hata türü.
|
||||
//! Kullanıcıya Türkçe ve sade mesaj döner; teknik detay yalnızca loga yazılır.
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AppError {
|
||||
#[error("Kayıt bulunamadı")]
|
||||
NotFound,
|
||||
#[error("{0}")]
|
||||
BadRequest(String),
|
||||
#[error("Oturum açmanız gerekiyor")]
|
||||
Unauthorized,
|
||||
#[error("Bu işlem için yetkiniz yok")]
|
||||
Forbidden,
|
||||
#[error("{0}")]
|
||||
Conflict(String),
|
||||
#[error("Çok fazla deneme yapıldı, lütfen biraz sonra tekrar deneyin")]
|
||||
TooManyRequests,
|
||||
#[error("veritabanı hatası: {0}")]
|
||||
Database(#[from] mongodb::error::Error),
|
||||
#[error("iç hata: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
impl AppError {
|
||||
fn status_and_code(&self) -> (StatusCode, &'static str) {
|
||||
match self {
|
||||
Self::NotFound => (StatusCode::NOT_FOUND, "not_found"),
|
||||
Self::BadRequest(_) => (StatusCode::BAD_REQUEST, "bad_request"),
|
||||
Self::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized"),
|
||||
Self::Forbidden => (StatusCode::FORBIDDEN, "forbidden"),
|
||||
Self::Conflict(_) => (StatusCode::CONFLICT, "conflict"),
|
||||
Self::TooManyRequests => (StatusCode::TOO_MANY_REQUESTS, "too_many_requests"),
|
||||
Self::Database(_) | Self::Internal(_) => {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "internal")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for AppError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, code) = self.status_and_code();
|
||||
|
||||
let message = if status.is_server_error() {
|
||||
// Teknik detay sadece logda; kullanıcıya genel mesaj.
|
||||
tracing::error!(error = %self, "sunucu hatası");
|
||||
"Beklenmeyen bir hata oluştu, lütfen tekrar deneyin".to_string()
|
||||
} else {
|
||||
self.to_string()
|
||||
};
|
||||
|
||||
(
|
||||
status,
|
||||
Json(json!({ "error": { "code": code, "message": message } })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
/// Route fonksiyonlarında kısa dönüş tipi: `AppResult<Json<T>>`
|
||||
pub type AppResult<T> = Result<T, AppError>;
|
||||
+53
-18
@@ -1,11 +1,18 @@
|
||||
use std::net::SocketAddr;
|
||||
mod config;
|
||||
mod db;
|
||||
#[allow(dead_code)] // AppError, Faz 1 – Adım 3 itibarıyla route'larda kullanılacak
|
||||
mod error;
|
||||
mod routes;
|
||||
mod state;
|
||||
|
||||
use std::{net::SocketAddr, process::ExitCode, sync::Arc};
|
||||
|
||||
use axum::{Json, Router, routing::get};
|
||||
use serde_json::{Value, json};
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
use crate::{config::Config, state::AppState};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
async fn main() -> ExitCode {
|
||||
// .env dosyasını yükle (proje kökünde; üst klasörler de aranır). Yoksa sorun değil.
|
||||
dotenvy::dotenv().ok();
|
||||
|
||||
@@ -15,22 +22,50 @@ async fn main() {
|
||||
)
|
||||
.init();
|
||||
|
||||
let port: u16 = std::env::var("PORT")
|
||||
.ok()
|
||||
.and_then(|p| p.parse().ok())
|
||||
.unwrap_or(8080);
|
||||
let config = match Config::from_env() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::error!("Ayar hatası: {e}");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
|
||||
let app = Router::new().route("/api/health", get(health));
|
||||
let db = match db::connect(&config.mongodb_uri).await {
|
||||
Ok(db) => {
|
||||
tracing::info!("MongoDB bağlantısı kuruldu (veritabanı: {})", db.name());
|
||||
db
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
"MongoDB'ye bağlanılamadı. MONGODB_URI doğru mu, veritabanı çalışıyor mu? \
|
||||
(yerelde: npm run db:up)\nDetay: {e}"
|
||||
);
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
|
||||
let port = config.port;
|
||||
let state = AppState {
|
||||
db,
|
||||
config: Arc::new(config),
|
||||
};
|
||||
let app = routes::router(state);
|
||||
|
||||
let addr = SocketAddr::from(([0, 0, 0, 0], port));
|
||||
let listener = tokio::net::TcpListener::bind(addr)
|
||||
.await
|
||||
.expect("Port dinlenemedi");
|
||||
tracing::info!("Kakeibo API çalışıyor: http://localhost:{port}");
|
||||
axum::serve(listener, app).await.expect("Sunucu hatası");
|
||||
}
|
||||
let listener = match tokio::net::TcpListener::bind(addr).await {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
"{port} portu dinlenemedi (başka bir uygulama kullanıyor olabilir): {e}"
|
||||
);
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
|
||||
/// Sağlık kontrolü. Faz 1'de veritabanı durumu da eklenecek.
|
||||
async fn health() -> Json<Value> {
|
||||
Json(json!({ "status": "ok", "version": env!("CARGO_PKG_VERSION") }))
|
||||
tracing::info!("Kakeibo API çalışıyor: http://localhost:{port}");
|
||||
if let Err(e) = axum::serve(listener, app).await {
|
||||
tracing::error!("Sunucu hatası: {e}");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
use axum::{Json, extract::State, http::StatusCode};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{db, state::AppState};
|
||||
|
||||
/// Sağlık kontrolü (Dokploy health check bunu kullanır).
|
||||
/// DB erişilemezse 503 döner.
|
||||
pub async fn health(State(state): State<AppState>) -> (StatusCode, Json<Value>) {
|
||||
let version = env!("CARGO_PKG_VERSION");
|
||||
|
||||
match db::ping(&state.db).await {
|
||||
Ok(()) => (
|
||||
StatusCode::OK,
|
||||
Json(json!({ "status": "ok", "db": "ok", "version": version })),
|
||||
),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "health: veritabanına erişilemiyor");
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(json!({ "status": "degraded", "db": "error", "version": version })),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//! API route'larının tek giriş noktası. Tüm uç noktalar `/api` altında.
|
||||
|
||||
mod health;
|
||||
|
||||
use axum::{Router, routing::get};
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn router(state: AppState) -> Router {
|
||||
let api = Router::new().route("/health", get(health::health));
|
||||
|
||||
Router::new().nest("/api", api).with_state(state)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//! Tüm route'ların paylaştığı uygulama durumu.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use mongodb::Database;
|
||||
|
||||
use crate::config::Config;
|
||||
|
||||
/// `Clone` ucuzdur: `Database` ve `Arc` içte referans sayımı kullanır, veri kopyalanmaz.
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub db: Database,
|
||||
#[allow(dead_code)] // Faz 1 – Adım 3'te kullanılacak
|
||||
pub config: Arc<Config>,
|
||||
}
|
||||
Reference in New Issue
Block a user