use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct HealthResponse {
pub status: String,
pub version: String,
pub uptime_seconds: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub dagdb_runtime_status: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dagdb_runtime_reason: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExochainDiscoveryResponse {
pub base_url: String,
pub routes: ExochainDiscoveryRoutes,
pub sdk: ExochainSdkDiscovery,
pub mcp: ExochainMcpDiscovery,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExochainDiscoveryRoutes {
pub health: String,
pub ready: String,
pub avc: ExochainAvcDiscoveryRoutes,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExochainAvcDiscoveryRoutes {
pub issue: String,
pub validate: String,
pub receipts_emit: String,
pub receipts_get: String,
pub protocol: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExochainSdkDiscovery {
pub rust: String,
pub typescript: String,
pub python: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExochainMcpDiscovery {
pub public_transport: bool,
pub transports: Vec<String>,
pub capabilities: Vec<String>,
}
impl ExochainDiscoveryResponse {
#[must_use]
pub fn canonical() -> Self {
Self {
base_url: "https://exochain.io".to_owned(),
routes: ExochainDiscoveryRoutes {
health: "/health".to_owned(),
ready: "/ready".to_owned(),
avc: ExochainAvcDiscoveryRoutes {
issue: "/api/v1/avc/issue".to_owned(),
validate: "/api/v1/avc/validate".to_owned(),
receipts_emit: "/api/v1/avc/receipts/emit".to_owned(),
receipts_get: "/api/v1/avc/receipts/:hash".to_owned(),
protocol: "/api/v1/avc/protocol".to_owned(),
},
},
sdk: ExochainSdkDiscovery {
rust: "crates/exochain-sdk".to_owned(),
typescript: "packages/exochain-sdk".to_owned(),
python: "packages/exochain-py".to_owned(),
},
mcp: ExochainMcpDiscovery {
public_transport: false,
transports: vec!["stdio".to_owned(), "loopback-sse".to_owned()],
capabilities: vec![
"tools".to_owned(),
"resources".to_owned(),
"prompts".to_owned(),
],
},
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RestRoute {
ExochainDiscovery,
Health,
Ready,
GatewayMetrics,
DbHealth,
GetDecision,
CreateDecision,
AuthToken,
GetConstitution,
EDiscoveryExport,
AuditTrail,
AuthRegister,
AuthLogin,
AuthRefresh,
AuthMe,
AuthLogout,
AgentEnroll,
ListAgents,
GetAgent,
AdvanceAgentPace,
GetIdentityScore,
DeleteIdentity,
ListUsers,
AdvanceUserPace,
ListLayoutTemplates,
PutLayoutTemplate,
DeleteLayoutTemplate,
ListFeedbackIssues,
CreateFeedbackIssue,
UpdateFeedbackIssue,
}
impl RestRoute {
pub fn method(&self) -> &str {
match self {
RestRoute::ExochainDiscovery
| RestRoute::Health
| RestRoute::Ready
| RestRoute::GatewayMetrics
| RestRoute::DbHealth
| RestRoute::GetDecision
| RestRoute::GetConstitution
| RestRoute::AuditTrail
| RestRoute::AuthMe
| RestRoute::ListAgents
| RestRoute::GetAgent
| RestRoute::GetIdentityScore
| RestRoute::ListUsers
| RestRoute::ListLayoutTemplates
| RestRoute::ListFeedbackIssues => "GET",
RestRoute::CreateDecision
| RestRoute::AuthToken
| RestRoute::EDiscoveryExport
| RestRoute::AuthRegister
| RestRoute::AuthLogin
| RestRoute::AuthRefresh
| RestRoute::AuthLogout
| RestRoute::AgentEnroll
| RestRoute::AdvanceAgentPace
| RestRoute::AdvanceUserPace
| RestRoute::CreateFeedbackIssue => "POST",
RestRoute::PutLayoutTemplate => "PUT",
RestRoute::DeleteIdentity | RestRoute::DeleteLayoutTemplate => "DELETE",
RestRoute::UpdateFeedbackIssue => "PATCH",
}
}
pub fn path(&self) -> &str {
match self {
RestRoute::ExochainDiscovery => "/.well-known/exochain.json",
RestRoute::Health => "/health",
RestRoute::Ready => "/ready",
RestRoute::GatewayMetrics => "/gateway/metrics",
RestRoute::DbHealth => "/health/db",
RestRoute::GetDecision => "/api/v1/decisions/:id",
RestRoute::CreateDecision => "/api/v1/decisions",
RestRoute::AuthToken => "/api/v1/auth/token",
RestRoute::GetConstitution => "/api/v1/tenants/:id/constitution",
RestRoute::EDiscoveryExport => "/api/v1/ediscovery/export",
RestRoute::AuditTrail => "/api/v1/audit/:decision_id",
RestRoute::AuthRegister => "/api/v1/auth/register",
RestRoute::AuthLogin => "/api/v1/auth/login",
RestRoute::AuthRefresh => "/api/v1/auth/refresh",
RestRoute::AuthMe => "/api/v1/auth/me",
RestRoute::AuthLogout => "/api/v1/auth/logout",
RestRoute::AgentEnroll => "/api/v1/agents/enroll",
RestRoute::ListAgents => "/api/v1/agents",
RestRoute::GetAgent => "/api/v1/agents/:did",
RestRoute::AdvanceAgentPace => "/api/v1/agents/:did/advance-pace",
RestRoute::GetIdentityScore => "/api/v1/identity/:did/score",
RestRoute::DeleteIdentity => "/api/v1/identity/:did",
RestRoute::ListUsers => "/api/v1/users",
RestRoute::AdvanceUserPace => "/api/v1/users/:did/advance-pace",
RestRoute::ListLayoutTemplates => "/api/v1/layout-templates",
RestRoute::PutLayoutTemplate => "/api/v1/layout-templates",
RestRoute::DeleteLayoutTemplate => "/api/v1/layout-templates/:id",
RestRoute::ListFeedbackIssues => "/api/v1/feedback-issues",
RestRoute::CreateFeedbackIssue => "/api/v1/feedback-issues",
RestRoute::UpdateFeedbackIssue => "/api/v1/feedback-issues/:id",
}
}
pub fn all() -> Vec<RestRoute> {
vec![
RestRoute::ExochainDiscovery,
RestRoute::Health,
RestRoute::Ready,
RestRoute::GatewayMetrics,
RestRoute::DbHealth,
RestRoute::GetDecision,
RestRoute::CreateDecision,
RestRoute::AuthToken,
RestRoute::GetConstitution,
RestRoute::EDiscoveryExport,
RestRoute::AuditTrail,
RestRoute::AuthRegister,
RestRoute::AuthLogin,
RestRoute::AuthRefresh,
RestRoute::AuthMe,
RestRoute::AuthLogout,
RestRoute::AgentEnroll,
RestRoute::ListAgents,
RestRoute::GetAgent,
RestRoute::AdvanceAgentPace,
RestRoute::GetIdentityScore,
RestRoute::DeleteIdentity,
RestRoute::ListUsers,
RestRoute::AdvanceUserPace,
RestRoute::ListLayoutTemplates,
RestRoute::PutLayoutTemplate,
RestRoute::DeleteLayoutTemplate,
RestRoute::ListFeedbackIssues,
RestRoute::CreateFeedbackIssue,
RestRoute::UpdateFeedbackIssue,
]
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
use super::*;
#[test]
fn test_route_methods() {
assert_eq!(RestRoute::ExochainDiscovery.method(), "GET");
assert_eq!(RestRoute::Health.method(), "GET");
assert_eq!(RestRoute::Ready.method(), "GET");
assert_eq!(RestRoute::GatewayMetrics.method(), "GET");
assert_eq!(RestRoute::DbHealth.method(), "GET");
assert_eq!(RestRoute::CreateDecision.method(), "POST");
assert_eq!(RestRoute::AuthRegister.method(), "POST");
assert_eq!(RestRoute::AuthLogin.method(), "POST");
assert_eq!(RestRoute::AuthMe.method(), "GET");
assert_eq!(RestRoute::ListAgents.method(), "GET");
assert_eq!(RestRoute::AgentEnroll.method(), "POST");
assert_eq!(RestRoute::GetIdentityScore.method(), "GET");
assert_eq!(RestRoute::DeleteIdentity.method(), "DELETE");
assert_eq!(RestRoute::ListUsers.method(), "GET");
assert_eq!(RestRoute::AdvanceUserPace.method(), "POST");
assert_eq!(RestRoute::ListLayoutTemplates.method(), "GET");
assert_eq!(RestRoute::PutLayoutTemplate.method(), "PUT");
assert_eq!(RestRoute::DeleteLayoutTemplate.method(), "DELETE");
assert_eq!(RestRoute::ListFeedbackIssues.method(), "GET");
assert_eq!(RestRoute::CreateFeedbackIssue.method(), "POST");
assert_eq!(RestRoute::UpdateFeedbackIssue.method(), "PATCH");
}
#[test]
fn test_route_paths() {
assert_eq!(
RestRoute::ExochainDiscovery.path(),
"/.well-known/exochain.json"
);
assert_eq!(RestRoute::Health.path(), "/health");
assert_eq!(RestRoute::Ready.path(), "/ready");
assert_eq!(RestRoute::GatewayMetrics.path(), "/gateway/metrics");
assert_eq!(RestRoute::DbHealth.path(), "/health/db");
assert_eq!(RestRoute::GetDecision.path(), "/api/v1/decisions/:id");
assert_eq!(RestRoute::CreateDecision.path(), "/api/v1/decisions");
assert_eq!(RestRoute::AuthToken.path(), "/api/v1/auth/token");
assert_eq!(
RestRoute::GetConstitution.path(),
"/api/v1/tenants/:id/constitution"
);
assert_eq!(
RestRoute::EDiscoveryExport.path(),
"/api/v1/ediscovery/export"
);
assert_eq!(RestRoute::AuditTrail.path(), "/api/v1/audit/:decision_id");
assert_eq!(RestRoute::AuthRegister.path(), "/api/v1/auth/register");
assert_eq!(RestRoute::AuthLogin.path(), "/api/v1/auth/login");
assert_eq!(RestRoute::AuthRefresh.path(), "/api/v1/auth/refresh");
assert_eq!(RestRoute::AuthMe.path(), "/api/v1/auth/me");
assert_eq!(RestRoute::AuthLogout.path(), "/api/v1/auth/logout");
assert_eq!(RestRoute::AgentEnroll.path(), "/api/v1/agents/enroll");
assert_eq!(RestRoute::ListAgents.path(), "/api/v1/agents");
assert_eq!(RestRoute::GetAgent.path(), "/api/v1/agents/:did");
assert_eq!(
RestRoute::AdvanceAgentPace.path(),
"/api/v1/agents/:did/advance-pace"
);
assert_eq!(
RestRoute::GetIdentityScore.path(),
"/api/v1/identity/:did/score"
);
assert_eq!(RestRoute::DeleteIdentity.path(), "/api/v1/identity/:did");
assert_eq!(RestRoute::ListUsers.path(), "/api/v1/users");
assert_eq!(
RestRoute::AdvanceUserPace.path(),
"/api/v1/users/:did/advance-pace"
);
assert_eq!(
RestRoute::ListLayoutTemplates.path(),
"/api/v1/layout-templates"
);
assert_eq!(
RestRoute::PutLayoutTemplate.path(),
"/api/v1/layout-templates"
);
assert_eq!(
RestRoute::DeleteLayoutTemplate.path(),
"/api/v1/layout-templates/:id"
);
assert_eq!(
RestRoute::ListFeedbackIssues.path(),
"/api/v1/feedback-issues"
);
assert_eq!(
RestRoute::CreateFeedbackIssue.path(),
"/api/v1/feedback-issues"
);
assert_eq!(
RestRoute::UpdateFeedbackIssue.path(),
"/api/v1/feedback-issues/:id"
);
}
#[test]
fn health_response_fields() {
let r = HealthResponse {
status: "ok".into(),
version: "0.1.0".into(),
uptime_seconds: 42,
dagdb_runtime_status: None,
dagdb_runtime_reason: None,
};
assert_eq!(r.status, "ok");
assert_eq!(r.version, "0.1.0");
assert_eq!(r.uptime_seconds, 42);
assert_eq!(r.dagdb_runtime_status, None);
assert_eq!(r.dagdb_runtime_reason, None);
}
#[test]
fn discovery_document_advertises_public_api_sdk_and_mcp_boundaries() {
let discovery = ExochainDiscoveryResponse::canonical();
assert_eq!(discovery.base_url, "https://exochain.io");
assert_eq!(discovery.routes.health, "/health");
assert_eq!(discovery.routes.ready, "/ready");
assert_eq!(discovery.routes.avc.issue, "/api/v1/avc/issue");
assert_eq!(discovery.routes.avc.validate, "/api/v1/avc/validate");
assert_eq!(
discovery.routes.avc.receipts_emit,
"/api/v1/avc/receipts/emit"
);
assert_eq!(
discovery.routes.avc.receipts_get,
"/api/v1/avc/receipts/:hash"
);
assert_eq!(discovery.routes.avc.protocol, "/api/v1/avc/protocol");
assert_eq!(discovery.sdk.rust, "crates/exochain-sdk");
assert_eq!(discovery.sdk.typescript, "packages/exochain-sdk");
assert_eq!(discovery.sdk.python, "packages/exochain-py");
assert!(!discovery.mcp.public_transport);
assert_eq!(discovery.mcp.transports, vec!["stdio", "loopback-sse"]);
assert_eq!(
discovery.mcp.capabilities,
vec!["tools", "resources", "prompts"]
);
}
#[test]
fn test_all_routes() {
let routes = RestRoute::all();
assert_eq!(routes.len(), 30);
}
#[test]
fn rest_route_inventory_matches_live_non_graphql_gateway_surface() {
let routes = RestRoute::all();
let actual: BTreeSet<(&str, &str)> = routes
.iter()
.map(|route| (route.method(), route.path()))
.collect();
let expected = BTreeSet::from([
("GET", "/.well-known/exochain.json"),
("GET", "/health"),
("GET", "/ready"),
("GET", "/gateway/metrics"),
("GET", "/health/db"),
("GET", "/api/v1/decisions/:id"),
("POST", "/api/v1/decisions"),
("POST", "/api/v1/auth/token"),
("POST", "/api/v1/auth/register"),
("POST", "/api/v1/auth/login"),
("POST", "/api/v1/auth/refresh"),
("GET", "/api/v1/auth/me"),
("POST", "/api/v1/auth/logout"),
("POST", "/api/v1/agents/enroll"),
("GET", "/api/v1/agents"),
("GET", "/api/v1/agents/:did"),
("POST", "/api/v1/agents/:did/advance-pace"),
("GET", "/api/v1/identity/:did/score"),
("DELETE", "/api/v1/identity/:did"),
("GET", "/api/v1/tenants/:id/constitution"),
("POST", "/api/v1/ediscovery/export"),
("GET", "/api/v1/audit/:decision_id"),
("GET", "/api/v1/users"),
("POST", "/api/v1/users/:did/advance-pace"),
("GET", "/api/v1/layout-templates"),
("PUT", "/api/v1/layout-templates"),
("DELETE", "/api/v1/layout-templates/:id"),
("GET", "/api/v1/feedback-issues"),
("POST", "/api/v1/feedback-issues"),
("PATCH", "/api/v1/feedback-issues/:id"),
]);
assert_eq!(
actual, expected,
"RestRoute::all must enumerate every live non-GraphQL gateway HTTP endpoint"
);
}
}