1use super::state::AppState;
2use crate::application::ProvisioningError;
3use crate::domain::{
4 Account, AlgorithmMode, AssetFocus, Bot, BotConfig, BotSecrets, Persona, RiskConfig,
5 SignalKnobs, StrictnessLevel, TradingConfig,
6};
7use crate::infrastructure::{AccountRepository, DigitalOceanError};
8use axum::{
9 extract::{Path, Query, State},
10 http::{header, header::HeaderMap, StatusCode},
11 response::IntoResponse,
12 routing::{get, post},
13 Json, Router,
14};
15use serde::{Deserialize, Serialize};
16use tracing::{error, info};
17use utoipa::{IntoParams, OpenApi, ToSchema};
18use utoipa_swagger_ui::SwaggerUi;
19use uuid::Uuid;
20
21pub fn router(state: AppState) -> Router {
22 Router::new()
23 .route("/health", get(health_check))
24 .route("/accounts", post(create_account))
25 .route("/accounts/:id", get(get_account))
26 .route("/accounts/:id/bots", get(list_bots))
27 .route("/bots", post(create_bot))
28 .route("/bots/:id", get(get_bot))
29 .route("/bots/:id/config", get(get_bot_config))
30 .route("/bots/:id/actions", post(bot_action))
31 .route("/bot/register", post(register_bot))
32 .route("/bot/:id/config", get(get_desired_config))
33 .route("/bot/:id/config_ack", post(acknowledge_config))
34 .route("/bot/:id/heartbeat", post(record_heartbeat))
35 .merge(SwaggerUi::new("/docs").url("/api-docs/openapi.json", ApiDoc::openapi()))
36 .with_state(state)
37}
38
39fn extract_bearer_token(headers: &HeaderMap) -> Option<&str> {
40 headers
41 .get(header::AUTHORIZATION)
42 .and_then(|v| v.to_str().ok())
43 .and_then(|s| s.strip_prefix("Bearer "))
44 .filter(|t| !t.is_empty())
45}
46
47fn parse_subscription_tier(tier: &str) -> Option<crate::domain::SubscriptionTier> {
48 match tier {
49 "free" => Some(crate::domain::SubscriptionTier::Free),
50 "basic" => Some(crate::domain::SubscriptionTier::Basic),
51 "pro" => Some(crate::domain::SubscriptionTier::Pro),
52 _ => None,
53 }
54}
55
56fn parse_persona(persona: &str) -> Option<Persona> {
57 match persona {
58 "beginner" => Some(Persona::Beginner),
59 "tweaker" => Some(Persona::Tweaker),
60 "quant_lite" => Some(Persona::QuantLite),
61 _ => None,
62 }
63}
64
65fn parse_asset_focus(asset_focus: &str) -> Option<AssetFocus> {
66 match asset_focus {
67 "majors" => Some(AssetFocus::Majors),
68 "memes" => Some(AssetFocus::Memes),
69 _ => None,
70 }
71}
72
73fn parse_algorithm(algorithm: &str) -> Option<AlgorithmMode> {
74 match algorithm {
75 "trend" => Some(AlgorithmMode::Trend),
76 "mean_reversion" => Some(AlgorithmMode::MeanReversion),
77 "breakout" => Some(AlgorithmMode::Breakout),
78 _ => None,
79 }
80}
81
82fn parse_strictness(strictness: &str) -> Option<StrictnessLevel> {
83 match strictness {
84 "low" => Some(StrictnessLevel::Low),
85 "medium" => Some(StrictnessLevel::Medium),
86 "high" => Some(StrictnessLevel::High),
87 _ => None,
88 }
89}
90
91#[cfg(test)]
92mod tests {
93 use super::*;
94 use axum::http::HeaderValue;
95
96 #[test]
97 fn extract_bearer_token_happy_path() {
98 let mut headers = HeaderMap::new();
99 headers.insert(
100 header::AUTHORIZATION,
101 HeaderValue::from_static("Bearer abc123"),
102 );
103 assert_eq!(extract_bearer_token(&headers), Some("abc123"));
104 }
105
106 #[test]
107 fn extract_bearer_token_rejects_missing_or_empty() {
108 let headers = HeaderMap::new();
109 assert_eq!(extract_bearer_token(&headers), None);
110
111 let mut headers2 = HeaderMap::new();
112 headers2.insert(header::AUTHORIZATION, HeaderValue::from_static("Bearer "));
113 assert_eq!(extract_bearer_token(&headers2), None);
114 }
115
116 #[test]
117 fn extract_bearer_token_rejects_wrong_scheme() {
118 let mut headers = HeaderMap::new();
119 headers.insert(
120 header::AUTHORIZATION,
121 HeaderValue::from_static("Basic abc123"),
122 );
123 assert_eq!(extract_bearer_token(&headers), None);
124 }
125
126 #[test]
127 fn parse_invalid_inputs_return_none() {
128 assert!(parse_subscription_tier("nope").is_none());
129 assert!(parse_persona("nope").is_none());
130 assert!(parse_asset_focus("nope").is_none());
131 assert!(parse_algorithm("nope").is_none());
132 assert!(parse_strictness("nope").is_none());
133 }
134}
135
136#[derive(OpenApi)]
138#[openapi(
139 paths(
140 health_check,
141 create_account,
142 get_account,
143 list_bots,
144 create_bot,
145 get_bot,
146 get_bot_config,
147 bot_action,
148 register_bot,
149 get_desired_config,
150 acknowledge_config,
151 record_heartbeat,
152 ),
153 components(
154 schemas(
155 CreateAccountRequest,
156 CreateBotRequest,
157 BotActionRequest,
158 RegisterBotRequest,
159 AckConfigRequest,
160 BotResponse,
161 HealthResponse,
162 )
163 ),
164 tags(
165 (name = "Health", description = "Health check endpoints"),
166 (name = "Accounts", description = "Account management endpoints"),
167 (name = "Bots", description = "Bot management and lifecycle endpoints"),
168 (name = "Configuration", description = "Bot configuration endpoints"),
169 ),
170 info(
171 title = "Claw Spawn API",
172 version = "0.1.0",
173 description = "API for managing trading bot provisioning and lifecycle",
174 license(name = "MIT OR Apache-2.0")
175 )
176)]
177struct ApiDoc;
178
179#[derive(Serialize, ToSchema)]
181struct HealthResponse {
182 status: String,
183 #[serde(skip_serializing_if = "Option::is_none")]
184 error: Option<String>,
185}
186
187#[utoipa::path(
191 get,
192 path = "/health",
193 tag = "Health",
194 responses(
195 (status = 200, description = "Service is healthy", body = HealthResponse),
196 (status = 503, description = "Service is unhealthy", body = HealthResponse)
197 )
198)]
199async fn health_check(State(state): State<AppState>) -> impl IntoResponse {
200 match sqlx::query("SELECT 1").fetch_one(&state.pool).await {
201 Ok(_) => (
202 StatusCode::OK,
203 Json(HealthResponse {
204 status: "healthy".to_string(),
205 error: None,
206 }),
207 ),
208 Err(e) => {
209 error!(error = %e, "Health check failed: DB connectivity issue");
210 (
211 StatusCode::SERVICE_UNAVAILABLE,
212 Json(HealthResponse {
213 status: "unhealthy".to_string(),
214 error: Some("Database connectivity failed".to_string()),
215 }),
216 )
217 }
218 }
219}
220
221#[derive(Deserialize, ToSchema)]
223struct CreateAccountRequest {
224 #[schema(example = "user-123")]
225 external_id: String,
226 #[schema(example = "pro")]
227 tier: String,
228}
229
230#[utoipa::path(
232 post,
233 path = "/accounts",
234 tag = "Accounts",
235 request_body = CreateAccountRequest,
236 responses(
237 (status = 201, description = "Account created successfully", body = Object),
238 (status = 400, description = "Invalid subscription tier", body = Object),
239 (status = 500, description = "Failed to create account", body = Object)
240 )
241)]
242async fn create_account(
243 State(state): State<AppState>,
244 Json(req): Json<CreateAccountRequest>,
245) -> impl IntoResponse {
246 let tier = match parse_subscription_tier(req.tier.as_str()) {
247 Some(t) => t,
248 None => {
249 return (
250 StatusCode::BAD_REQUEST,
251 Json(serde_json::json!({
252 "error": "Invalid subscription tier",
253 "allowed": ["free", "basic", "pro"]
254 })),
255 );
256 }
257 };
258
259 let account = Account::new(req.external_id, tier);
260 if let Err(e) = state.account_repo.create(&account).await {
261 error!(error = %e, "Failed to create account");
262 return (
263 StatusCode::INTERNAL_SERVER_ERROR,
264 Json(serde_json::json!({"error": "Failed to create account"})),
265 );
266 }
267
268 (
269 StatusCode::CREATED,
270 Json(serde_json::json!({"id": account.id})),
271 )
272}
273
274#[utoipa::path(
275 get,
276 path = "/accounts/{id}",
277 tag = "Accounts",
278 params(("id" = Uuid, Path, description = "Account ID")),
279 responses((status = 501, description = "Not implemented"))
280)]
281async fn get_account(State(_state): State<AppState>, Path(_id): Path<Uuid>) -> impl IntoResponse {
282 (StatusCode::NOT_IMPLEMENTED, "Get account not implemented")
283}
284
285#[derive(Deserialize, Debug, IntoParams, ToSchema)]
286struct PaginationParams {
287 #[serde(default = "default_limit")]
288 #[param(default = 100, maximum = 1000)]
289 limit: i64,
290 #[serde(default)]
291 #[param(default = 0)]
292 offset: i64,
293}
294
295fn default_limit() -> i64 {
296 100
297}
298
299const MAX_PAGINATION_LIMIT: i64 = 1000;
300
301#[utoipa::path(
302 get,
303 path = "/accounts/{id}/bots",
304 tag = "Bots",
305 params(("id" = Uuid, Path, description = "Account ID"), PaginationParams),
306 responses(
307 (status = 200, description = "List of bots", body = [BotResponse]),
308 (status = 500, description = "Failed to list bots", body = Object)
309 )
310)]
311async fn list_bots(
312 State(state): State<AppState>,
313 Path(account_id): Path<Uuid>,
314 Query(params): Query<PaginationParams>,
315) -> impl IntoResponse {
316 let limit = params.limit.clamp(1, MAX_PAGINATION_LIMIT);
317 let offset = params.offset.max(0);
318
319 match state
320 .lifecycle
321 .list_account_bots(account_id, limit, offset)
322 .await
323 {
324 Ok(bots) => {
325 let bot_responses: Vec<BotResponse> = bots.into_iter().map(Into::into).collect();
326 (StatusCode::OK, Json(serde_json::json!(bot_responses)))
327 }
328 Err(e) => {
329 error!(error = %e, "Failed to list bots");
330 (
331 StatusCode::INTERNAL_SERVER_ERROR,
332 Json(serde_json::json!({"error": "Failed to list bots"})),
333 )
334 }
335 }
336}
337
338#[derive(Deserialize, ToSchema)]
339struct CreateBotRequest {
340 account_id: Uuid,
341 name: String,
342 persona: String,
343 asset_focus: String,
344 algorithm: String,
345 strictness: String,
346 paper_mode: bool,
347 max_position_size_pct: f64,
348 max_daily_loss_pct: f64,
349 max_drawdown_pct: f64,
350 max_trades_per_day: i32,
351 llm_provider: String,
352 llm_api_key: String,
353}
354
355#[utoipa::path(
356 post,
357 path = "/bots",
358 tag = "Bots",
359 request_body = CreateBotRequest,
360 responses(
361 (status = 201, description = "Bot created successfully", body = BotResponse),
362 (status = 400, description = "Invalid risk configuration", body = Object),
363 (status = 403, description = "Account limit reached", body = Object),
364 (status = 429, description = "Rate limited by DigitalOcean", body = Object),
365 (status = 500, description = "Failed to create bot", body = Object)
366 )
367)]
368async fn create_bot(
369 State(state): State<AppState>,
370 Json(req): Json<CreateBotRequest>,
371) -> impl IntoResponse {
372 let persona = match parse_persona(req.persona.as_str()) {
373 Some(p) => p,
374 None => {
375 return (
376 StatusCode::BAD_REQUEST,
377 Json(serde_json::json!({
378 "error": "Invalid persona",
379 "allowed": ["beginner", "tweaker", "quant_lite"]
380 })),
381 );
382 }
383 };
384
385 let asset_focus = match parse_asset_focus(req.asset_focus.as_str()) {
386 Some(a) => a,
387 None => {
388 return (
389 StatusCode::BAD_REQUEST,
390 Json(serde_json::json!({
391 "error": "Invalid asset_focus",
392 "allowed": ["majors", "memes"]
393 })),
394 );
395 }
396 };
397
398 let algorithm = match parse_algorithm(req.algorithm.as_str()) {
399 Some(a) => a,
400 None => {
401 return (
402 StatusCode::BAD_REQUEST,
403 Json(serde_json::json!({
404 "error": "Invalid algorithm",
405 "allowed": ["trend", "mean_reversion", "breakout"]
406 })),
407 );
408 }
409 };
410
411 let strictness = match parse_strictness(req.strictness.as_str()) {
412 Some(s) => s,
413 None => {
414 return (
415 StatusCode::BAD_REQUEST,
416 Json(serde_json::json!({
417 "error": "Invalid strictness",
418 "allowed": ["low", "medium", "high"]
419 })),
420 );
421 }
422 };
423
424 let trading_config = TradingConfig {
425 asset_focus,
426 algorithm,
427 strictness,
428 paper_mode: req.paper_mode,
429 signal_knobs: if matches!(persona, Persona::QuantLite) {
430 Some(SignalKnobs {
431 volume_confirmation: true,
432 volatility_brake: true,
433 liquidity_filter: StrictnessLevel::Medium,
434 correlation_brake: true,
435 })
436 } else {
437 None
438 },
439 };
440
441 let risk_config = RiskConfig {
442 max_position_size_pct: req.max_position_size_pct,
443 max_daily_loss_pct: req.max_daily_loss_pct,
444 max_drawdown_pct: req.max_drawdown_pct,
445 max_trades_per_day: req.max_trades_per_day,
446 };
447
448 if let Err(errors) = risk_config.validate() {
449 error!(errors = ?errors, "RiskConfig validation failed");
450 return (
451 StatusCode::BAD_REQUEST,
452 Json(serde_json::json!({"error": "Invalid risk configuration", "details": errors})),
453 );
454 }
455
456 let config = BotConfig {
457 id: Uuid::new_v4(),
458 bot_id: Uuid::new_v4(),
459 version: 1,
460 trading_config,
461 risk_config,
462 secrets: BotSecrets {
463 llm_provider: req.llm_provider,
464 llm_api_key: req.llm_api_key,
465 },
466 created_at: chrono::Utc::now(),
467 };
468
469 match state
470 .provisioning
471 .create_bot(req.account_id, req.name, persona, config)
472 .await
473 {
474 Ok(bot) => (
475 StatusCode::CREATED,
476 Json(serde_json::json!(BotResponse::from(bot))),
477 ),
478 Err(ProvisioningError::AccountLimitReached(max)) => (
479 StatusCode::FORBIDDEN,
480 Json(serde_json::json!({
481 "error": format!("Account limit reached: maximum {} bots allowed", max)
482 })),
483 ),
484 Err(ProvisioningError::DigitalOcean(DigitalOceanError::RateLimited)) => (
485 StatusCode::TOO_MANY_REQUESTS,
486 Json(serde_json::json!({"error": "Rate limited by DigitalOcean, please retry"})),
487 ),
488 Err(e) => {
489 error!(error = %e, "Failed to create bot");
490 (
491 StatusCode::INTERNAL_SERVER_ERROR,
492 Json(serde_json::json!({"error": "Failed to create bot"})),
493 )
494 }
495 }
496}
497
498#[utoipa::path(
499 get,
500 path = "/bots/{id}",
501 tag = "Bots",
502 params(("id" = Uuid, Path, description = "Bot ID")),
503 responses(
504 (status = 200, description = "Bot found", body = BotResponse),
505 (status = 404, description = "Bot not found", body = Object)
506 )
507)]
508async fn get_bot(State(state): State<AppState>, Path(id): Path<Uuid>) -> impl IntoResponse {
509 match state.lifecycle.get_bot(id).await {
510 Ok(bot) => (
511 StatusCode::OK,
512 Json(serde_json::json!(BotResponse::from(bot))),
513 ),
514 Err(_) => (
515 StatusCode::NOT_FOUND,
516 Json(serde_json::json!({"error": "Bot not found"})),
517 ),
518 }
519}
520
521#[utoipa::path(
522 get,
523 path = "/bots/{id}/config",
524 tag = "Configuration",
525 params(("id" = Uuid, Path, description = "Bot ID")),
526 responses(
527 (status = 200, description = "Configuration found", body = Object),
528 (status = 404, description = "No config found", body = Object),
529 (status = 500, description = "Failed to get config", body = Object)
530 )
531)]
532async fn get_bot_config(State(state): State<AppState>, Path(id): Path<Uuid>) -> impl IntoResponse {
533 match state.lifecycle.get_desired_config(id).await {
534 Ok(Some(config)) => (StatusCode::OK, Json(serde_json::json!(config))),
535 Ok(None) => (
536 StatusCode::NOT_FOUND,
537 Json(serde_json::json!({"error": "No config found"})),
538 ),
539 Err(_) => (
540 StatusCode::INTERNAL_SERVER_ERROR,
541 Json(serde_json::json!({"error": "Failed to get config"})),
542 ),
543 }
544}
545
546#[derive(Deserialize, ToSchema)]
547struct BotActionRequest {
548 action: String,
549}
550
551#[utoipa::path(
552 post,
553 path = "/bots/{id}/actions",
554 tag = "Bots",
555 params(("id" = Uuid, Path, description = "Bot ID")),
556 request_body = BotActionRequest,
557 responses(
558 (status = 200, description = "Action completed successfully", body = Object),
559 (status = 400, description = "Invalid action", body = Object),
560 (status = 500, description = "Action failed", body = Object)
561 )
562)]
563async fn bot_action(
564 State(state): State<AppState>,
565 Path(id): Path<Uuid>,
566 Json(req): Json<BotActionRequest>,
567) -> impl IntoResponse {
568 let result = match req.action.as_str() {
569 "pause" => state.provisioning.pause_bot(id).await,
570 "resume" => state.provisioning.resume_bot(id).await,
571 "redeploy" => state.provisioning.redeploy_bot(id).await,
572 "destroy" => state.provisioning.destroy_bot(id).await,
573 _ => Err(ProvisioningError::InvalidConfig(
574 "Unknown action".to_string(),
575 )),
576 };
577
578 match result {
579 Ok(_) => (StatusCode::OK, Json(serde_json::json!({"status": "ok"}))),
580 Err(e) => {
581 error!(error = %e, "Bot action failed");
582 (
583 StatusCode::INTERNAL_SERVER_ERROR,
584 Json(serde_json::json!({"error": "Action failed"})),
585 )
586 }
587 }
588}
589
590#[derive(Deserialize, ToSchema)]
591struct RegisterBotRequest {
592 bot_id: Uuid,
593}
594
595#[utoipa::path(
596 post,
597 path = "/bot/register",
598 tag = "Bots",
599 request_body = RegisterBotRequest,
600 responses(
601 (status = 200, description = "Bot registered successfully", body = Object),
602 (status = 401, description = "Invalid or missing authorization token", body = Object)
603 )
604)]
605async fn register_bot(
606 State(state): State<AppState>,
607 headers: HeaderMap,
608 Json(req): Json<RegisterBotRequest>,
609) -> impl IntoResponse {
610 let token = match extract_bearer_token(&headers) {
611 Some(t) => t,
612 None => {
613 return (
614 StatusCode::UNAUTHORIZED,
615 Json(serde_json::json!({"error": "Missing or invalid authorization token"})),
616 );
617 }
618 };
619
620 match state.lifecycle.get_bot_with_token(req.bot_id, token).await {
621 Ok(bot) => {
622 info!(bot_id = %bot.id, "Bot registered successfully");
623 (
624 StatusCode::OK,
625 Json(serde_json::json!({"status": "registered"})),
626 )
627 }
628 Err(_) => (
629 StatusCode::UNAUTHORIZED,
630 Json(serde_json::json!({"error": "Invalid bot ID or registration token"})),
631 ),
632 }
633}
634
635#[derive(Deserialize, ToSchema)]
636struct AckConfigRequest {
637 config_id: Uuid,
638}
639
640#[utoipa::path(
641 get,
642 path = "/bot/{id}/config",
643 tag = "Configuration",
644 params(("id" = Uuid, Path, description = "Bot ID")),
645 responses(
646 (status = 200, description = "Desired config found", body = Object),
647 (status = 401, description = "Invalid or missing authorization token", body = Object),
648 (status = 404, description = "No desired config", body = Object),
649 (status = 500, description = "Failed to get config", body = Object)
650 )
651)]
652async fn get_desired_config(
653 State(state): State<AppState>,
654 Path(id): Path<Uuid>,
655 headers: HeaderMap,
656) -> impl IntoResponse {
657 let token = match extract_bearer_token(&headers) {
658 Some(t) => t,
659 None => {
660 return (
661 StatusCode::UNAUTHORIZED,
662 Json(serde_json::json!({"error": "Missing or invalid authorization token"})),
663 );
664 }
665 };
666
667 if state.lifecycle.get_bot_with_token(id, token).await.is_err() {
668 return (
669 StatusCode::UNAUTHORIZED,
670 Json(serde_json::json!({"error": "Invalid bot ID or registration token"})),
671 );
672 }
673
674 match state.lifecycle.get_desired_config(id).await {
675 Ok(Some(config)) => (StatusCode::OK, Json(serde_json::json!(config))),
676 Ok(None) => (
677 StatusCode::NOT_FOUND,
678 Json(serde_json::json!({"error": "No desired config"})),
679 ),
680 Err(_) => (
681 StatusCode::INTERNAL_SERVER_ERROR,
682 Json(serde_json::json!({"error": "Failed to get config"})),
683 ),
684 }
685}
686
687#[utoipa::path(
688 post,
689 path = "/bot/{id}/config_ack",
690 tag = "Configuration",
691 params(("id" = Uuid, Path, description = "Bot ID")),
692 request_body = AckConfigRequest,
693 responses(
694 (status = 200, description = "Config acknowledged", body = Object),
695 (status = 401, description = "Invalid or missing authorization token", body = Object),
696 (status = 400, description = "Failed to acknowledge config", body = Object)
697 )
698)]
699async fn acknowledge_config(
700 State(state): State<AppState>,
701 Path(id): Path<Uuid>,
702 headers: HeaderMap,
703 Json(req): Json<AckConfigRequest>,
704) -> impl IntoResponse {
705 let token = match extract_bearer_token(&headers) {
706 Some(t) => t,
707 None => {
708 return (
709 StatusCode::UNAUTHORIZED,
710 Json(serde_json::json!({"error": "Missing or invalid authorization token"})),
711 );
712 }
713 };
714
715 if state.lifecycle.get_bot_with_token(id, token).await.is_err() {
716 return (
717 StatusCode::UNAUTHORIZED,
718 Json(serde_json::json!({"error": "Invalid bot ID or registration token"})),
719 );
720 }
721
722 match state.lifecycle.acknowledge_config(id, req.config_id).await {
723 Ok(_) => (
724 StatusCode::OK,
725 Json(serde_json::json!({"status": "acknowledged"})),
726 ),
727 Err(_) => (
728 StatusCode::BAD_REQUEST,
729 Json(serde_json::json!({"error": "Failed to acknowledge config"})),
730 ),
731 }
732}
733
734#[utoipa::path(
735 post,
736 path = "/bot/{id}/heartbeat",
737 tag = "Bots",
738 params(("id" = Uuid, Path, description = "Bot ID")),
739 responses(
740 (status = 200, description = "Heartbeat recorded", body = Object),
741 (status = 401, description = "Invalid or missing authorization token", body = Object),
742 (status = 500, description = "Failed to record heartbeat", body = Object)
743 )
744)]
745async fn record_heartbeat(
746 State(state): State<AppState>,
747 Path(id): Path<Uuid>,
748 headers: HeaderMap,
749) -> impl IntoResponse {
750 let token = match extract_bearer_token(&headers) {
751 Some(t) => t,
752 None => {
753 return (
754 StatusCode::UNAUTHORIZED,
755 Json(serde_json::json!({"error": "Missing or invalid authorization token"})),
756 );
757 }
758 };
759
760 if state.lifecycle.get_bot_with_token(id, token).await.is_err() {
761 return (
762 StatusCode::UNAUTHORIZED,
763 Json(serde_json::json!({"error": "Invalid bot ID or registration token"})),
764 );
765 }
766
767 match state.lifecycle.record_heartbeat(id).await {
768 Ok(_) => (StatusCode::OK, Json(serde_json::json!({"status": "ok"}))),
769 Err(_) => (
770 StatusCode::INTERNAL_SERVER_ERROR,
771 Json(serde_json::json!({"error": "Failed to record heartbeat"})),
772 ),
773 }
774}
775
776#[derive(Serialize, ToSchema)]
777struct BotResponse {
778 id: Uuid,
779 account_id: Uuid,
780 name: String,
781 persona: String,
782 status: String,
783 droplet_id: Option<i64>,
784 desired_config_version_id: Option<Uuid>,
785 applied_config_version_id: Option<Uuid>,
786 created_at: chrono::DateTime<chrono::Utc>,
787 updated_at: chrono::DateTime<chrono::Utc>,
788 #[schema(format = "date-time")]
789 last_heartbeat_at: Option<chrono::DateTime<chrono::Utc>>,
790}
791
792impl From<Bot> for BotResponse {
793 fn from(bot: Bot) -> Self {
794 Self {
795 id: bot.id,
796 account_id: bot.account_id,
797 name: bot.name,
798 persona: bot.persona.to_string(),
799 status: bot.status.to_string(),
800 droplet_id: bot.droplet_id,
801 desired_config_version_id: bot.desired_config_version_id,
802 applied_config_version_id: bot.applied_config_version_id,
803 created_at: bot.created_at,
804 updated_at: bot.updated_at,
805 last_heartbeat_at: bot.last_heartbeat_at,
806 }
807 }
808}