1use std::collections::HashMap;
2use std::fs;
3use std::path::PathBuf;
4use std::sync::Arc;
5
6use aster::recipe::local_recipes;
7use aster::recipe::validate_recipe::validate_recipe_template_from_content;
8use aster::recipe::Recipe;
9use aster::session::SessionManager;
10use aster::{recipe_deeplink, slash_commands};
11use axum::extract::rejection::JsonRejection;
12use axum::routing::get;
13use axum::{extract::State, http::StatusCode, routing::post, Json, Router};
14
15use serde::{Deserialize, Serialize};
16use serde_json::Value;
17use serde_path_to_error::deserialize as deserialize_with_path;
18use utoipa::ToSchema;
19
20fn format_json_rejection_message(rejection: &JsonRejection) -> String {
21 match rejection {
22 JsonRejection::JsonDataError(err) => {
23 format!("Request body validation failed: {}", clean_data_error(err))
24 }
25 JsonRejection::JsonSyntaxError(err) => format!("Invalid JSON payload: {}", err.body_text()),
26 JsonRejection::MissingJsonContentType(err) => err.body_text(),
27 JsonRejection::BytesRejection(err) => err.body_text(),
28 _ => rejection.body_text(),
29 }
30}
31
32fn clean_data_error(err: &axum::extract::rejection::JsonDataError) -> String {
33 let message = err.body_text();
34 message
35 .strip_prefix("Failed to deserialize the JSON body into the target type: ")
36 .map(|s| s.to_string())
37 .unwrap_or_else(|| message.to_string())
38}
39
40use crate::routes::errors::ErrorResponse;
41use crate::routes::recipe_utils::{
42 get_all_recipes_manifests, get_recipe_file_path_by_id, short_id_from_path, validate_recipe,
43 RecipeManifest, RecipeValidationError,
44};
45use crate::state::AppState;
46
47#[derive(Debug, Deserialize, ToSchema)]
48pub struct CreateRecipeRequest {
49 session_id: String,
50 #[serde(default)]
51 author: Option<AuthorRequest>,
52}
53
54#[derive(Debug, Deserialize, ToSchema)]
55pub struct AuthorRequest {
56 #[serde(default)]
57 contact: Option<String>,
58 #[serde(default)]
59 metadata: Option<String>,
60}
61
62#[derive(Debug, Serialize, ToSchema)]
63pub struct CreateRecipeResponse {
64 recipe: Option<Recipe>,
65 error: Option<String>,
66}
67
68#[derive(Debug, Deserialize, ToSchema)]
69pub struct EncodeRecipeRequest {
70 recipe: Recipe,
71}
72
73#[derive(Debug, Serialize, ToSchema)]
74pub struct EncodeRecipeResponse {
75 deeplink: String,
76}
77
78#[derive(Debug, Deserialize, ToSchema)]
79pub struct DecodeRecipeRequest {
80 deeplink: String,
81}
82
83#[derive(Debug, Serialize, ToSchema)]
84pub struct DecodeRecipeResponse {
85 recipe: Recipe,
86}
87
88#[derive(Debug, Deserialize, ToSchema)]
89pub struct ScanRecipeRequest {
90 recipe: Recipe,
91}
92
93#[derive(Debug, Serialize, ToSchema)]
94pub struct ScanRecipeResponse {
95 has_security_warnings: bool,
96}
97
98#[derive(Debug, Deserialize, ToSchema)]
99pub struct SaveRecipeRequest {
100 recipe: Recipe,
101 id: Option<String>,
102}
103
104#[derive(Debug, Serialize, ToSchema)]
105pub struct SaveRecipeResponse {
106 id: String,
107}
108#[derive(Debug, Deserialize, ToSchema)]
109pub struct ParseRecipeRequest {
110 pub content: String,
111}
112
113#[derive(Debug, Serialize, ToSchema)]
114pub struct ParseRecipeResponse {
115 pub recipe: Recipe,
116}
117
118#[derive(Debug, Deserialize, ToSchema)]
119pub struct DeleteRecipeRequest {
120 id: String,
121}
122
123#[derive(Debug, Serialize, ToSchema)]
124pub struct ListRecipeResponse {
125 manifests: Vec<RecipeManifest>,
126}
127
128#[derive(Debug, Deserialize, ToSchema)]
129pub struct ScheduleRecipeRequest {
130 id: String,
131 cron_schedule: Option<String>,
132}
133
134#[derive(Debug, Deserialize, ToSchema)]
135pub struct SetSlashCommandRequest {
136 id: String,
137 slash_command: Option<String>,
138}
139
140#[derive(Debug, Deserialize, ToSchema)]
141pub struct RecipeToYamlRequest {
142 recipe: Recipe,
143}
144
145#[derive(Debug, Serialize, ToSchema)]
146pub struct RecipeToYamlResponse {
147 yaml: String,
148}
149
150#[utoipa::path(
151 post,
152 path = "/recipes/create",
153 request_body = CreateRecipeRequest,
154 responses(
155 (status = 200, description = "Recipe created successfully", body = CreateRecipeResponse),
156 (status = 400, description = "Bad request"),
157 (status = 412, description = "Precondition failed - Agent not available"),
158 (status = 500, description = "Internal server error")
159 ),
160 tag = "Recipe Management"
161)]
162async fn create_recipe(
163 State(state): State<Arc<AppState>>,
164 Json(request): Json<CreateRecipeRequest>,
165) -> Result<Json<CreateRecipeResponse>, StatusCode> {
166 tracing::info!(
167 "Recipe creation request received for session_id: {}",
168 request.session_id
169 );
170
171 let session = match SessionManager::get_session(&request.session_id, true).await {
172 Ok(session) => session,
173 Err(e) => {
174 tracing::error!("Failed to get session: {}", e);
175 return Err(StatusCode::INTERNAL_SERVER_ERROR);
176 }
177 };
178
179 let conversation = match session.conversation {
180 Some(conversation) => conversation,
181 None => {
182 let error_message = "Session has no conversation".to_string();
183 let error_response = CreateRecipeResponse {
184 recipe: None,
185 error: Some(error_message),
186 };
187 return Ok(Json(error_response));
188 }
189 };
190
191 let agent = state.get_agent_for_route(request.session_id).await?;
192
193 let recipe_result = agent.create_recipe(conversation).await;
194
195 match recipe_result {
196 Ok(mut recipe) => {
197 if let Some(author_req) = request.author {
198 recipe.author = Some(aster::recipe::Author {
199 contact: author_req.contact,
200 metadata: author_req.metadata,
201 });
202 }
203
204 Ok(Json(CreateRecipeResponse {
205 recipe: Some(recipe),
206 error: None,
207 }))
208 }
209 Err(e) => {
210 tracing::error!("Error details: {:?}", e);
211 aster::posthog::emit_error("recipe_create_failed", &e.to_string());
212 let error_response = CreateRecipeResponse {
213 recipe: None,
214 error: Some(format!("Failed to create recipe: {}", e)),
215 };
216 Ok(Json(error_response))
217 }
218 }
219}
220
221#[utoipa::path(
222 post,
223 path = "/recipes/encode",
224 request_body = EncodeRecipeRequest,
225 responses(
226 (status = 200, description = "Recipe encoded successfully", body = EncodeRecipeResponse),
227 (status = 400, description = "Bad request")
228 ),
229 tag = "Recipe Management"
230)]
231async fn encode_recipe(
232 Json(request): Json<EncodeRecipeRequest>,
233) -> Result<Json<EncodeRecipeResponse>, StatusCode> {
234 match recipe_deeplink::encode(&request.recipe) {
235 Ok(encoded) => Ok(Json(EncodeRecipeResponse { deeplink: encoded })),
236 Err(err) => {
237 tracing::error!("Failed to encode recipe: {}", err);
238 aster::posthog::emit_error("recipe_encode_failed", &err.to_string());
239 Err(StatusCode::BAD_REQUEST)
240 }
241 }
242}
243
244#[utoipa::path(
245 post,
246 path = "/recipes/decode",
247 request_body = DecodeRecipeRequest,
248 responses(
249 (status = 200, description = "Recipe decoded successfully", body = DecodeRecipeResponse),
250 (status = 400, description = "Bad request")
251 ),
252 tag = "Recipe Management"
253)]
254async fn decode_recipe(
255 Json(request): Json<DecodeRecipeRequest>,
256) -> Result<Json<DecodeRecipeResponse>, StatusCode> {
257 match recipe_deeplink::decode(&request.deeplink) {
258 Ok(recipe) => match validate_recipe(&recipe) {
259 Ok(_) => Ok(Json(DecodeRecipeResponse { recipe })),
260 Err(RecipeValidationError { status, .. }) => Err(status),
261 },
262 Err(err) => {
263 tracing::error!("Failed to decode deeplink: {}", err);
264 aster::posthog::emit_error("recipe_decode_failed", &err.to_string());
265 Err(StatusCode::BAD_REQUEST)
266 }
267 }
268}
269
270#[utoipa::path(
271 post,
272 path = "/recipes/scan",
273 request_body = ScanRecipeRequest,
274 responses(
275 (status = 200, description = "Recipe scanned successfully", body = ScanRecipeResponse),
276 ),
277 tag = "Recipe Management"
278)]
279async fn scan_recipe(
280 Json(request): Json<ScanRecipeRequest>,
281) -> Result<Json<ScanRecipeResponse>, StatusCode> {
282 let has_security_warnings = request.recipe.check_for_security_warnings();
283
284 Ok(Json(ScanRecipeResponse {
285 has_security_warnings,
286 }))
287}
288
289#[utoipa::path(
290 get,
291 path = "/recipes/list",
292 responses(
293 (status = 200, description = "Get recipe list successfully", body = ListRecipeResponse),
294 (status = 401, description = "Unauthorized - Invalid or missing API key"),
295 (status = 500, description = "Internal server error")
296 ),
297 tag = "Recipe Management"
298)]
299async fn list_recipes(
300 State(state): State<Arc<AppState>>,
301) -> Result<Json<ListRecipeResponse>, StatusCode> {
302 let mut manifests = get_all_recipes_manifests().unwrap_or_default();
303 let recipe_file_hash_map: HashMap<_, _> = manifests
304 .iter()
305 .map(|m| (m.id.clone(), m.file_path.clone()))
306 .collect();
307 state.set_recipe_file_hash_map(recipe_file_hash_map).await;
308
309 let scheduler = state.scheduler();
310 let scheduled_jobs = scheduler.list_scheduled_jobs().await;
311 let schedule_map: HashMap<_, _> = scheduled_jobs
312 .into_iter()
313 .map(|j| (PathBuf::from(j.source), j.cron))
314 .collect();
315
316 let all_commands = slash_commands::list_commands();
317 let slash_map: HashMap<_, _> = all_commands
318 .into_iter()
319 .map(|sc| (PathBuf::from(sc.recipe_path), sc.command))
320 .collect();
321
322 for manifest in &mut manifests {
323 if let Some(cron) = schedule_map.get(&manifest.file_path) {
324 manifest.schedule_cron = Some(cron.clone());
325 }
326 if let Some(command) = slash_map.get(&manifest.file_path) {
327 manifest.slash_command = Some(command.clone());
328 }
329 }
330
331 Ok(Json(ListRecipeResponse { manifests }))
332}
333
334#[utoipa::path(
335 post,
336 path = "/recipes/delete",
337 request_body = DeleteRecipeRequest,
338 responses(
339 (status = 204, description = "Recipe deleted successfully"),
340 (status = 401, description = "Unauthorized - Invalid or missing API key"),
341 (status = 404, description = "Recipe not found"),
342 (status = 500, description = "Internal server error")
343 ),
344 tag = "Recipe Management"
345)]
346async fn delete_recipe(
347 State(state): State<Arc<AppState>>,
348 Json(request): Json<DeleteRecipeRequest>,
349) -> StatusCode {
350 let file_path = match get_recipe_file_path_by_id(state.as_ref(), &request.id).await {
351 Ok(path) => path,
352 Err(err) => return err.status,
353 };
354
355 if fs::remove_file(file_path).is_err() {
356 return StatusCode::INTERNAL_SERVER_ERROR;
357 }
358
359 StatusCode::NO_CONTENT
360}
361
362#[utoipa::path(
363 post,
364 path = "/recipes/schedule",
365 request_body = ScheduleRecipeRequest,
366 responses(
367 (status = 200, description = "Recipe scheduled successfully"),
368 (status = 404, description = "Recipe not found"),
369 (status = 500, description = "Internal server error")
370 ),
371 tag = "Recipe Management"
372)]
373async fn schedule_recipe(
374 State(state): State<Arc<AppState>>,
375 Json(request): Json<ScheduleRecipeRequest>,
376) -> Result<StatusCode, StatusCode> {
377 let file_path = match get_recipe_file_path_by_id(state.as_ref(), &request.id).await {
378 Ok(path) => path,
379 Err(err) => return Err(err.status),
380 };
381
382 let scheduler = state.scheduler();
383 match scheduler
384 .schedule_recipe(file_path, request.cron_schedule)
385 .await
386 {
387 Ok(_) => Ok(StatusCode::OK),
388 Err(e) => {
389 tracing::error!("Failed to schedule recipe: {}", e);
390 aster::posthog::emit_error("recipe_schedule_failed", &e.to_string());
391 Err(StatusCode::INTERNAL_SERVER_ERROR)
392 }
393 }
394}
395
396#[utoipa::path(
397 post,
398 path = "/recipes/slash-command",
399 request_body = SetSlashCommandRequest,
400 responses(
401 (status = 200, description = "Slash command set successfully"),
402 (status = 404, description = "Recipe not found"),
403 (status = 500, description = "Internal server error")
404 ),
405 tag = "Recipe Management"
406)]
407async fn set_recipe_slash_command(
408 State(state): State<Arc<AppState>>,
409 Json(request): Json<SetSlashCommandRequest>,
410) -> Result<StatusCode, StatusCode> {
411 let file_path = match get_recipe_file_path_by_id(state.as_ref(), &request.id).await {
412 Ok(path) => path,
413 Err(err) => return Err(err.status),
414 };
415
416 match slash_commands::set_recipe_slash_command(file_path, request.slash_command) {
417 Ok(_) => Ok(StatusCode::OK),
418 Err(e) => {
419 tracing::error!("Failed to set slash command: {}", e);
420 Err(StatusCode::INTERNAL_SERVER_ERROR)
421 }
422 }
423}
424
425#[utoipa::path(
426 post,
427 path = "/recipes/save",
428 request_body = SaveRecipeRequest,
429 responses(
430 (status = 204, description = "Recipe saved to file successfully", body = SaveRecipeResponse),
431 (status = 401, description = "Unauthorized - Invalid or missing API key"),
432 (status = 401, description = "Unauthorized", body = ErrorResponse),
433 (status = 404, description = "Not found", body = ErrorResponse),
434 (status = 500, description = "Internal server error", body = ErrorResponse)
435 ),
436 tag = "Recipe Management"
437)]
438async fn save_recipe(
439 State(state): State<Arc<AppState>>,
440 payload: Result<Json<Value>, JsonRejection>,
441) -> Result<Json<SaveRecipeResponse>, ErrorResponse> {
442 let Json(raw_json) = payload.map_err(json_rejection_to_error_response)?;
443 let request = deserialize_save_recipe_request(raw_json)?;
444 let has_security_warnings = request.recipe.check_for_security_warnings();
445 if has_security_warnings {
446 return Err(ErrorResponse {
447 message: "This recipe contains hidden characters that could be malicious. Please remove them before trying to save.".to_string(),
448 status: StatusCode::BAD_REQUEST,
449 });
450 }
451 ensure_recipe_valid(&request.recipe)?;
452
453 let file_path = match request.id.as_ref() {
454 Some(id) => Some(get_recipe_file_path_by_id(state.as_ref(), id).await?),
455 None => None,
456 };
457
458 match local_recipes::save_recipe_to_file(request.recipe, file_path.clone()) {
459 Ok(save_file_path) => Ok(Json(SaveRecipeResponse {
460 id: short_id_from_path(&save_file_path.display().to_string()),
461 })),
462 Err(e) => Err(ErrorResponse {
463 message: e.to_string(),
464 status: StatusCode::INTERNAL_SERVER_ERROR,
465 }),
466 }
467}
468
469fn json_rejection_to_error_response(rejection: JsonRejection) -> ErrorResponse {
470 ErrorResponse {
471 message: format_json_rejection_message(&rejection),
472 status: StatusCode::BAD_REQUEST,
473 }
474}
475
476fn ensure_recipe_valid(recipe: &Recipe) -> Result<(), ErrorResponse> {
477 if let Err(err) = validate_recipe(recipe) {
478 return Err(ErrorResponse {
479 message: err.message,
480 status: err.status,
481 });
482 }
483 Ok(())
484}
485
486fn deserialize_save_recipe_request(value: Value) -> Result<SaveRecipeRequest, ErrorResponse> {
487 let payload = value.to_string();
488 let mut deserializer = serde_json::Deserializer::from_str(&payload);
489 let result: Result<SaveRecipeRequest, _> = deserialize_with_path(&mut deserializer);
490 result.map_err(|err| {
491 let path = err.path().to_string();
492 let inner = err.into_inner();
493 let message = if path.is_empty() {
494 format!("Save recipe validation failed: {}", inner)
495 } else {
496 format!(
497 "save recipe validation failed at {}: {}",
498 path.trim_start_matches('.'),
499 inner
500 )
501 };
502 ErrorResponse {
503 message,
504 status: StatusCode::BAD_REQUEST,
505 }
506 })
507}
508
509#[utoipa::path(
510 post,
511 path = "/recipes/parse",
512 request_body = ParseRecipeRequest,
513 responses(
514 (status = 200, description = "Recipe parsed successfully", body = ParseRecipeResponse),
515 (status = 400, description = "Bad request - Invalid recipe format", body = ErrorResponse),
516 (status = 500, description = "Internal server error", body = ErrorResponse)
517 ),
518 tag = "Recipe Management"
519)]
520async fn parse_recipe(
521 Json(request): Json<ParseRecipeRequest>,
522) -> Result<Json<ParseRecipeResponse>, ErrorResponse> {
523 let recipe = validate_recipe_template_from_content(&request.content, None).map_err(|e| {
524 ErrorResponse {
525 message: format!("Invalid recipe format: {}", e),
526 status: StatusCode::BAD_REQUEST,
527 }
528 })?;
529
530 Ok(Json(ParseRecipeResponse { recipe }))
531}
532
533#[utoipa::path(
534 post,
535 path = "/recipes/to-yaml",
536 request_body = RecipeToYamlRequest,
537 responses(
538 (status = 200, description = "Recipe converted to YAML successfully", body = RecipeToYamlResponse),
539 (status = 400, description = "Bad request - Failed to convert recipe to YAML", body = ErrorResponse),
540 ),
541 tag = "Recipe Management"
542)]
543async fn recipe_to_yaml(
544 Json(request): Json<RecipeToYamlRequest>,
545) -> Result<Json<RecipeToYamlResponse>, ErrorResponse> {
546 let yaml = request.recipe.to_yaml().map_err(|e| ErrorResponse {
547 message: format!("Failed to convert recipe to YAML: {}", e),
548 status: StatusCode::BAD_REQUEST,
549 })?;
550
551 Ok(Json(RecipeToYamlResponse { yaml }))
552}
553
554pub fn routes(state: Arc<AppState>) -> Router {
555 Router::new()
556 .route("/recipes/create", post(create_recipe))
557 .route("/recipes/encode", post(encode_recipe))
558 .route("/recipes/decode", post(decode_recipe))
559 .route("/recipes/scan", post(scan_recipe))
560 .route("/recipes/list", get(list_recipes))
561 .route("/recipes/delete", post(delete_recipe))
562 .route("/recipes/schedule", post(schedule_recipe))
563 .route("/recipes/slash-command", post(set_recipe_slash_command))
564 .route("/recipes/save", post(save_recipe))
565 .route("/recipes/parse", post(parse_recipe))
566 .route("/recipes/to-yaml", post(recipe_to_yaml))
567 .with_state(state)
568}
569
570#[cfg(test)]
571mod tests {
572 use super::*;
573 use aster::recipe::Recipe;
574
575 #[tokio::test]
576 async fn test_decode_and_encode_recipe() {
577 let original_recipe = Recipe::builder()
578 .title("Test Recipe")
579 .description("A test recipe")
580 .instructions("Test instructions")
581 .build()
582 .unwrap();
583 let encoded = recipe_deeplink::encode(&original_recipe).unwrap();
584
585 let request = DecodeRecipeRequest {
586 deeplink: encoded.clone(),
587 };
588 let response = decode_recipe(Json(request)).await;
589
590 assert!(response.is_ok());
591 let decoded = response.unwrap().0.recipe;
592 assert_eq!(decoded.title, original_recipe.title);
593 assert_eq!(decoded.description, original_recipe.description);
594 assert_eq!(decoded.instructions, original_recipe.instructions);
595
596 let encode_request = EncodeRecipeRequest { recipe: decoded };
597 let encode_response = encode_recipe(Json(encode_request)).await;
598
599 assert!(encode_response.is_ok());
600 let encoded_again = encode_response.unwrap().0.deeplink;
601 assert!(!encoded_again.is_empty());
602 assert_eq!(encoded, encoded_again);
603 }
604}