use axum::Router;
use axum::extract::{Path as AxumPath, State};
use axum::http::{StatusCode, header};
use axum::response::IntoResponse;
use axum::routing::get;
use std::path::{Path, PathBuf};
use systemprompt_config::ProfileBootstrap;
use systemprompt_identifiers::{MarketplaceId, PluginId};
use systemprompt_loader::ConfigLoader;
use systemprompt_marketplace::{
MarketplaceService, render_marketplace_json, render_marketplace_list,
};
use systemprompt_models::api::ApiError;
use systemprompt_runtime::AppContext;
use crate::error::ApiHttpError;
pub fn router() -> Router<AppContext> {
Router::new()
.route("/marketplace.json", get(serve_default_marketplace_json))
.route("/marketplaces", get(list_marketplaces))
.route("/marketplaces/{id}", get(get_marketplace))
.route(
"/marketplaces/{id}/manifest.yaml",
get(get_marketplace_yaml),
)
.route("/plugins/{plugin_id}/{*path}", get(serve_plugin_file))
}
fn marketplaces_path(ctx: &AppContext) -> PathBuf {
ctx.app_paths().system().services().join("marketplaces")
}
#[derive(Debug, thiserror::Error)]
enum MarketplaceRouteError {
#[error("failed to render marketplace JSON")]
Render(#[source] serde_json::Error),
#[error("failed to read marketplace config")]
ReadConfig(#[source] std::io::Error),
}
impl From<MarketplaceRouteError> for ApiHttpError {
fn from(err: MarketplaceRouteError) -> Self {
let context = match &err {
MarketplaceRouteError::Render(_) => "Failed to render marketplace",
MarketplaceRouteError::ReadConfig(_) => "Failed to read marketplace config",
};
Self::from(ApiError::internal(context, err))
}
}
async fn serve_default_marketplace_json(
State(_ctx): State<AppContext>,
) -> Result<impl IntoResponse, ApiHttpError> {
let services = ConfigLoader::load()?;
let service = MarketplaceService::new(&services);
let (id, marketplace) = service.resolve_default()?;
let body = serde_json::to_vec_pretty(&render_marketplace_json(id, marketplace))
.map_err(MarketplaceRouteError::Render)?;
Ok((
StatusCode::OK,
[
(header::CONTENT_TYPE, "application/json"),
(header::CACHE_CONTROL, "public, max-age=300"),
],
body,
))
}
async fn list_marketplaces(
State(_ctx): State<AppContext>,
) -> Result<impl IntoResponse, ApiHttpError> {
let services = ConfigLoader::load()?;
let service = MarketplaceService::new(&services);
let body = serde_json::to_vec_pretty(&render_marketplace_list(service.list()))
.map_err(MarketplaceRouteError::Render)?;
Ok((
StatusCode::OK,
[
(header::CONTENT_TYPE, "application/json"),
(header::CACHE_CONTROL, "public, max-age=300"),
],
body,
))
}
async fn get_marketplace(
State(_ctx): State<AppContext>,
AxumPath(id): AxumPath<String>,
) -> Result<impl IntoResponse, ApiHttpError> {
let id = MarketplaceId::try_new(id).map_err(ApiError::from)?;
let services = ConfigLoader::load()?;
let service = MarketplaceService::new(&services);
let marketplace = service.get(&id)?;
let body = serde_json::to_vec_pretty(&render_marketplace_json(&id, marketplace))
.map_err(MarketplaceRouteError::Render)?;
Ok((
StatusCode::OK,
[
(header::CONTENT_TYPE, "application/json"),
(header::CACHE_CONTROL, "public, max-age=300"),
],
body,
))
}
async fn get_marketplace_yaml(
State(ctx): State<AppContext>,
AxumPath(id): AxumPath<String>,
) -> Result<impl IntoResponse, ApiHttpError> {
let id = MarketplaceId::try_new(id).map_err(ApiError::from)?;
let marketplaces_root = marketplaces_path(&ctx);
let requested = marketplaces_root.join(id.as_str()).join("config.yaml");
let canonical_root = marketplaces_root
.canonicalize()
.map_err(|_e| ApiHttpError::not_found(format!("Marketplace '{id}' not found")))?;
let canonical_requested = requested
.canonicalize()
.map_err(|_e| ApiHttpError::not_found(format!("Marketplace '{id}' has no config.yaml")))?;
if !canonical_requested.starts_with(&canonical_root) {
return Err(ApiHttpError::forbidden("Invalid marketplace id"));
}
if !canonical_requested.is_file() {
return Err(ApiHttpError::not_found(format!(
"Marketplace '{id}' has no config.yaml"
)));
}
let content = tokio::fs::read(&canonical_requested)
.await
.map_err(MarketplaceRouteError::ReadConfig)?;
Ok((
StatusCode::OK,
[
(header::CONTENT_TYPE, "application/yaml"),
(header::CACHE_CONTROL, "public, max-age=300"),
],
content,
))
}
async fn serve_plugin_file(
State(ctx): State<AppContext>,
AxumPath((plugin_id, file_path)): AxumPath<(String, String)>,
) -> Result<impl IntoResponse, ApiHttpError> {
let services = ConfigLoader::load()?;
let profile = ProfileBootstrap::get()?;
let api_external_url = &profile.server.api_external_url;
let services_root = ctx.app_paths().system().services();
let disk_catalog =
ctx.marketplace_cache()
.catalog(&services, services_root, api_external_url)?;
let catalog = (*disk_catalog)
.clone()
.without_organization_skills(
ctx.managed_repository().as_ref().clone(),
ctx.system_admin().id(),
)
.await?;
let bundles = ctx
.marketplace_cache()
.bundles(&services, &catalog.as_content())?;
let id = PluginId::try_new(&plugin_id)
.map_err(|_e| ApiHttpError::not_found(format!("Plugin '{plugin_id}' not found")))?;
let Some(bundle) = bundles.get(&id) else {
return Err(ApiHttpError::not_found(format!(
"Plugin '{plugin_id}' not found"
)));
};
let Some(file) = bundle.get(&file_path) else {
return Err(ApiHttpError::not_found(format!(
"File not found: {file_path}"
)));
};
Ok((
StatusCode::OK,
[
(
header::CONTENT_TYPE,
systemprompt_models::mime::http_content_type(Path::new(&file_path)),
),
(header::CACHE_CONTROL, "public, max-age=300"),
],
file.bytes.clone(),
))
}