feat(arayüz): API istemcisi, TanStack Query, ts-rs tip üretimi ve oturum durumu

- Backend: ts-rs ile DTO'lardan TypeScript tipleri (frontend/src/types, npm run types)
- Hata ve health yanıtları tipli yapılara çevrildi
- lib/api.ts: tek API istemcisi, Türkçe ApiError, ağ hatası mesajı
- lib/query.ts: QueryClient (4xx tekrar denenmez, 401 → oturum kapanır)
- lib/auth.ts: useMe, useLogin, useRegister, useLogout
- Vitest: 7 API istemcisi testi (toplam 29)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-12 00:39:56 +03:00
parent f9be08114d
commit 906f05f62f
41 changed files with 558 additions and 49 deletions
+3
View File
@@ -0,0 +1,3 @@
# ts-rs: Rust DTO'larından üretilen TypeScript tipleri buraya yazılır (npm run types)
[env]
TS_RS_EXPORT_DIR = { value = "../frontend/src/types", relative = true }
+32
View File
@@ -1298,6 +1298,7 @@ dependencies = [
"tower",
"tracing",
"tracing-subscriber",
"ts-rs",
"uuid",
"validator",
]
@@ -2429,6 +2430,15 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
[[package]]
name = "termcolor"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
dependencies = [
"winapi-util",
]
[[package]]
name = "thiserror"
version = "2.0.21"
@@ -2656,6 +2666,28 @@ dependencies = [
"tracing-log",
]
[[package]]
name = "ts-rs"
version = "12.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "756050066659291d47a554a9f558125db17428b073c5ffce1daf5dcb0f7231d8"
dependencies = [
"thiserror",
"ts-rs-macros",
]
[[package]]
name = "ts-rs-macros"
version = "12.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38d90eea51bc7988ef9e674bf80a85ba6804739e535e9cab48e4bb34a8b652aa"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"termcolor",
]
[[package]]
name = "typed-builder"
version = "0.22.0"
+1
View File
@@ -17,6 +17,7 @@ time = "0.3.55"
tokio = { version = "1.54.1", features = ["full"] }
tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
ts-rs = "12.0.1"
uuid = { version = "1.29.2", features = ["v4", "v7"] }
validator = { version = "0.21.0", features = ["derive"] }
+25 -6
View File
@@ -6,7 +6,24 @@ use axum::{
http::StatusCode,
response::{IntoResponse, Response},
};
use serde_json::json;
use serde::Serialize;
use ts_rs::TS;
/// Tüm hata yanıtlarının gövdesi: `{ "error": { "code": "...", "message": "..." } }`
#[derive(Serialize, TS)]
#[ts(export)]
pub struct ApiErrorBody {
pub error: ApiErrorDetail,
}
#[derive(Serialize, TS)]
#[ts(export)]
pub struct ApiErrorDetail {
/// Makine için: `unauthorized`, `bad_request`, `conflict`, `not_found`, ...
pub code: String,
/// Kullanıcıya gösterilecek Türkçe mesaj
pub message: String,
}
#[derive(Debug, thiserror::Error)]
pub enum AppError {
@@ -59,11 +76,13 @@ impl IntoResponse for AppError {
self.to_string()
};
(
status,
Json(json!({ "error": { "code": code, "message": message } })),
)
.into_response()
let body = ApiErrorBody {
error: ApiErrorDetail {
code: code.into(),
message,
},
};
(status, Json(body)).into_response()
}
}
+3 -1
View File
@@ -1,9 +1,11 @@
//! 4 sabit ana grup (DATA_MODEL.md §2). Değiştirilemez, silinemez; veritabanında değil kodda.
use serde::{Deserialize, Serialize};
use ts_rs::TS;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, TS)]
#[serde(rename_all = "lowercase")]
#[ts(export)]
pub enum Group {
/// 🏠 Zorunlu
Needs,
+3 -1
View File
@@ -2,9 +2,11 @@
use mongodb::bson::Uuid;
use serde::{Deserialize, Serialize};
use ts_rs::TS;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "lowercase")]
#[ts(export)]
pub enum Role {
Owner,
Member,
+11 -5
View File
@@ -5,6 +5,7 @@ use axum::{Json, extract::State, http::StatusCode};
use axum_extra::extract::cookie::CookieJar;
use mongodb::bson::{Uuid, doc};
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use validator::Validate;
use crate::{
@@ -26,7 +27,8 @@ use crate::{
// ---------- İstek / yanıt yapıları ----------
#[derive(Deserialize, Validate)]
#[derive(Deserialize, Validate, TS)]
#[ts(export, optional_fields)]
pub struct RegisterRequest {
#[validate(length(min = 1, max = 80, message = "Ad 1–80 karakter olmalı"))]
name: String,
@@ -41,7 +43,8 @@ pub struct RegisterRequest {
invite_code: Option<String>,
}
#[derive(Deserialize, Validate)]
#[derive(Deserialize, Validate, TS)]
#[ts(export)]
pub struct LoginRequest {
#[validate(length(min = 1, message = "E-posta gerekli"))]
email: String,
@@ -51,13 +54,15 @@ pub struct LoginRequest {
remember_me: bool,
}
#[derive(Serialize)]
#[derive(Serialize, TS)]
#[ts(export)]
pub struct MeResponse {
user: UserDto,
household: HouseholdDto,
}
#[derive(Serialize)]
#[derive(Serialize, TS)]
#[ts(export, rename = "User")]
struct UserDto {
id: String,
name: String,
@@ -65,7 +70,8 @@ struct UserDto {
role: Role,
}
#[derive(Serialize)]
#[derive(Serialize, TS)]
#[ts(export, rename = "HouseholdSummary")]
struct HouseholdDto {
id: String,
name: String,
+10 -4
View File
@@ -16,6 +16,7 @@ use axum::{
};
use mongodb::bson::Uuid;
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use validator::Validate;
use crate::{
@@ -35,7 +36,8 @@ pub struct ListQuery {
include_inactive: bool,
}
#[derive(Deserialize, Validate)]
#[derive(Deserialize, Validate, TS)]
#[ts(export, rename = "CatalogCreateRequest", optional_fields)]
pub struct CreateRequest {
group: Option<Group>,
#[validate(length(max = 40, message = "Ad en fazla 40 karakter olabilir"))]
@@ -44,7 +46,8 @@ pub struct CreateRequest {
emoji: Option<String>,
}
#[derive(Deserialize, Validate)]
#[derive(Deserialize, Validate, TS)]
#[ts(export, rename = "CatalogUpdateRequest", optional_fields)]
pub struct UpdateRequest {
#[validate(length(max = 40, message = "Ad en fazla 40 karakter olabilir"))]
name: Option<String>,
@@ -54,17 +57,20 @@ pub struct UpdateRequest {
version: Option<i32>,
}
#[derive(Deserialize, Validate)]
#[derive(Deserialize, Validate, TS)]
#[ts(export, rename = "CatalogOrderRequest", optional_fields)]
pub struct OrderRequest {
group: Option<Group>,
#[validate(length(max = 200, message = "Liste çok uzun"))]
ids: Vec<String>,
}
#[derive(Serialize)]
#[derive(Serialize, TS)]
#[ts(export, rename = "CatalogItem")]
pub struct ItemDto {
id: String,
#[serde(skip_serializing_if = "Option::is_none")]
#[ts(optional)]
group: Option<Group>,
name: String,
emoji: Option<String>,
+23 -4
View File
@@ -1,23 +1,42 @@
use axum::{Json, extract::State, http::StatusCode};
use serde_json::{Value, json};
use serde::Serialize;
use ts_rs::TS;
use crate::{db, state::AppState};
#[derive(Serialize, TS)]
#[ts(export)]
pub struct HealthResponse {
/// `ok` veya `degraded`
status: &'static str,
/// `ok` veya `error`
db: &'static str,
version: &'static str,
}
/// 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>) {
pub async fn health(State(state): State<AppState>) -> (StatusCode, Json<HealthResponse>) {
let version = env!("CARGO_PKG_VERSION");
match db::ping(&state.db).await {
Ok(()) => (
StatusCode::OK,
Json(json!({ "status": "ok", "db": "ok", "version": version })),
Json(HealthResponse {
status: "ok",
db: "ok",
version,
}),
),
Err(e) => {
tracing::warn!(error = %e, "health: veritabanına erişilemiyor");
(
StatusCode::SERVICE_UNAVAILABLE,
Json(json!({ "status": "degraded", "db": "error", "version": version })),
Json(HealthResponse {
status: "degraded",
db: "error",
version,
}),
)
}
}
+7 -3
View File
@@ -11,6 +11,7 @@ use axum::{
};
use mongodb::bson::{DateTime, Uuid, doc};
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use crate::{
auth::AuthUser,
@@ -21,14 +22,16 @@ use crate::{
state::AppState,
};
#[derive(Serialize)]
#[derive(Serialize, TS)]
#[ts(export, rename = "Household")]
pub struct HouseholdResponse {
id: String,
name: String,
members: Vec<MemberDto>,
}
#[derive(Serialize)]
#[derive(Serialize, TS)]
#[ts(export, rename = "Member")]
struct MemberDto {
id: String,
name: String,
@@ -36,7 +39,8 @@ struct MemberDto {
role: Role,
}
#[derive(Serialize)]
#[derive(Serialize, TS)]
#[ts(export, rename = "Invite")]
pub struct InviteDto {
id: String,
code: String,