Skip to main content

aster_server/routes/
recipe.rs

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}