feat: add openapi generator and error schemas

This commit is contained in:
Nathan Flurry 2026-01-25 00:16:43 -08:00
parent 55c45bfc12
commit 1ac5a0a23a
5 changed files with 348 additions and 0 deletions

View file

@ -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"

View file

@ -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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub instance: Option<String>,
#[serde(flatten, default, skip_serializing_if = "Map::is_empty")]
pub extensions: Map<String, Value>,
}
impl ProblemDetails {
pub fn new(error_type: ErrorType, detail: Option<String>) -> 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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub details: Option<Value>,
}
#[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<String> },
#[error("agent process exited: {agent}")]
AgentProcessExited {
agent: String,
exit_code: Option<i32>,
stderr: Option<String>,
},
#[error("token invalid")]
TokenInvalid { message: Option<String> },
#[error("permission denied")]
PermissionDenied { message: Option<String> },
#[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<String> },
}
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<SandboxError> 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<SandboxError> 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()
}
}

View file

@ -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"

View file

@ -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());
}

View file

@ -0,0 +1,3 @@
//! Generated OpenAPI schema output.
pub const OPENAPI_JSON: &str = include_str!(concat!(env!("OUT_DIR"), "/openapi.json"));