mirror of
https://github.com/aljazceru/goose.git
synced 2026-01-06 16:04:28 +01:00
refactor: Centralise deeplink encode and decode into server (#3489)
This commit is contained in:
@@ -71,7 +71,6 @@ http = "1.0"
|
||||
webbrowser = "1.0"
|
||||
|
||||
indicatif = "0.17.11"
|
||||
urlencoding = "2"
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
winapi = { version = "0.3", features = ["wincred"] }
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
use anyhow::Result;
|
||||
use base64::Engine;
|
||||
use console::style;
|
||||
use serde_json;
|
||||
|
||||
use crate::recipes::github_recipe::RecipeSource;
|
||||
use crate::recipes::recipe::load_recipe_for_validation;
|
||||
use crate::recipes::search_recipe::list_available_recipes;
|
||||
use goose::recipe_deeplink;
|
||||
|
||||
/// Validates a recipe file
|
||||
///
|
||||
@@ -42,21 +41,26 @@ pub fn handle_validate(recipe_name: &str) -> Result<()> {
|
||||
pub fn handle_deeplink(recipe_name: &str) -> Result<String> {
|
||||
// Load the recipe file first to validate it
|
||||
match load_recipe_for_validation(recipe_name) {
|
||||
Ok(recipe) => {
|
||||
let mut full_url = String::new();
|
||||
if let Ok(recipe_json) = serde_json::to_string(&recipe) {
|
||||
let deeplink = base64::engine::general_purpose::STANDARD.encode(recipe_json);
|
||||
Ok(recipe) => match recipe_deeplink::encode(&recipe) {
|
||||
Ok(encoded) => {
|
||||
println!(
|
||||
"{} Generated deeplink for: {}",
|
||||
style("✓").green().bold(),
|
||||
recipe.title
|
||||
);
|
||||
let url_safe = urlencoding::encode(&deeplink);
|
||||
full_url = format!("goose://recipe?config={}", url_safe);
|
||||
let full_url = format!("goose://recipe?config={}", encoded);
|
||||
println!("{}", full_url);
|
||||
Ok(full_url)
|
||||
}
|
||||
Ok(full_url)
|
||||
}
|
||||
Err(err) => {
|
||||
println!(
|
||||
"{} Failed to encode recipe: {}",
|
||||
style("✗").red().bold(),
|
||||
err
|
||||
);
|
||||
Err(anyhow::anyhow!("Failed to encode recipe: {}", err))
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
println!("{} {}", style("✗").red().bold(), err);
|
||||
Err(err)
|
||||
@@ -185,7 +189,10 @@ response:
|
||||
|
||||
let result = handle_deeplink(&recipe_path);
|
||||
assert!(result.is_ok());
|
||||
assert!(result.unwrap().contains("goose://recipe?config=eyJ2ZXJzaW9uIjoiMS4wLjAiLCJ0aXRsZSI6IlRlc3QgUmVjaXBlIHdpdGggVmFsaWQgSlNPTiBTY2hlbWEiLCJkZXNjcmlwdGlvbiI6IkEgdGVzdCByZWNpcGUgd2l0aCB2YWxpZCBKU09OIHNjaGVtYSIsImluc3RydWN0aW9ucyI6IlRlc3QgaW5zdHJ1Y3Rpb25zIiwicHJvbXB0IjoiVGVzdCBwcm9tcHQgY29udGVudCIsInJlc3BvbnNlIjp7Impzb25fc2NoZW1hIjp7InByb3BlcnRpZXMiOnsiY291bnQiOnsiZGVzY3JpcHRpb24iOiJBIGNvdW50IHZhbHVlIiwidHlwZSI6Im51bWJlciJ9LCJyZXN1bHQiOnsiZGVzY3JpcHRpb24iOiJUaGUgcmVzdWx0IiwidHlwZSI6InN0cmluZyJ9fSwicmVxdWlyZWQiOlsicmVzdWx0Il0sInR5cGUiOiJvYmplY3QifX19"));
|
||||
let url = result.unwrap();
|
||||
assert!(url.starts_with("goose://recipe?config="));
|
||||
let encoded_part = url.strip_prefix("goose://recipe?config=").unwrap();
|
||||
assert!(encoded_part.len() > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -20,7 +20,6 @@ tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
tracing-appender = "0.2"
|
||||
url = "2.5"
|
||||
urlencoding = "2.1.3"
|
||||
base64 = "0.21"
|
||||
thiserror = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
|
||||
@@ -3,6 +3,7 @@ use std::sync::Arc;
|
||||
use axum::{extract::State, http::StatusCode, routing::post, Json, Router};
|
||||
use goose::message::Message;
|
||||
use goose::recipe::Recipe;
|
||||
use goose::recipe_deeplink;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::state::AppState;
|
||||
@@ -34,6 +35,26 @@ pub struct CreateRecipeResponse {
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct EncodeRecipeRequest {
|
||||
recipe: Recipe,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct EncodeRecipeResponse {
|
||||
deeplink: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DecodeRecipeRequest {
|
||||
deeplink: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DecodeRecipeResponse {
|
||||
recipe: Recipe,
|
||||
}
|
||||
|
||||
/// Create a Recipe configuration from the current state of an agent
|
||||
async fn create_recipe(
|
||||
State(state): State<Arc<AppState>>,
|
||||
@@ -84,8 +105,70 @@ async fn create_recipe(
|
||||
}
|
||||
}
|
||||
|
||||
async fn encode_recipe(
|
||||
Json(request): Json<EncodeRecipeRequest>,
|
||||
) -> Result<Json<EncodeRecipeResponse>, StatusCode> {
|
||||
match recipe_deeplink::encode(&request.recipe) {
|
||||
Ok(encoded) => Ok(Json(EncodeRecipeResponse { deeplink: encoded })),
|
||||
Err(err) => {
|
||||
tracing::error!("Failed to encode recipe: {}", err);
|
||||
Err(StatusCode::BAD_REQUEST)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn decode_recipe(
|
||||
Json(request): Json<DecodeRecipeRequest>,
|
||||
) -> Result<Json<DecodeRecipeResponse>, StatusCode> {
|
||||
match recipe_deeplink::decode(&request.deeplink) {
|
||||
Ok(recipe) => Ok(Json(DecodeRecipeResponse { recipe })),
|
||||
Err(err) => {
|
||||
tracing::error!("Failed to decode deeplink: {}", err);
|
||||
Err(StatusCode::BAD_REQUEST)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn routes(state: Arc<AppState>) -> Router {
|
||||
Router::new()
|
||||
.route("/recipe/create", post(create_recipe))
|
||||
.route("/recipes/encode", post(encode_recipe))
|
||||
.route("/recipes/decode", post(decode_recipe))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use goose::recipe::Recipe;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_decode_and_encode_recipe() {
|
||||
let original_recipe = Recipe::builder()
|
||||
.title("Test Recipe")
|
||||
.description("A test recipe")
|
||||
.instructions("Test instructions")
|
||||
.build()
|
||||
.unwrap();
|
||||
let encoded = recipe_deeplink::encode(&original_recipe).unwrap();
|
||||
|
||||
let request = DecodeRecipeRequest {
|
||||
deeplink: encoded.clone(),
|
||||
};
|
||||
let response = decode_recipe(Json(request)).await;
|
||||
|
||||
assert!(response.is_ok());
|
||||
let decoded = response.unwrap().0.recipe;
|
||||
assert_eq!(decoded.title, original_recipe.title);
|
||||
assert_eq!(decoded.description, original_recipe.description);
|
||||
assert_eq!(decoded.instructions, original_recipe.instructions);
|
||||
|
||||
let encode_request = EncodeRecipeRequest { recipe: decoded };
|
||||
let encode_response = encode_recipe(Json(encode_request)).await;
|
||||
|
||||
assert!(encode_response.is_ok());
|
||||
let encoded_again = encode_response.unwrap().0.deeplink;
|
||||
assert!(!encoded_again.is_empty());
|
||||
assert_eq!(encoded, encoded_again);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ nanoid = "0.4"
|
||||
sha2 = "0.10"
|
||||
base64 = "0.21"
|
||||
url = "2.5"
|
||||
urlencoding = "2.1"
|
||||
axum = "0.8.1"
|
||||
webbrowser = "0.8"
|
||||
lazy_static = "1.5.0"
|
||||
|
||||
@@ -8,6 +8,7 @@ pub mod project;
|
||||
pub mod prompt_template;
|
||||
pub mod providers;
|
||||
pub mod recipe;
|
||||
pub mod recipe_deeplink;
|
||||
pub mod scheduler;
|
||||
pub mod scheduler_factory;
|
||||
pub mod scheduler_trait;
|
||||
|
||||
108
crates/goose/src/recipe_deeplink.rs
Normal file
108
crates/goose/src/recipe_deeplink.rs
Normal file
@@ -0,0 +1,108 @@
|
||||
use anyhow::Result;
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::recipe::Recipe;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum DecodeError {
|
||||
#[error("All decoding methods failed")]
|
||||
AllMethodsFailed,
|
||||
}
|
||||
|
||||
pub fn encode(recipe: &Recipe) -> Result<String, serde_json::Error> {
|
||||
let recipe_json = serde_json::to_string(recipe)?;
|
||||
let encoded = URL_SAFE_NO_PAD.encode(recipe_json.as_bytes());
|
||||
Ok(encoded)
|
||||
}
|
||||
|
||||
pub fn decode(link: &str) -> Result<Recipe, DecodeError> {
|
||||
// Handle the current format: URL-safe Base64 without padding.
|
||||
if let Ok(decoded_bytes) = URL_SAFE_NO_PAD.decode(link) {
|
||||
if let Ok(recipe_json) = String::from_utf8(decoded_bytes) {
|
||||
if let Ok(recipe) = serde_json::from_str::<Recipe>(&recipe_json) {
|
||||
return Ok(recipe);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle legacy formats of 'standard base64 encoded' and standard base64 encoded that was then url encoded.
|
||||
if let Ok(url_decoded) = urlencoding::decode(link) {
|
||||
if let Ok(decoded_bytes) =
|
||||
base64::engine::general_purpose::STANDARD.decode(url_decoded.as_bytes())
|
||||
{
|
||||
if let Ok(recipe_json) = String::from_utf8(decoded_bytes) {
|
||||
if let Ok(recipe) = serde_json::from_str::<Recipe>(&recipe_json) {
|
||||
return Ok(recipe);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(DecodeError::AllMethodsFailed)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::recipe::Recipe;
|
||||
|
||||
fn create_test_recipe() -> Recipe {
|
||||
Recipe::builder()
|
||||
.title("Test Recipe")
|
||||
.description("A test recipe for deeplink encoding/decoding")
|
||||
.instructions("Act as a helpful assistant")
|
||||
.build()
|
||||
.expect("Failed to build test recipe")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encode_decode_round_trip() {
|
||||
let original_recipe = create_test_recipe();
|
||||
|
||||
let encoded = encode(&original_recipe).expect("Failed to encode recipe");
|
||||
assert!(!encoded.is_empty());
|
||||
|
||||
let decoded_recipe = decode(&encoded).expect("Failed to decode recipe");
|
||||
|
||||
assert_eq!(original_recipe.title, decoded_recipe.title);
|
||||
assert_eq!(original_recipe.description, decoded_recipe.description);
|
||||
assert_eq!(original_recipe.instructions, decoded_recipe.instructions);
|
||||
assert_eq!(original_recipe.version, decoded_recipe.version);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decode_legacy_standard_base64() {
|
||||
let recipe = create_test_recipe();
|
||||
let recipe_json = serde_json::to_string(&recipe).unwrap();
|
||||
let legacy_encoded =
|
||||
base64::engine::general_purpose::STANDARD.encode(recipe_json.as_bytes());
|
||||
|
||||
let decoded_recipe = decode(&legacy_encoded).expect("Failed to decode legacy format");
|
||||
assert_eq!(recipe.title, decoded_recipe.title);
|
||||
assert_eq!(recipe.description, decoded_recipe.description);
|
||||
assert_eq!(recipe.instructions, decoded_recipe.instructions);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decode_legacy_url_encoded_base64() {
|
||||
let recipe = create_test_recipe();
|
||||
let recipe_json = serde_json::to_string(&recipe).unwrap();
|
||||
let base64_encoded =
|
||||
base64::engine::general_purpose::STANDARD.encode(recipe_json.as_bytes());
|
||||
let url_encoded = urlencoding::encode(&base64_encoded);
|
||||
|
||||
let decoded_recipe =
|
||||
decode(&url_encoded).expect("Failed to decode URL-encoded legacy format");
|
||||
assert_eq!(recipe.title, decoded_recipe.title);
|
||||
assert_eq!(recipe.description, decoded_recipe.description);
|
||||
assert_eq!(recipe.instructions, decoded_recipe.instructions);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decode_invalid_input() {
|
||||
let result = decode("invalid_base64!");
|
||||
assert!(result.is_err());
|
||||
assert!(matches!(result.unwrap_err(), DecodeError::AllMethodsFailed));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user