tangle


git clone https://radroots.dev/git/tangle.git
Log | Files | Refs | README | LICENSE

commit 5635b88327826d48db6844ee888fd035b5965552
parent 2a1550d7e925c696dbe0502a357b748c6fd0212d
Author: triesap <tyson@radroots.org>
Date:   Sat,  6 Jun 2026 00:41:22 -0700

http: add api error model

Diffstat:
MCargo.lock | 8++++++++
MCargo.toml | 1+
Acrates/tangle_runtime/Cargo.toml | 17+++++++++++++++++
Acrates/tangle_runtime/src/lib.rs | 194+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 220 insertions(+), 0 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -3881,6 +3881,14 @@ dependencies = [ ] [[package]] +name = "tangle_runtime" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] name = "tangle_store" version = "0.1.0" dependencies = [ diff --git a/Cargo.toml b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "crates/tangle_crypto", "crates/tangle_nips", "crates/tangle_protocol", + "crates/tangle_runtime", "crates/tangle_store", "crates/tangle_store_surreal", "crates/tangle_test_support", diff --git a/crates/tangle_runtime/Cargo.toml b/crates/tangle_runtime/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "tangle_runtime" +version.workspace = true +edition.workspace = true +authors.workspace = true +rust-version.workspace = true +license.workspace = true +description = "HTTP, WebSocket, and CLI runtime surfaces for tangle" + +[dependencies] +serde = { version = "1", features = ["derive"] } + +[dev-dependencies] +serde_json = "1" + +[lints] +workspace = true diff --git a/crates/tangle_runtime/src/lib.rs b/crates/tangle_runtime/src/lib.rs @@ -0,0 +1,194 @@ +#![forbid(unsafe_code)] + +use core::fmt; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ApiErrorCode { + InvalidRequest, + Unauthorized, + Forbidden, + NotFound, + Conflict, + Internal, +} + +impl ApiErrorCode { + pub fn as_str(self) -> &'static str { + match self { + Self::InvalidRequest => "invalid_request", + Self::Unauthorized => "unauthorized", + Self::Forbidden => "forbidden", + Self::NotFound => "not_found", + Self::Conflict => "conflict", + Self::Internal => "internal_error", + } + } + + pub fn http_status(self) -> u16 { + match self { + Self::InvalidRequest => 400, + Self::Unauthorized => 401, + Self::Forbidden => 403, + Self::NotFound => 404, + Self::Conflict => 409, + Self::Internal => 500, + } + } +} + +impl fmt::Display for ApiErrorCode { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ApiError { + code: ApiErrorCode, + message: String, +} + +impl ApiError { + pub fn new(code: ApiErrorCode, message: impl Into<String>) -> Self { + Self { + code, + message: message.into(), + } + } + + pub fn invalid_request(message: impl Into<String>) -> Self { + Self::new(ApiErrorCode::InvalidRequest, message) + } + + pub fn unauthorized(message: impl Into<String>) -> Self { + Self::new(ApiErrorCode::Unauthorized, message) + } + + pub fn forbidden(message: impl Into<String>) -> Self { + Self::new(ApiErrorCode::Forbidden, message) + } + + pub fn not_found(message: impl Into<String>) -> Self { + Self::new(ApiErrorCode::NotFound, message) + } + + pub fn conflict(message: impl Into<String>) -> Self { + Self::new(ApiErrorCode::Conflict, message) + } + + pub fn internal() -> Self { + Self::new(ApiErrorCode::Internal, "internal server error") + } + + pub fn code(&self) -> ApiErrorCode { + self.code + } + + pub fn message(&self) -> &str { + &self.message + } + + pub fn http_status(&self) -> u16 { + self.code.http_status() + } + + pub fn envelope(&self) -> ApiErrorEnvelope { + ApiErrorEnvelope { + error: ApiErrorBody { + code: self.code.as_str().to_owned(), + message: self.message.clone(), + }, + } + } +} + +impl fmt::Display for ApiError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{}: {}", self.code, self.message) + } +} + +impl std::error::Error for ApiError {} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ApiErrorEnvelope { + pub error: ApiErrorBody, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ApiErrorBody { + pub code: String, + pub message: String, +} + +#[cfg(test)] +mod tests { + use super::{ApiError, ApiErrorBody, ApiErrorCode, ApiErrorEnvelope}; + + #[test] + fn api_error_codes_have_stable_labels_and_statuses() { + let cases = [ + (ApiErrorCode::InvalidRequest, "invalid_request", 400), + (ApiErrorCode::Unauthorized, "unauthorized", 401), + (ApiErrorCode::Forbidden, "forbidden", 403), + (ApiErrorCode::NotFound, "not_found", 404), + (ApiErrorCode::Conflict, "conflict", 409), + (ApiErrorCode::Internal, "internal_error", 500), + ]; + for (code, label, status) in cases { + assert_eq!(code.as_str(), label); + assert_eq!(code.to_string(), label); + assert_eq!(code.http_status(), status); + } + } + + #[test] + fn api_error_constructors_preserve_public_envelope_shape() { + let errors = [ + ApiError::invalid_request("bad query"), + ApiError::unauthorized("authentication required"), + ApiError::forbidden("admin role required"), + ApiError::not_found("listing not found"), + ApiError::conflict("event already exists"), + ApiError::internal(), + ]; + assert_eq!(errors[0].http_status(), 400); + assert_eq!(errors[1].http_status(), 401); + assert_eq!(errors[2].http_status(), 403); + assert_eq!(errors[3].http_status(), 404); + assert_eq!(errors[4].http_status(), 409); + assert_eq!(errors[5].http_status(), 500); + assert_eq!(errors[0].code(), ApiErrorCode::InvalidRequest); + assert_eq!(errors[0].message(), "bad query"); + assert_eq!(errors[0].to_string(), "invalid_request: bad query"); + assert_eq!( + errors[5].envelope(), + ApiErrorEnvelope { + error: ApiErrorBody { + code: "internal_error".to_owned(), + message: "internal server error".to_owned() + } + } + ); + assert_eq!( + serde_json::to_value(errors[0].envelope()).expect("json"), + serde_json::json!({ + "error": { + "code": "invalid_request", + "message": "bad query" + } + }) + ); + assert_eq!( + serde_json::from_value::<ApiErrorEnvelope>(serde_json::json!({ + "error": { + "code": "invalid_request", + "message": "bad query" + } + })) + .expect("envelope"), + errors[0].envelope() + ); + } +}