Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions crates/goose-server/src/openapi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,8 @@ impl<'__s> ToSchema<'__s> for AnnotatedSchema {
super::routes::recipe::scan_recipe,
super::routes::recipe::list_recipes,
super::routes::recipe::delete_recipe,
super::routes::recipe::save_recipe,
super::routes::recipe::parse_recipe,
super::routes::setup::start_openrouter_setup,
super::routes::setup::start_tetrate_setup,
),
Expand Down Expand Up @@ -469,6 +471,10 @@ impl<'__s> ToSchema<'__s> for AnnotatedSchema {
super::routes::recipe::RecipeManifestResponse,
super::routes::recipe::ListRecipeResponse,
super::routes::recipe::DeleteRecipeRequest,
super::routes::recipe::SaveRecipeRequest,
super::routes::errors::ErrorResponse,
super::routes::recipe::ParseRecipeRequest,
super::routes::recipe::ParseRecipeResponse,
goose::recipe::Recipe,
goose::recipe::Author,
goose::recipe::Settings,
Expand Down
24 changes: 24 additions & 0 deletions crates/goose-server/src/routes/errors.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde::Serialize;
use utoipa::ToSchema;

#[derive(Debug, Serialize, ToSchema)]
pub struct ErrorResponse {
pub message: String,
#[serde(skip)]
pub status: StatusCode,
}

impl IntoResponse for ErrorResponse {
fn into_response(self) -> Response {
let body = Json(serde_json::json!({
"message": self.message,
}));

(self.status, body).into_response()
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is cute and we certainly need something like this, but I would leave it out for now; the real refactor here is to have something that converts from our errors to a ErrorResponse globally and then apply it everywhere. would love to have it, but I don't think it is in scope /cc @jamadeo

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

axum is very permissive about what you can return: https://docs.rs/axum/latest/axum/response/index.html

You can already return a (StatusCode, String) and it will convert

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cool. we should do this for everything some time. maybe goose can

1 change: 1 addition & 0 deletions crates/goose-server/src/routes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ pub mod agent;
pub mod audio;
pub mod config_management;
pub mod context;
pub mod errors;
pub mod extension;
pub mod health;
pub mod recipe;
Expand Down
74 changes: 71 additions & 3 deletions crates/goose-server/src/routes/recipe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ use std::sync::Arc;
use axum::routing::get;
use axum::{extract::State, http::StatusCode, routing::post, Json, Router};
use goose::conversation::{message::Message, Conversation};
use goose::recipe::recipe_library;
use goose::recipe::Recipe;
use goose::recipe_deeplink;

use serde::{Deserialize, Serialize};
use utoipa::ToSchema;

use crate::routes::errors::ErrorResponse;
use crate::routes::recipe_utils::get_all_recipes_manifests;
use crate::state::AppState;

Expand Down Expand Up @@ -72,11 +74,25 @@ pub struct ScanRecipeResponse {
has_security_warnings: bool,
}

#[derive(Debug, Deserialize, ToSchema)]
pub struct SaveRecipeRequest {
recipe: Recipe,
id: Option<String>,
is_global: Option<bool>,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct ParseRecipeRequest {
pub content: String,
}

#[derive(Debug, Serialize, ToSchema)]
pub struct ParseRecipeResponse {
pub recipe: Recipe,
}

#[derive(Debug, Serialize, ToSchema)]
pub struct RecipeManifestResponse {
name: String,
#[serde(rename = "isGlobal")]
is_global: bool,
recipe: Recipe,
#[serde(rename = "lastModified")]
last_modified: String,
Expand Down Expand Up @@ -235,7 +251,6 @@ async fn list_recipes(
recipe_file_hash_map.insert(id.clone(), file_path);
RecipeManifestResponse {
name: recipe_manifest_with_path.name.clone(),
is_global: recipe_manifest_with_path.is_global,
recipe: recipe_manifest_with_path.recipe.clone(),
id: id.clone(),
last_modified: recipe_manifest_with_path.last_modified.clone(),
Expand Down Expand Up @@ -278,6 +293,57 @@ async fn delete_recipe(
StatusCode::NO_CONTENT
}

#[utoipa::path(
post,
path = "/recipes/save",
request_body = SaveRecipeRequest,
responses(
(status = 204, description = "Recipe saved to file successfully"),
(status = 401, description = "Unauthorized - Invalid or missing API key"),
(status = 500, description = "Internal server error", body = ErrorResponse)
),
tag = "Recipe Management"
)]
async fn save_recipe(
State(state): State<Arc<AppState>>,
Json(request): Json<SaveRecipeRequest>,
) -> Result<StatusCode, ErrorResponse> {
let file_path = match request.id {
Some(id) => state.recipe_file_hash_map.lock().await.get(&id).cloned(),
None => None,
};

match recipe_library::save_recipe_to_file(request.recipe, request.is_global, file_path) {
Ok(_) => Ok(StatusCode::NO_CONTENT),
Err(e) => Err(ErrorResponse {
message: e.to_string(),
status: StatusCode::INTERNAL_SERVER_ERROR,
}),
}
}

#[utoipa::path(
post,
path = "/recipes/parse",
request_body = ParseRecipeRequest,
responses(
(status = 200, description = "Recipe parsed successfully", body = ParseRecipeResponse),
(status = 400, description = "Bad request - Invalid recipe format", body = ErrorResponse),
(status = 500, description = "Internal server error", body = ErrorResponse)
),
tag = "Recipe Management"
)]
async fn parse_recipe(
Json(request): Json<ParseRecipeRequest>,
) -> Result<Json<ParseRecipeResponse>, ErrorResponse> {
let recipe = Recipe::from_content(&request.content).map_err(|e| ErrorResponse {
message: format!("Invalid recipe format: {}", e),
status: StatusCode::BAD_REQUEST,
})?;

Ok(Json(ParseRecipeResponse { recipe }))
}

pub fn routes(state: Arc<AppState>) -> Router {
Router::new()
.route("/recipes/create", post(create_recipe))
Expand All @@ -286,6 +352,8 @@ pub fn routes(state: Arc<AppState>) -> Router {
.route("/recipes/scan", post(scan_recipe))
.route("/recipes/list", get(list_recipes))
.route("/recipes/delete", post(delete_recipe))
.route("/recipes/save", post(save_recipe))
.route("/recipes/parse", post(parse_recipe))
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we replace /decode with this too? and really make parse so that it also takes base64 etc

.with_state(state)
}

Expand Down
82 changes: 24 additions & 58 deletions crates/goose-server/src/routes/recipe_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,8 @@ use std::hash::{Hash, Hasher};
use std::path::PathBuf;

use anyhow::Result;
use etcetera::{choose_app_strategy, AppStrategy};

use goose::config::APP_STRATEGY;
use goose::recipe::read_recipe_file_content::read_recipe_file;
use goose::recipe::recipe_library::list_all_recipes_from_library;
use goose::recipe::Recipe;

use std::path::Path;
Expand All @@ -18,7 +16,6 @@ use utoipa::ToSchema;
pub struct RecipeManifestWithPath {
pub id: String,
pub name: String,
pub is_global: bool,
pub recipe: Recipe,
pub file_path: PathBuf,
pub last_modified: String,
Expand All @@ -31,59 +28,31 @@ fn short_id_from_path(path: &str) -> String {
format!("{:016x}", h)
}

fn load_recipes_from_path(path: &PathBuf, is_global: bool) -> Result<Vec<RecipeManifestWithPath>> {
let mut recipe_manifests_with_path = Vec::new();
if path.exists() {
for entry in fs::read_dir(path)? {
let path = entry?.path();
if path.extension() == Some("yaml".as_ref()) {
let Ok(recipe_file) = read_recipe_file(path.clone()) else {
continue;
};
let Ok(recipe) = Recipe::from_content(&recipe_file.content) else {
continue;
};
let Ok(last_modified) = fs::metadata(path.clone()).map(|m| {
chrono::DateTime::<chrono::Utc>::from(m.modified().unwrap()).to_rfc3339()
}) else {
continue;
};
let recipe_metadata =
RecipeManifestMetadata::from_yaml_file(&path).unwrap_or_else(|_| {
RecipeManifestMetadata {
name: recipe.title.clone(),
is_global,
}
});

let manifest_with_path = RecipeManifestWithPath {
id: short_id_from_path(recipe_file.file_path.to_string_lossy().as_ref()),
name: recipe_metadata.name,
is_global: recipe_metadata.is_global,
recipe,
file_path: recipe_file.file_path,
last_modified,
};
recipe_manifests_with_path.push(manifest_with_path);
}
}
}
Ok(recipe_manifests_with_path)
}

pub fn get_all_recipes_manifests() -> Result<Vec<RecipeManifestWithPath>> {
let current_dir = std::env::current_dir()?;
let local_recipe_path = current_dir.join(".goose/recipes");

let global_recipe_path = choose_app_strategy(APP_STRATEGY.clone())
.expect("goose requires a home dir")
.config_dir()
.join("recipes");

let recipes_with_path = list_all_recipes_from_library()?;
let mut recipe_manifests_with_path = Vec::new();

recipe_manifests_with_path.extend(load_recipes_from_path(&local_recipe_path, false)?);
recipe_manifests_with_path.extend(load_recipes_from_path(&global_recipe_path, true)?);
for (file_path, recipe) in recipes_with_path {
let Ok(last_modified) = fs::metadata(file_path.clone())
.map(|m| chrono::DateTime::<chrono::Utc>::from(m.modified().unwrap()).to_rfc3339())
else {
continue;
};
let recipe_metadata =
RecipeManifestMetadata::from_yaml_file(&file_path).unwrap_or_else(|_| {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current import UI also supports JSON so we may want to consider that too

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yaml is a superset of json so that should be alright

RecipeManifestMetadata {
name: recipe.title.clone(),
}
});

let manifest_with_path = RecipeManifestWithPath {
id: short_id_from_path(file_path.to_string_lossy().as_ref()),
name: recipe_metadata.name,
recipe,
file_path,
last_modified,
};
recipe_manifests_with_path.push(manifest_with_path);
}
recipe_manifests_with_path.sort_by(|a, b| b.last_modified.cmp(&a.last_modified));

Ok(recipe_manifests_with_path)
Expand All @@ -93,8 +62,6 @@ pub fn get_all_recipes_manifests() -> Result<Vec<RecipeManifestWithPath>> {
#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)]
struct RecipeManifestMetadata {
pub name: String,
#[serde(rename = "isGlobal")]
pub is_global: bool,
}

impl RecipeManifestMetadata {
Expand Down Expand Up @@ -129,6 +96,5 @@ recipe: recipe_content
let result = RecipeManifestMetadata::from_yaml_file(&file_path).unwrap();

assert_eq!(result.name, "Test Recipe");
assert!(result.is_global);
}
}
1 change: 1 addition & 0 deletions crates/goose/src/recipe/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use utoipa::ToSchema;

pub mod build_recipe;
pub mod read_recipe_file_content;
pub mod recipe_library;
pub mod template_recipe;

pub const BUILT_IN_RECIPE_DIR_PARAM: &str = "recipe_dir";
Expand Down
Loading
Loading