1use axum::{
6 Json,
7 extract::{Path, State},
8 http::StatusCode,
9 response::IntoResponse,
10};
11use serde::{Deserialize, Serialize};
12use serde_json::json;
13use std::sync::Arc;
14
15use crate::config::CredentialConfig;
16use crate::error::AppError;
17use crate::server::AppState;
18
19#[derive(Debug, Deserialize)]
21pub struct CreateCredentialRequest {
22 pub id: String,
23 pub adapter: String,
25 pub token: String,
26 #[serde(default = "default_true")]
27 pub active: bool,
28 #[serde(default)]
29 pub emergency: bool,
30 #[serde(default)]
31 pub config: Option<serde_json::Value>,
32 #[serde(default)]
33 pub backend: Option<String>,
34 pub route: serde_json::Value,
35}
36
37fn default_true() -> bool {
38 true
39}
40
41#[derive(Debug, Deserialize)]
43pub struct UpdateCredentialRequest {
44 #[serde(default)]
45 pub adapter: Option<String>,
46 #[serde(default)]
47 pub token: Option<String>,
48 #[serde(default)]
49 pub active: Option<bool>,
50 #[serde(default)]
51 pub emergency: Option<bool>,
52 #[serde(default)]
53 pub config: Option<serde_json::Value>,
54 #[serde(default)]
55 pub backend: Option<String>,
56 #[serde(default)]
57 pub route: Option<serde_json::Value>,
58}
59
60#[derive(Debug, Serialize)]
62pub struct CredentialResponse {
63 pub id: String,
64 pub adapter: String,
65 pub active: bool,
66 pub emergency: bool,
67 pub config: Option<serde_json::Value>,
68 pub backend: Option<String>,
69 pub route: serde_json::Value,
70 pub instance_status: Option<String>,
71}
72
73pub async fn get_credential(
75 State(state): State<Arc<AppState>>,
76 Path(id): Path<String>,
77) -> Result<impl IntoResponse, AppError> {
78 let config = state.config.read().await;
79
80 let cred = config
81 .credentials
82 .get(&id)
83 .ok_or_else(|| AppError::CredentialNotFound(id.clone()))?;
84
85 let instance_status = state.manager.registry.get_status(&id).await;
86
87 Ok(Json(CredentialResponse {
88 id: id.clone(),
89 adapter: cred.adapter.clone(),
90 active: cred.active,
91 emergency: cred.emergency,
92 config: cred.config.clone(),
93 backend: cred.backend.clone(),
94 route: cred.route.clone(),
95 instance_status: instance_status.map(|s| format!("{:?}", s)),
96 }))
97}
98
99pub async fn create_credential(
101 State(state): State<Arc<AppState>>,
102 Json(req): Json<CreateCredentialRequest>,
103) -> Result<impl IntoResponse, AppError> {
104 set_skip_reload(&state).await;
106
107 {
109 let config = state.config.read().await;
110 if config.credentials.contains_key(&req.id) {
111 return Err(AppError::Internal(format!(
112 "Credential already exists: {}",
113 req.id
114 )));
115 }
116 }
117
118 let cred_config = CredentialConfig {
119 adapter: req.adapter,
120 token: req.token,
121 active: req.active,
122 emergency: req.emergency,
123 config: req.config,
124 backend: req.backend,
125 route: req.route,
126 };
127
128 {
130 let mut config = state.config.write().await;
131 config
132 .credentials
133 .insert(req.id.clone(), cred_config.clone());
134 }
135
136 write_config(&state).await?;
138
139 if req.active {
141 state.manager.spawn_task(req.id.clone(), cred_config).await;
142 }
143
144 tracing::info!(credential_id = %req.id, "Credential created");
145
146 Ok((
147 StatusCode::CREATED,
148 Json(json!({
149 "id": req.id,
150 "status": "created"
151 })),
152 ))
153}
154
155pub async fn update_credential(
157 State(state): State<Arc<AppState>>,
158 Path(id): Path<String>,
159 Json(req): Json<UpdateCredentialRequest>,
160) -> Result<impl IntoResponse, AppError> {
161 set_skip_reload(&state).await;
163
164 let old_config;
165 let new_cred_config;
166
167 {
169 let mut config = state.config.write().await;
170
171 let cred = config
172 .credentials
173 .get_mut(&id)
174 .ok_or_else(|| AppError::CredentialNotFound(id.clone()))?;
175
176 old_config = cred.clone();
177
178 if let Some(adapter) = req.adapter {
180 cred.adapter = adapter;
181 }
182 if let Some(token) = req.token {
183 cred.token = token;
184 }
185 if let Some(active) = req.active {
186 cred.active = active;
187 }
188 if let Some(emergency) = req.emergency {
189 cred.emergency = emergency;
190 }
191 if req.config.is_some() {
192 cred.config = req.config;
193 }
194 if req.backend.is_some() {
195 cred.backend = req.backend;
196 }
197 if let Some(route) = req.route {
198 cred.route = route;
199 }
200
201 new_cred_config = cred.clone();
202 }
203
204 write_config(&state).await?;
206
207 let needs_restart = old_config.adapter != new_cred_config.adapter
209 || old_config.token != new_cred_config.token
210 || old_config.config != new_cred_config.config;
211
212 if needs_restart && old_config.active {
213 state.manager.stop_task(&id).await;
214 }
215
216 if new_cred_config.active && (needs_restart || !old_config.active) {
217 state.manager.spawn_task(id.clone(), new_cred_config).await;
218 } else if !new_cred_config.active && old_config.active {
219 state.manager.stop_task(&id).await;
220 }
221
222 tracing::info!(credential_id = %id, "Credential updated");
223
224 Ok(Json(json!({
225 "id": id,
226 "status": "updated"
227 })))
228}
229
230pub async fn delete_credential(
232 State(state): State<Arc<AppState>>,
233 Path(id): Path<String>,
234) -> Result<impl IntoResponse, AppError> {
235 set_skip_reload(&state).await;
237
238 state.manager.stop_task(&id).await;
240
241 {
243 let mut config = state.config.write().await;
244 if config.credentials.remove(&id).is_none() {
245 return Err(AppError::CredentialNotFound(id));
246 }
247 }
248
249 write_config(&state).await?;
251
252 tracing::info!(credential_id = %id, "Credential deleted");
253
254 Ok(Json(json!({
255 "id": id,
256 "status": "deleted"
257 })))
258}
259
260pub async fn activate_credential(
262 State(state): State<Arc<AppState>>,
263 Path(id): Path<String>,
264) -> Result<impl IntoResponse, AppError> {
265 set_skip_reload(&state).await;
267
268 let cred_config;
269
270 {
271 let mut config = state.config.write().await;
272
273 let cred = config
274 .credentials
275 .get_mut(&id)
276 .ok_or_else(|| AppError::CredentialNotFound(id.clone()))?;
277
278 if cred.active {
279 return Ok(Json(json!({
280 "id": id,
281 "status": "already_active"
282 })));
283 }
284
285 cred.active = true;
286 cred_config = cred.clone();
287 }
288
289 write_config(&state).await?;
291
292 state.manager.spawn_task(id.clone(), cred_config).await;
294
295 tracing::info!(credential_id = %id, "Credential activated");
296
297 Ok(Json(json!({
298 "id": id,
299 "status": "activated"
300 })))
301}
302
303pub async fn deactivate_credential(
305 State(state): State<Arc<AppState>>,
306 Path(id): Path<String>,
307) -> Result<impl IntoResponse, AppError> {
308 set_skip_reload(&state).await;
310
311 {
312 let mut config = state.config.write().await;
313
314 let cred = config
315 .credentials
316 .get_mut(&id)
317 .ok_or_else(|| AppError::CredentialNotFound(id.clone()))?;
318
319 if !cred.active {
320 return Ok(Json(json!({
321 "id": id,
322 "status": "already_inactive"
323 })));
324 }
325
326 cred.active = false;
327 }
328
329 write_config(&state).await?;
331
332 state.manager.stop_task(&id).await;
334
335 tracing::info!(credential_id = %id, "Credential deactivated");
336
337 Ok(Json(json!({
338 "id": id,
339 "status": "deactivated"
340 })))
341}
342
343pub async fn set_skip_reload(state: &AppState) {
345 use std::time::{Duration, Instant};
346 let mut skip_until = state.skip_reload_until.write().await;
347 *skip_until = Some(Instant::now() + Duration::from_secs(2));
348}
349
350async fn write_config(state: &AppState) -> Result<(), AppError> {
352 let config_path = std::env::var("GATEWAY_CONFIG").unwrap_or_else(|_| "config.json".to_string());
353
354 let config = state.config.read().await;
355
356 let json = serde_json::to_string_pretty(&*config)
358 .map_err(|e| AppError::Internal(format!("Failed to serialize config: {}", e)))?;
359
360 drop(config); let temp_path = format!("{}.tmp", config_path);
364 tokio::fs::write(&temp_path, &json)
365 .await
366 .map_err(|e| AppError::Internal(format!("Failed to write temp config: {}", e)))?;
367
368 tokio::fs::rename(&temp_path, &config_path)
370 .await
371 .map_err(|e| AppError::Internal(format!("Failed to rename config: {}", e)))?;
372
373 tracing::debug!("Config written to {}", config_path);
374
375 Ok(())
376}
377
378#[cfg(test)]
379mod tests {
380 use super::*;
381 #[test]
384 fn test_create_credential_request_parse_minimal() {
385 let json = r#"{
386 "id": "test_cred",
387 "adapter": "telegram",
388 "token": "secret123",
389 "route": {"type": "default"}
390 }"#;
391
392 let req: CreateCredentialRequest = serde_json::from_str(json).unwrap();
393 assert_eq!(req.id, "test_cred");
394 assert_eq!(req.adapter, "telegram");
395 assert_eq!(req.token, "secret123");
396 assert!(req.active); assert!(!req.emergency); assert!(req.config.is_none());
399 assert!(req.backend.is_none());
400 }
401
402 #[test]
403 fn test_create_credential_request_parse_full() {
404 let json = r#"{
405 "id": "test_cred",
406 "adapter": "telegram",
407 "token": "secret123",
408 "active": false,
409 "emergency": true,
410 "config": {"chat_id": "123"},
411 "backend": "pipelit",
412 "route": {"type": "custom", "path": "/api"}
413 }"#;
414
415 let req: CreateCredentialRequest = serde_json::from_str(json).unwrap();
416 assert_eq!(req.id, "test_cred");
417 assert!(!req.active);
418 assert!(req.emergency);
419 assert!(req.config.is_some());
420 assert_eq!(req.backend, Some("pipelit".to_string()));
421 }
422
423 #[test]
424 fn test_update_credential_request_parse_empty() {
425 let json = r#"{}"#;
426
427 let req: UpdateCredentialRequest = serde_json::from_str(json).unwrap();
428 assert!(req.adapter.is_none());
429 assert!(req.token.is_none());
430 assert!(req.active.is_none());
431 assert!(req.emergency.is_none());
432 assert!(req.config.is_none());
433 assert!(req.backend.is_none());
434 assert!(req.route.is_none());
435 }
436
437 #[test]
438 fn test_update_credential_request_parse_partial() {
439 let json = r#"{
440 "active": true,
441 "token": "new_token"
442 }"#;
443
444 let req: UpdateCredentialRequest = serde_json::from_str(json).unwrap();
445 assert!(req.adapter.is_none());
446 assert_eq!(req.token, Some("new_token".to_string()));
447 assert_eq!(req.active, Some(true));
448 assert!(req.emergency.is_none());
449 }
450
451 #[test]
452 fn test_update_credential_request_parse_full() {
453 let json = r#"{
454 "adapter": "discord",
455 "token": "new_token",
456 "active": false,
457 "emergency": true,
458 "config": {"setting": "value"},
459 "backend": "opencode",
460 "route": {"new": "route"}
461 }"#;
462
463 let req: UpdateCredentialRequest = serde_json::from_str(json).unwrap();
464 assert_eq!(req.adapter, Some("discord".to_string()));
465 assert_eq!(req.token, Some("new_token".to_string()));
466 assert_eq!(req.active, Some(false));
467 assert_eq!(req.emergency, Some(true));
468 assert!(req.config.is_some());
469 assert_eq!(req.backend, Some("opencode".to_string()));
470 assert!(req.route.is_some());
471 }
472
473 #[test]
474 fn test_credential_response_serialize() {
475 let response = CredentialResponse {
476 id: "cred1".to_string(),
477 adapter: "telegram".to_string(),
478 active: true,
479 emergency: false,
480 config: Some(serde_json::json!({"key": "value"})),
481 backend: None,
482 route: serde_json::json!({"type": "default"}),
483 instance_status: Some("Running".to_string()),
484 };
485
486 let json = serde_json::to_string(&response).unwrap();
487 assert!(json.contains("\"id\":\"cred1\""));
488 assert!(json.contains("\"adapter\":\"telegram\""));
489 assert!(json.contains("\"active\":true"));
490 assert!(json.contains("\"emergency\":false"));
491 assert!(json.contains("\"instance_status\":\"Running\""));
492 }
493
494 #[test]
495 fn test_credential_response_serialize_minimal() {
496 let response = CredentialResponse {
497 id: "cred2".to_string(),
498 adapter: "generic".to_string(),
499 active: false,
500 emergency: true,
501 config: None,
502 backend: None,
503 route: serde_json::json!(null),
504 instance_status: None,
505 };
506
507 let json = serde_json::to_string(&response).unwrap();
508 assert!(json.contains("\"id\":\"cred2\""));
509 assert!(json.contains("\"config\":null"));
510 assert!(json.contains("\"instance_status\":null"));
511 }
512
513 #[test]
514 fn test_default_true() {
515 assert!(default_true());
516 }
517
518 #[test]
521 fn test_create_credential_request_debug() {
522 let req = CreateCredentialRequest {
523 id: "test".to_string(),
524 adapter: "telegram".to_string(),
525 token: "secret".to_string(),
526 active: true,
527 emergency: false,
528 config: None,
529 backend: None,
530 route: serde_json::json!({}),
531 };
532
533 let debug_str = format!("{:?}", req);
534 assert!(debug_str.contains("CreateCredentialRequest"));
535 assert!(debug_str.contains("test"));
536 }
537
538 #[test]
539 fn test_update_credential_request_debug() {
540 let req = UpdateCredentialRequest {
541 adapter: Some("discord".to_string()),
542 token: None,
543 active: Some(true),
544 emergency: None,
545 config: None,
546 backend: None,
547 route: None,
548 };
549
550 let debug_str = format!("{:?}", req);
551 assert!(debug_str.contains("UpdateCredentialRequest"));
552 assert!(debug_str.contains("discord"));
553 }
554
555 #[test]
556 fn test_credential_response_debug() {
557 let response = CredentialResponse {
558 id: "cred1".to_string(),
559 adapter: "telegram".to_string(),
560 active: true,
561 emergency: false,
562 config: None,
563 backend: None,
564 route: serde_json::json!({}),
565 instance_status: None,
566 };
567
568 let debug_str = format!("{:?}", response);
569 assert!(debug_str.contains("CredentialResponse"));
570 assert!(debug_str.contains("cred1"));
571 }
572
573 #[test]
576 fn test_create_request_with_backend_name() {
577 let json = r#"{
578 "id": "test",
579 "adapter": "telegram",
580 "token": "tok",
581 "route": {},
582 "backend": "pipelit"
583 }"#;
584
585 let req: CreateCredentialRequest = serde_json::from_str(json).unwrap();
586 assert_eq!(req.backend, Some("pipelit".to_string()));
587 }
588
589 #[test]
590 fn test_create_request_without_backend() {
591 let json = r#"{
592 "id": "test",
593 "adapter": "generic",
594 "token": "tok",
595 "route": {}
596 }"#;
597
598 let req: CreateCredentialRequest = serde_json::from_str(json).unwrap();
599 assert!(req.backend.is_none());
600 }
601
602 #[tokio::test]
605 async fn test_set_skip_reload() {
606 use std::time::Instant;
607 use tokio::sync::RwLock;
608
609 let skip_reload_until: RwLock<Option<Instant>> = RwLock::new(None);
611
612 assert!(skip_reload_until.read().await.is_none());
614
615 {
617 use std::time::Duration;
618 let mut skip_until = skip_reload_until.write().await;
619 *skip_until = Some(Instant::now() + Duration::from_secs(2));
620 }
621
622 let value = skip_reload_until.read().await;
624 assert!(value.is_some());
625 assert!(value.unwrap() > Instant::now());
627 }
628
629 #[test]
632 fn test_credential_config_from_create_request() {
633 let req = CreateCredentialRequest {
634 id: "test_id".to_string(),
635 adapter: "telegram".to_string(),
636 token: "test_token".to_string(),
637 active: true,
638 emergency: true,
639 config: Some(serde_json::json!({"key": "value"})),
640 backend: Some("pipelit".to_string()),
641 route: serde_json::json!({"route": "data"}),
642 };
643
644 let cred_config = CredentialConfig {
645 adapter: req.adapter.clone(),
646 token: req.token.clone(),
647 active: req.active,
648 emergency: req.emergency,
649 config: req.config.clone(),
650 backend: req.backend.clone(),
651 route: req.route.clone(),
652 };
653
654 assert_eq!(cred_config.adapter, "telegram");
655 assert_eq!(cred_config.token, "test_token");
656 assert!(cred_config.active);
657 assert!(cred_config.emergency);
658 assert!(cred_config.config.is_some());
659 assert_eq!(cred_config.backend, Some("pipelit".to_string()));
660 }
661
662 #[test]
663 fn test_update_applies_partial_changes() {
664 let mut cred = CredentialConfig {
665 adapter: "telegram".to_string(),
666 token: "old_token".to_string(),
667 active: true,
668 emergency: false,
669 config: None,
670 backend: None,
671 route: serde_json::json!({"old": "route"}),
672 };
673
674 let update = UpdateCredentialRequest {
675 adapter: None,
676 token: Some("new_token".to_string()),
677 active: Some(false),
678 emergency: None,
679 config: None,
680 backend: None,
681 route: None,
682 };
683
684 if let Some(token) = update.token {
686 cred.token = token;
687 }
688 if let Some(active) = update.active {
689 cred.active = active;
690 }
691
692 assert_eq!(cred.adapter, "telegram"); assert_eq!(cred.token, "new_token"); assert!(!cred.active); assert!(!cred.emergency); }
697}