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:
2026-11-11 23:09:46 +03:00
parent 306c550b28
commit 125bb7f476
13 changed files with 2068 additions and 27 deletions
+1792 -5
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -6,7 +6,10 @@ edition = "2024"
[dependencies] [dependencies]
axum = "0.8.9" axum = "0.8.9"
dotenvy = "0.15.7" dotenvy = "0.15.7"
mongodb = "3.10.0"
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.154" serde_json = "1.0.154"
thiserror = "2.0.21"
tokio = { version = "1.54.1", features = ["full"] } tokio = { version = "1.54.1", features = ["full"] }
tracing = "0.1.44" tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
+1 -1
View File
@@ -3,5 +3,5 @@
@baseUrl = http://localhost:8080/api @baseUrl = http://localhost:8080/api
### Sağlık kontrolü ### Sağlık kontrolü (DB erişilemezse 503 + "db": "error")
GET {{baseUrl}}/health GET {{baseUrl}}/health
+63
View File
@@ -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))
}
+30
View File
@@ -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(|_| ())
}
+68
View File
@@ -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
View File
@@ -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 tracing_subscriber::EnvFilter;
use crate::{config::Config, state::AppState};
#[tokio::main] #[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. // .env dosyasını yükle (proje kökünde; üst klasörler de aranır). Yoksa sorun değil.
dotenvy::dotenv().ok(); dotenvy::dotenv().ok();
@@ -15,22 +22,50 @@ async fn main() {
) )
.init(); .init();
let port: u16 = std::env::var("PORT") let config = match Config::from_env() {
.ok() Ok(c) => c,
.and_then(|p| p.parse().ok()) Err(e) => {
.unwrap_or(8080); 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 addr = SocketAddr::from(([0, 0, 0, 0], port));
let listener = tokio::net::TcpListener::bind(addr) let listener = match tokio::net::TcpListener::bind(addr).await {
.await Ok(l) => l,
.expect("Port dinlenemedi"); Err(e) => {
tracing::info!("Kakeibo API çalışıyor: http://localhost:{port}"); tracing::error!(
axum::serve(listener, app).await.expect("Sunucu hatası"); "{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. tracing::info!("Kakeibo API çalışıyor: http://localhost:{port}");
async fn health() -> Json<Value> { if let Err(e) = axum::serve(listener, app).await {
Json(json!({ "status": "ok", "version": env!("CARGO_PKG_VERSION") })) tracing::error!("Sunucu hatası: {e}");
return ExitCode::FAILURE;
}
ExitCode::SUCCESS
} }
View File
+24
View File
@@ -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 })),
)
}
}
}
+13
View File
@@ -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)
}
+15
View File
@@ -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>,
}
+4 -1
View File
@@ -1,7 +1,7 @@
# 🧠 Proje Hafızası # 🧠 Proje Hafızası
## ▶️ Sıradaki Adım ## ▶️ Sıradaki Adım
**Faz 1 – Adım 1:** `config.rs` (ortam değişkenleri), `db.rs` (MongoDB bağlantısı + ping), `error.rs` (Türkçe hata yanıtları), `/api/health` DB durumu. **Faz 1 – Adım 2:** Ortak model alanları (UUID v7, zaman damgaları, version), `schema_migrations` ile migration mekanizması, `DATA_MODEL.md` §4 indeksleri (açılışta otomatik).
## 📌 Alınan Kararlar ## 📌 Alınan Kararlar
- Sürüm kontrolü: Gitea (`https://gitea.ta1sph.info/suphicakir/kakeibo.git`), dal `main`. - Sürüm kontrolü: Gitea (`https://gitea.ta1sph.info/suphicakir/kakeibo.git`), dal `main`.
@@ -27,3 +27,6 @@
- **Adım 3:** `CLAUDE.md`, `docs/MEMORY.md`, `docs/PHASES.md` oluşturuldu. Gitea remote eklendi, ilk commit push edildi. - **Adım 3:** `CLAUDE.md`, `docs/MEMORY.md`, `docs/PHASES.md` oluşturuldu. Gitea remote eklendi, ilk commit push edildi.
- **Adım 4:** Rust kuruldu. `docker-compose.dev.yml` (mongo:7, rs0, healthcheck ile otomatik `rs.initiate`). Backend: Axum, yalnızca `GET /api/health`. Frontend: Vite react-ts şablonu, `/api` proxy → 8080, geçici durum ekranı. Kök `package.json`: concurrently ile `npm run dev`. Uçtan uca test edildi (Mongo PRIMARY, proxy çalışıyor). - **Adım 4:** Rust kuruldu. `docker-compose.dev.yml` (mongo:7, rs0, healthcheck ile otomatik `rs.initiate`). Backend: Axum, yalnızca `GET /api/health`. Frontend: Vite react-ts şablonu, `/api` proxy → 8080, geçici durum ekranı. Kök `package.json`: concurrently ile `npm run dev`. Uçtan uca test edildi (Mongo PRIMARY, proxy çalışıyor).
- **Adım 5:** `docs/DATA_MODEL.md` hazırlandı ve onaylandı. **Faz 0 tamamlandı.** - **Adım 5:** `docs/DATA_MODEL.md` hazırlandı ve onaylandı. **Faz 0 tamamlandı.**
### 2026-11-11 — Faz 1
- **Adım 1:** `config.rs` (env + doğrulama, JWT_SECRET ≥ 32), `db.rs` (5 sn timeout + ping), `error.rs` (`AppError` → `{error:{code,message}}` Türkçe; 5xx detayı yalnızca logda), `state.rs`, `routes/` (health: DB yoksa 503). Açılışta DB/ayar hatasında anlaşılır log + çıkış kodu 1. Elle test: 4 senaryo geçti.
+2 -2
View File
@@ -25,8 +25,8 @@
- ✅ 5. Veri modeli taslağı (`docs/DATA_MODEL.md`) → onaylandı - ✅ 5. Veri modeli taslağı (`docs/DATA_MODEL.md`) → onaylandı
## Faz 1 Adımları ## Faz 1 Adımları
- 🔄 1. `config.rs` + `db.rs` (MongoDB bağlantısı), Türkçe hata yapısı, health check'e DB durumu - ✅ 1. `config.rs` + `db.rs` (MongoDB bağlantısı), Türkçe hata yapısı, health check'e DB durumu
- ⏳ 2. Modeller, migration mekanizması ve indeksler - 🔄 2. Modeller, migration mekanizması ve indeksler
- ⏳ 3. Kimlik doğrulama: kayıt (+ hane + örnek kategori/ödeme türü), giriş, çıkış, `me`; argon2 + JWT cookie + rate limit - ⏳ 3. Kimlik doğrulama: kayıt (+ hane + örnek kategori/ödeme türü), giriş, çıkış, `me`; argon2 + JWT cookie + rate limit
- ⏳ 4. Hane izolasyonu (middleware), üye davet kodu - ⏳ 4. Hane izolasyonu (middleware), üye davet kodu
- ⏳ 5. Kategori ve ödeme türü uç noktaları + hane izolasyonu testleri, `api.http` - ⏳ 5. Kategori ve ödeme türü uç noktaları + hane izolasyonu testleri, `api.http`