From 1ac5a0a23a4fae81d4fa7620532b0f6a57a04a74 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Sun, 25 Jan 2026 00:16:43 -0800 Subject: [PATCH] feat: add openapi generator and error schemas --- engine/packages/error/Cargo.toml | 11 + engine/packages/error/src/lib.rs | 302 +++++++++++++++++++++++++ engine/packages/openapi-gen/Cargo.toml | 12 + engine/packages/openapi-gen/build.rs | 20 ++ engine/packages/openapi-gen/src/lib.rs | 3 + 5 files changed, 348 insertions(+) create mode 100644 engine/packages/error/Cargo.toml create mode 100644 engine/packages/error/src/lib.rs create mode 100644 engine/packages/openapi-gen/Cargo.toml create mode 100644 engine/packages/openapi-gen/build.rs create mode 100644 engine/packages/openapi-gen/src/lib.rs diff --git a/engine/packages/error/Cargo.toml b/engine/packages/error/Cargo.toml new file mode 100644 index 0000000..b38c8f9 --- /dev/null +++ b/engine/packages/error/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "error" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +thiserror = "1.0" +schemars = "0.8" +utoipa = "4.2" diff --git a/engine/packages/error/src/lib.rs b/engine/packages/error/src/lib.rs new file mode 100644 index 0000000..4db9e55 --- /dev/null +++ b/engine/packages/error/src/lib.rs @@ -0,0 +1,302 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use thiserror::Error; +use utoipa::ToSchema; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum ErrorType { + InvalidRequest, + UnsupportedAgent, + AgentNotInstalled, + InstallFailed, + AgentProcessExited, + TokenInvalid, + PermissionDenied, + SessionNotFound, + SessionAlreadyExists, + ModeNotSupported, + StreamError, + Timeout, +} + +impl ErrorType { + pub fn as_urn(&self) -> &'static str { + match self { + Self::InvalidRequest => "urn:sandbox-daemon:error:invalid_request", + Self::UnsupportedAgent => "urn:sandbox-daemon:error:unsupported_agent", + Self::AgentNotInstalled => "urn:sandbox-daemon:error:agent_not_installed", + Self::InstallFailed => "urn:sandbox-daemon:error:install_failed", + Self::AgentProcessExited => "urn:sandbox-daemon:error:agent_process_exited", + Self::TokenInvalid => "urn:sandbox-daemon:error:token_invalid", + Self::PermissionDenied => "urn:sandbox-daemon:error:permission_denied", + Self::SessionNotFound => "urn:sandbox-daemon:error:session_not_found", + Self::SessionAlreadyExists => "urn:sandbox-daemon:error:session_already_exists", + Self::ModeNotSupported => "urn:sandbox-daemon:error:mode_not_supported", + Self::StreamError => "urn:sandbox-daemon:error:stream_error", + Self::Timeout => "urn:sandbox-daemon:error:timeout", + } + } + + pub fn title(&self) -> &'static str { + match self { + Self::InvalidRequest => "Invalid Request", + Self::UnsupportedAgent => "Unsupported Agent", + Self::AgentNotInstalled => "Agent Not Installed", + Self::InstallFailed => "Install Failed", + Self::AgentProcessExited => "Agent Process Exited", + Self::TokenInvalid => "Token Invalid", + Self::PermissionDenied => "Permission Denied", + Self::SessionNotFound => "Session Not Found", + Self::SessionAlreadyExists => "Session Already Exists", + Self::ModeNotSupported => "Mode Not Supported", + Self::StreamError => "Stream Error", + Self::Timeout => "Timeout", + } + } + + pub fn status_code(&self) -> u16 { + match self { + Self::InvalidRequest => 400, + Self::UnsupportedAgent => 400, + Self::AgentNotInstalled => 404, + Self::InstallFailed => 500, + Self::AgentProcessExited => 500, + Self::TokenInvalid => 401, + Self::PermissionDenied => 403, + Self::SessionNotFound => 404, + Self::SessionAlreadyExists => 409, + Self::ModeNotSupported => 400, + Self::StreamError => 502, + Self::Timeout => 504, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] +pub struct ProblemDetails { + #[serde(rename = "type")] + pub type_: String, + pub title: String, + pub status: u16, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub detail: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub instance: Option, + #[serde(flatten, default, skip_serializing_if = "Map::is_empty")] + pub extensions: Map, +} + +impl ProblemDetails { + pub fn new(error_type: ErrorType, detail: Option) -> Self { + Self { + type_: error_type.as_urn().to_string(), + title: error_type.title().to_string(), + status: error_type.status_code(), + detail, + instance: None, + extensions: Map::new(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] +pub struct AgentError { + #[serde(rename = "type")] + pub type_: ErrorType, + pub message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub details: Option, +} + +#[derive(Debug, Error)] +pub enum SandboxError { + #[error("invalid request: {message}")] + InvalidRequest { message: String }, + #[error("unsupported agent: {agent}")] + UnsupportedAgent { agent: String }, + #[error("agent not installed: {agent}")] + AgentNotInstalled { agent: String }, + #[error("install failed: {agent}")] + InstallFailed { agent: String, stderr: Option }, + #[error("agent process exited: {agent}")] + AgentProcessExited { + agent: String, + exit_code: Option, + stderr: Option, + }, + #[error("token invalid")] + TokenInvalid { message: Option }, + #[error("permission denied")] + PermissionDenied { message: Option }, + #[error("session not found: {session_id}")] + SessionNotFound { session_id: String }, + #[error("session already exists: {session_id}")] + SessionAlreadyExists { session_id: String }, + #[error("mode not supported: {agent} {mode}")] + ModeNotSupported { agent: String, mode: String }, + #[error("stream error: {message}")] + StreamError { message: String }, + #[error("timeout")] + Timeout { message: Option }, +} + +impl SandboxError { + pub fn error_type(&self) -> ErrorType { + match self { + Self::InvalidRequest { .. } => ErrorType::InvalidRequest, + Self::UnsupportedAgent { .. } => ErrorType::UnsupportedAgent, + Self::AgentNotInstalled { .. } => ErrorType::AgentNotInstalled, + Self::InstallFailed { .. } => ErrorType::InstallFailed, + Self::AgentProcessExited { .. } => ErrorType::AgentProcessExited, + Self::TokenInvalid { .. } => ErrorType::TokenInvalid, + Self::PermissionDenied { .. } => ErrorType::PermissionDenied, + Self::SessionNotFound { .. } => ErrorType::SessionNotFound, + Self::SessionAlreadyExists { .. } => ErrorType::SessionAlreadyExists, + Self::ModeNotSupported { .. } => ErrorType::ModeNotSupported, + Self::StreamError { .. } => ErrorType::StreamError, + Self::Timeout { .. } => ErrorType::Timeout, + } + } + + pub fn to_agent_error(&self) -> AgentError { + let (agent, session_id, details) = match self { + Self::InvalidRequest { .. } => (None, None, None), + Self::UnsupportedAgent { agent } => { + (Some(agent.clone()), None, None) + } + Self::AgentNotInstalled { agent } => (Some(agent.clone()), None, None), + Self::InstallFailed { agent, stderr } => { + let mut map = Map::new(); + if let Some(stderr) = stderr { + map.insert("stderr".to_string(), Value::String(stderr.clone())); + } + ( + Some(agent.clone()), + None, + if map.is_empty() { None } else { Some(Value::Object(map)) }, + ) + } + Self::AgentProcessExited { + agent, + exit_code, + stderr, + } => { + let mut map = Map::new(); + if let Some(code) = exit_code { + map.insert( + "exitCode".to_string(), + Value::Number(serde_json::Number::from(*code as i64)), + ); + } + if let Some(stderr) = stderr { + map.insert("stderr".to_string(), Value::String(stderr.clone())); + } + ( + Some(agent.clone()), + None, + if map.is_empty() { None } else { Some(Value::Object(map)) }, + ) + } + Self::TokenInvalid { message } => { + let details = message.as_ref().map(|msg| { + let mut map = Map::new(); + map.insert("message".to_string(), Value::String(msg.clone())); + Value::Object(map) + }); + (None, None, details) + } + Self::PermissionDenied { message } => { + let details = message.as_ref().map(|msg| { + let mut map = Map::new(); + map.insert("message".to_string(), Value::String(msg.clone())); + Value::Object(map) + }); + (None, None, details) + } + Self::SessionNotFound { session_id } => { + (None, Some(session_id.clone()), None) + } + Self::SessionAlreadyExists { session_id } => { + (None, Some(session_id.clone()), None) + } + Self::ModeNotSupported { agent, mode } => { + let mut map = Map::new(); + map.insert("mode".to_string(), Value::String(mode.clone())); + ( + Some(agent.clone()), + None, + Some(Value::Object(map)), + ) + } + Self::StreamError { message } => { + let mut map = Map::new(); + map.insert("message".to_string(), Value::String(message.clone())); + (None, None, Some(Value::Object(map))) + } + Self::Timeout { message } => { + let details = message.as_ref().map(|msg| { + let mut map = Map::new(); + map.insert("message".to_string(), Value::String(msg.clone())); + Value::Object(map) + }); + (None, None, details) + } + }; + + AgentError { + type_: self.error_type(), + message: self.to_string(), + agent, + session_id, + details, + } + } + + pub fn to_problem_details(&self) -> ProblemDetails { + let mut problem = ProblemDetails::new(self.error_type(), Some(self.to_string())); + let agent_error = self.to_agent_error(); + + let mut extensions = Map::new(); + if let Some(agent) = agent_error.agent { + extensions.insert("agent".to_string(), Value::String(agent)); + } + if let Some(session_id) = agent_error.session_id { + extensions.insert("sessionId".to_string(), Value::String(session_id)); + } + if let Some(details) = agent_error.details { + extensions.insert("details".to_string(), details); + } + problem.extensions = extensions; + problem + } +} + +impl From for ProblemDetails { + fn from(value: SandboxError) -> Self { + value.to_problem_details() + } +} + +impl From<&SandboxError> for ProblemDetails { + fn from(value: &SandboxError) -> Self { + value.to_problem_details() + } +} + +impl From for AgentError { + fn from(value: SandboxError) -> Self { + value.to_agent_error() + } +} + +impl From<&SandboxError> for AgentError { + fn from(value: &SandboxError) -> Self { + value.to_agent_error() + } +} diff --git a/engine/packages/openapi-gen/Cargo.toml b/engine/packages/openapi-gen/Cargo.toml new file mode 100644 index 0000000..636ea77 --- /dev/null +++ b/engine/packages/openapi-gen/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "openapi-gen" +version = "0.1.0" +edition = "2021" +build = "build.rs" + +[dependencies] + +[build-dependencies] +sandbox-daemon = { path = "../sandbox-daemon" } +serde_json = "1.0" +utoipa = "4.2" diff --git a/engine/packages/openapi-gen/build.rs b/engine/packages/openapi-gen/build.rs new file mode 100644 index 0000000..d10d64c --- /dev/null +++ b/engine/packages/openapi-gen/build.rs @@ -0,0 +1,20 @@ +use std::fs; +use std::path::Path; + +use sandbox_daemon::router::ApiDoc; +use utoipa::OpenApi; + +fn main() { + println!("cargo:rerun-if-changed=../sandbox-daemon/src/router.rs"); + println!("cargo:rerun-if-changed=../sandbox-daemon/src/lib.rs"); + + let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR not set"); + let out_path = Path::new(&out_dir).join("openapi.json"); + + let openapi = ApiDoc::openapi(); + let json = serde_json::to_string_pretty(&openapi) + .expect("Failed to serialize OpenAPI spec"); + + fs::write(&out_path, json).expect("Failed to write OpenAPI spec"); + println!("cargo:warning=Generated OpenAPI spec at {}", out_path.display()); +} diff --git a/engine/packages/openapi-gen/src/lib.rs b/engine/packages/openapi-gen/src/lib.rs new file mode 100644 index 0000000..08f6999 --- /dev/null +++ b/engine/packages/openapi-gen/src/lib.rs @@ -0,0 +1,3 @@ +//! Generated OpenAPI schema output. + +pub const OPENAPI_JSON: &str = include_str!(concat!(env!("OUT_DIR"), "/openapi.json"));