use argentor_skills::{MarketplaceEntry, MarketplaceManager, MarketplaceSearch, SortBy};
use axum::{
extract::{Json, Path, Query, State},
http::StatusCode,
response::IntoResponse,
routing::{delete, get, post},
Router,
};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::sync::RwLock;
use tracing::{info, warn};
pub struct MarketplaceApiState {
pub manager: RwLock<MarketplaceManager>,
}
#[derive(Debug, Deserialize)]
pub struct SearchQuery {
pub q: Option<String>,
pub category: Option<String>,
pub sort: Option<String>,
pub limit: Option<usize>,
pub offset: Option<usize>,
}
#[derive(Debug, Deserialize)]
pub struct PopularQuery {
pub limit: Option<usize>,
}
#[derive(Debug, Deserialize)]
pub struct RecentQuery {
pub limit: Option<usize>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct MarketplaceEntryResponse {
pub name: String,
pub version: String,
pub description: String,
pub author: String,
pub license: Option<String>,
pub downloads: u64,
pub rating: f32,
pub ratings_count: u32,
pub categories: Vec<String>,
pub featured: bool,
pub published_at: String,
pub updated_at: String,
pub size_bytes: u64,
pub download_url: Option<String>,
pub homepage: Option<String>,
pub keywords: Vec<String>,
pub tags: Vec<String>,
}
impl From<&MarketplaceEntry> for MarketplaceEntryResponse {
fn from(e: &MarketplaceEntry) -> Self {
Self {
name: e.manifest.name.clone(),
version: e.manifest.version.clone(),
description: e.manifest.description.clone(),
author: e.manifest.author.clone(),
license: e.manifest.license.clone(),
downloads: e.downloads,
rating: e.rating,
ratings_count: e.ratings_count,
categories: e.categories.clone(),
featured: e.featured,
published_at: e.published_at.clone(),
updated_at: e.updated_at.clone(),
size_bytes: e.size_bytes,
download_url: e.download_url.clone(),
homepage: e.homepage.clone(),
keywords: e.keywords.clone(),
tags: e.manifest.tags.clone(),
}
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct InstalledSkillResponse {
pub name: String,
pub version: String,
pub installed_at: String,
pub vetted: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct InstallResponse {
pub success: bool,
pub name: String,
pub message: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct UninstallResponse {
pub success: bool,
pub name: String,
pub message: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CatalogStats {
pub total_skills: usize,
pub total_categories: usize,
pub total_installed: usize,
pub featured_count: usize,
pub categories: Vec<String>,
}
#[derive(Debug)]
pub enum MarketplaceApiError {
NotFound(String),
BadRequest(String),
Internal(String),
}
impl std::fmt::Display for MarketplaceApiError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NotFound(msg) => write!(f, "Not found: {msg}"),
Self::BadRequest(msg) => write!(f, "Bad request: {msg}"),
Self::Internal(msg) => write!(f, "Internal error: {msg}"),
}
}
}
impl IntoResponse for MarketplaceApiError {
fn into_response(self) -> axum::response::Response {
let (status, message) = match &self {
Self::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()),
Self::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
Self::Internal(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg.clone()),
};
let body = serde_json::json!({ "error": message });
(status, Json(body)).into_response()
}
}
pub fn marketplace_router(state: Arc<MarketplaceApiState>) -> Router {
Router::new()
.route("/api/v1/marketplace/search", get(search_catalog))
.route("/api/v1/marketplace/featured", get(list_featured))
.route("/api/v1/marketplace/categories", get(list_categories))
.route("/api/v1/marketplace/skills/{name}", get(get_skill_details))
.route("/api/v1/marketplace/popular", get(list_popular))
.route("/api/v1/marketplace/recent", get(list_recent))
.route("/api/v1/marketplace/install/{name}", post(install_skill))
.route(
"/api/v1/marketplace/install/{name}",
delete(uninstall_skill),
)
.route("/api/v1/marketplace/installed", get(list_installed))
.route("/api/v1/marketplace/stats", get(get_stats))
.with_state(state)
}
fn parse_sort(s: &str) -> SortBy {
match s.to_lowercase().as_str() {
"downloads" => SortBy::Downloads,
"rating" => SortBy::Rating,
"recent" => SortBy::Recent,
"name" => SortBy::Name,
_ => SortBy::Relevance,
}
}
async fn search_catalog(
State(state): State<Arc<MarketplaceApiState>>,
Query(params): Query<SearchQuery>,
) -> Result<Json<Vec<MarketplaceEntryResponse>>, MarketplaceApiError> {
let mgr = state.manager.read().await;
let search = MarketplaceSearch {
query: params.q,
category: params.category,
author: None,
min_rating: None,
tags: Vec::new(),
sort_by: params
.sort
.as_deref()
.map(parse_sort)
.unwrap_or(SortBy::Relevance),
limit: params.limit.unwrap_or(50),
offset: params.offset.unwrap_or(0),
};
let results = mgr.catalog().search(&search);
let response: Vec<MarketplaceEntryResponse> = results
.iter()
.map(|e| MarketplaceEntryResponse::from(*e))
.collect();
Ok(Json(response))
}
async fn list_featured(
State(state): State<Arc<MarketplaceApiState>>,
) -> Result<Json<Vec<MarketplaceEntryResponse>>, MarketplaceApiError> {
let mgr = state.manager.read().await;
let featured = mgr.catalog().list_featured();
let response: Vec<MarketplaceEntryResponse> = featured
.iter()
.map(|e| MarketplaceEntryResponse::from(*e))
.collect();
Ok(Json(response))
}
async fn list_categories(
State(state): State<Arc<MarketplaceApiState>>,
) -> Result<Json<Vec<String>>, MarketplaceApiError> {
let mgr = state.manager.read().await;
Ok(Json(mgr.catalog().categories()))
}
async fn get_skill_details(
State(state): State<Arc<MarketplaceApiState>>,
Path(name): Path<String>,
) -> Result<Json<MarketplaceEntryResponse>, MarketplaceApiError> {
let mgr = state.manager.read().await;
let entry = mgr.catalog().get(&name).ok_or_else(|| {
MarketplaceApiError::NotFound(format!("Skill '{name}' not found in catalog"))
})?;
Ok(Json(MarketplaceEntryResponse::from(entry)))
}
async fn list_popular(
State(state): State<Arc<MarketplaceApiState>>,
Query(params): Query<PopularQuery>,
) -> Result<Json<Vec<MarketplaceEntryResponse>>, MarketplaceApiError> {
let mgr = state.manager.read().await;
let limit = params.limit.unwrap_or(10);
let popular = mgr.catalog().list_popular(limit);
let response: Vec<MarketplaceEntryResponse> = popular
.iter()
.map(|e| MarketplaceEntryResponse::from(*e))
.collect();
Ok(Json(response))
}
async fn list_recent(
State(state): State<Arc<MarketplaceApiState>>,
Query(params): Query<RecentQuery>,
) -> Result<Json<Vec<MarketplaceEntryResponse>>, MarketplaceApiError> {
let mgr = state.manager.read().await;
let limit = params.limit.unwrap_or(10);
let recent = mgr.catalog().list_recent(limit);
let response: Vec<MarketplaceEntryResponse> = recent
.iter()
.map(|e| MarketplaceEntryResponse::from(*e))
.collect();
Ok(Json(response))
}
async fn install_skill(
State(state): State<Arc<MarketplaceApiState>>,
Path(name): Path<String>,
) -> Result<Json<InstallResponse>, MarketplaceApiError> {
let mut mgr = state.manager.write().await;
let entry = mgr.catalog().get(&name).ok_or_else(|| {
MarketplaceApiError::NotFound(format!("Skill '{name}' not found in catalog"))
})?;
if mgr.is_installed(&name) {
return Ok(Json(InstallResponse {
success: false,
name: name.clone(),
message: format!("Skill '{name}' is already installed"),
}));
}
let manifest = entry.manifest.clone();
let placeholder_wasm = b"(module)";
match mgr.install_from_bytes(manifest, placeholder_wasm) {
Ok(result) => {
info!(skill = %name, "Skill installed via marketplace API");
Ok(Json(InstallResponse {
success: result.passed,
name: name.clone(),
message: if result.passed {
format!("Skill '{name}' installed successfully")
} else {
format!(
"Skill '{name}' installation failed vetting: {}",
result
.checks
.iter()
.filter(|c| !c.passed)
.map(|c| c.message.clone())
.collect::<Vec<_>>()
.join(", ")
)
},
}))
}
Err(e) => {
warn!(skill = %name, error = %e, "Skill installation failed");
Err(MarketplaceApiError::Internal(format!(
"Failed to install skill '{name}': {e}"
)))
}
}
}
async fn uninstall_skill(
State(state): State<Arc<MarketplaceApiState>>,
Path(name): Path<String>,
) -> Result<Json<UninstallResponse>, MarketplaceApiError> {
let mut mgr = state.manager.write().await;
if !mgr.is_installed(&name) {
return Err(MarketplaceApiError::NotFound(format!(
"Skill '{name}' is not installed"
)));
}
match mgr.uninstall(&name) {
Ok(removed) => {
info!(skill = %name, removed, "Skill uninstalled via marketplace API");
Ok(Json(UninstallResponse {
success: removed,
name: name.clone(),
message: if removed {
format!("Skill '{name}' uninstalled successfully")
} else {
format!("Skill '{name}' was not found in the index")
},
}))
}
Err(e) => {
warn!(skill = %name, error = %e, "Skill uninstall failed");
Err(MarketplaceApiError::Internal(format!(
"Failed to uninstall skill '{name}': {e}"
)))
}
}
}
async fn list_installed(
State(state): State<Arc<MarketplaceApiState>>,
) -> Result<Json<Vec<InstalledSkillResponse>>, MarketplaceApiError> {
let mgr = state.manager.read().await;
let installed: Vec<InstalledSkillResponse> = mgr
.installed()
.list()
.iter()
.map(|entry| InstalledSkillResponse {
name: entry.manifest.name.clone(),
version: entry.manifest.version.clone(),
installed_at: entry.installed_at.clone(),
vetted: entry.vetted,
})
.collect();
Ok(Json(installed))
}
async fn get_stats(
State(state): State<Arc<MarketplaceApiState>>,
) -> Result<Json<CatalogStats>, MarketplaceApiError> {
let mgr = state.manager.read().await;
let catalog = mgr.catalog();
let categories = catalog.categories();
let featured_count = catalog.list_featured().len();
let total = catalog.count();
let installed = mgr.installed().list().len();
Ok(Json(CatalogStats {
total_skills: total,
total_categories: categories.len(),
total_installed: installed,
featured_count,
categories,
}))
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use argentor_skills::{builtin_catalog_entries, MarketplaceManager};
use axum::body::Body;
use axum::http::Request;
use tower::ServiceExt;
fn test_state() -> Arc<MarketplaceApiState> {
let dir = std::env::temp_dir().join("argentor_marketplace_api_test");
let _ = std::fs::create_dir_all(&dir);
let catalog_path = dir.join("catalog.json");
let mut mgr = MarketplaceManager::new(dir, catalog_path);
let entries = builtin_catalog_entries();
mgr.update_catalog(entries);
Arc::new(MarketplaceApiState {
manager: RwLock::new(mgr),
})
}
fn app(state: Arc<MarketplaceApiState>) -> Router {
marketplace_router(state)
}
#[tokio::test]
async fn test_search_returns_results() {
let state = test_state();
let router = app(state);
let resp = router
.oneshot(
Request::builder()
.uri("/api/v1/marketplace/search?q=calculator")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let results: Vec<MarketplaceEntryResponse> = serde_json::from_slice(&body).unwrap();
assert!(!results.is_empty());
assert!(results.iter().any(|r| r.name.contains("calculator")));
}
#[tokio::test]
async fn test_search_no_query_returns_all() {
let state = test_state();
let router = app(state);
let resp = router
.oneshot(
Request::builder()
.uri("/api/v1/marketplace/search")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let results: Vec<MarketplaceEntryResponse> = serde_json::from_slice(&body).unwrap();
assert!(results.len() > 1);
}
#[tokio::test]
async fn test_category_listing() {
let state = test_state();
let router = app(state);
let resp = router
.oneshot(
Request::builder()
.uri("/api/v1/marketplace/categories")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let categories: Vec<String> = serde_json::from_slice(&body).unwrap();
assert!(!categories.is_empty());
}
#[tokio::test]
async fn test_featured_listing() {
let state = test_state();
let router = app(state);
let resp = router
.oneshot(
Request::builder()
.uri("/api/v1/marketplace/featured")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let featured: Vec<MarketplaceEntryResponse> = serde_json::from_slice(&body).unwrap();
for entry in &featured {
assert!(entry.featured);
}
}
#[tokio::test]
async fn test_skill_details_found() {
let state = test_state();
let router = app(state);
let resp = router
.oneshot(
Request::builder()
.uri("/api/v1/marketplace/skills/calculator")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let detail: MarketplaceEntryResponse = serde_json::from_slice(&body).unwrap();
assert_eq!(detail.name, "calculator");
}
#[tokio::test]
async fn test_skill_details_not_found() {
let state = test_state();
let router = app(state);
let resp = router
.oneshot(
Request::builder()
.uri("/api/v1/marketplace/skills/nonexistent_skill_xyz")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_popular_listing() {
let state = test_state();
let router = app(state);
let resp = router
.oneshot(
Request::builder()
.uri("/api/v1/marketplace/popular?limit=5")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let popular: Vec<MarketplaceEntryResponse> = serde_json::from_slice(&body).unwrap();
assert!(popular.len() <= 5);
for w in popular.windows(2) {
assert!(w[0].downloads >= w[1].downloads);
}
}
#[tokio::test]
async fn test_recent_listing() {
let state = test_state();
let router = app(state);
let resp = router
.oneshot(
Request::builder()
.uri("/api/v1/marketplace/recent?limit=5")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let recent: Vec<MarketplaceEntryResponse> = serde_json::from_slice(&body).unwrap();
assert!(recent.len() <= 5);
}
#[tokio::test]
async fn test_installed_listing_empty() {
let state = test_state();
let router = app(state);
let resp = router
.oneshot(
Request::builder()
.uri("/api/v1/marketplace/installed")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let installed: Vec<InstalledSkillResponse> = serde_json::from_slice(&body).unwrap();
assert!(installed.is_empty());
}
#[tokio::test]
async fn test_stats_endpoint() {
let state = test_state();
let router = app(state);
let resp = router
.oneshot(
Request::builder()
.uri("/api/v1/marketplace/stats")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let stats: CatalogStats = serde_json::from_slice(&body).unwrap();
assert!(stats.total_skills > 0);
assert!(stats.total_categories > 0);
assert_eq!(stats.total_installed, 0);
}
#[tokio::test]
async fn test_uninstall_nonexistent() {
let state = test_state();
let router = app(state);
let resp = router
.oneshot(
Request::builder()
.method("DELETE")
.uri("/api/v1/marketplace/install/nonexistent_skill")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_install_nonexistent_skill() {
let state = test_state();
let router = app(state);
let resp = router
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/marketplace/install/nonexistent_skill_xyz")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_search_with_category_filter() {
let state = test_state();
let router = app(state);
let resp = router
.oneshot(
Request::builder()
.uri("/api/v1/marketplace/search?category=data")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let results: Vec<MarketplaceEntryResponse> = serde_json::from_slice(&body).unwrap();
for r in &results {
assert!(
r.categories.iter().any(|c| c.to_lowercase() == "data"),
"Entry '{}' does not have 'data' category: {:?}",
r.name,
r.categories
);
}
}
}