use axum::response::IntoResponse;
use ironflow_auth::extractor::Authenticated;
use serde::Serialize;
use tokio::task::spawn_blocking;
use tracing::warn;
use ironflow_engine::VERSION as ENGINE_VERSION;
use ironflow_templates::error::TemplateError;
use ironflow_templates::registry::{RegistryEntry, fetch_registry_index, resolve_registry_url};
use crate::error::ApiError;
use crate::response::ok;
#[derive(Debug, Serialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct TemplateListEntry {
pub name: String,
pub description: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
pub repo: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub min_ironflow_version: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub authors: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub latest_version: Option<String>,
}
#[derive(Debug, Serialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct TemplateRegistryResponse {
pub templates: Vec<TemplateListEntry>,
pub ironflow_version: String,
}
impl From<RegistryEntry> for TemplateListEntry {
fn from(entry: RegistryEntry) -> Self {
Self {
name: entry.name,
description: entry.description,
category: entry.category,
repo: entry.repo,
min_ironflow_version: entry.min_ironflow_version.map(|v| v.to_string()),
authors: entry.authors,
latest_version: entry.latest_version.map(|v| v.to_string()),
}
}
}
#[cfg_attr(
feature = "openapi",
utoipa::path(
get,
path = "/api/v1/templates/registry",
tags = ["templates"],
responses(
(status = 200, description = "Template catalogue", body = TemplateRegistryResponse),
(status = 404, description = "No registry at the configured URL (`REGISTRY_NOT_FOUND`)"),
(status = 502, description = "Registry host unreachable (`REGISTRY_UNREACHABLE`)")
)
)
)]
pub async fn list_registry_templates(_auth: Authenticated) -> Result<impl IntoResponse, ApiError> {
let response = fetch_registry_listing(resolve_registry_url(None)).await?;
Ok(ok(response))
}
async fn fetch_registry_listing(
registry_url: String,
) -> Result<TemplateRegistryResponse, ApiError> {
let index = spawn_blocking(move || fetch_registry_index(®istry_url))
.await
.map_err(|e| ApiError::Internal(format!("task join error: {e}")))?
.map_err(|e| {
warn!(error = %e, "template registry fetch failed");
match e {
TemplateError::RegistryNotFound { .. } => ApiError::RegistryNotFound,
TemplateError::RegistryUnreachable { .. } => ApiError::RegistryUnreachable,
other => ApiError::BadGateway(format!("registry fetch failed: {other}")),
}
})?;
let templates: Vec<TemplateListEntry> = index.templates.into_iter().map(Into::into).collect();
Ok(TemplateRegistryResponse {
templates,
ironflow_version: ENGINE_VERSION.to_string(),
})
}
#[cfg(test)]
mod tests {
use axum::body::to_bytes;
use axum::http::StatusCode;
use axum::response::Response;
use semver::Version;
use super::*;
#[test]
fn template_list_entry_from_registry_entry() {
let entry = RegistryEntry {
name: "hello".to_string(),
description: "Hello world".to_string(),
category: Some("getting-started".to_string()),
repo: "https://example.com/templates".to_string(),
min_ironflow_version: Some(Version::new(0, 5, 0)),
authors: vec!["Alice".to_string()],
latest_version: Some(Version::new(1, 2, 0)),
};
let api_entry = TemplateListEntry::from(entry);
assert_eq!(api_entry.name, "hello");
assert_eq!(api_entry.category, Some("getting-started".to_string()));
assert_eq!(api_entry.min_ironflow_version, Some("0.5.0".to_string()));
assert_eq!(api_entry.authors, vec!["Alice"]);
assert_eq!(api_entry.latest_version, Some("1.2.0".to_string()));
}
type TestResult = Result<(), Box<dyn std::error::Error>>;
async fn listing_error(url: String) -> Result<ApiError, String> {
match fetch_registry_listing(url).await {
Ok(_) => Err("registry listing unexpectedly succeeded".to_string()),
Err(err) => Ok(err),
}
}
async fn error_body(
response: Response,
) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
let body = to_bytes(response.into_body(), usize::MAX).await?;
Ok(serde_json::from_slice(&body)?)
}
#[tokio::test]
async fn missing_registry_answers_404_registry_not_found() -> TestResult {
let tmp = tempfile::TempDir::new()?;
let url = format!("file://{}", tmp.path().join("absent").display());
let response = listing_error(url).await?.into_response();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
assert_eq!(
error_body(response).await?["error"]["code"],
"REGISTRY_NOT_FOUND"
);
Ok(())
}
#[tokio::test]
async fn unreachable_registry_answers_502_registry_unreachable() -> TestResult {
let url = "http://127.0.0.1:1/ironflow/registry".to_string();
let response = listing_error(url).await?.into_response();
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
assert_eq!(
error_body(response).await?["error"]["code"],
"REGISTRY_UNREACHABLE"
);
Ok(())
}
#[tokio::test]
async fn registry_error_body_does_not_echo_the_url() -> TestResult {
let url = "http://user:s3cret@127.0.0.1:1/registry".to_string();
let response = listing_error(url).await?.into_response();
let body = error_body(response).await?.to_string();
assert!(!body.contains("s3cret"), "leaked: {body}");
Ok(())
}
#[test]
fn template_list_entry_serializes_without_optional_fields() {
let entry = TemplateListEntry {
name: "hello".to_string(),
description: "Hello".to_string(),
category: None,
repo: "https://example.com".to_string(),
min_ironflow_version: None,
authors: vec![],
latest_version: None,
};
let json = serde_json::to_value(&entry).expect("test serialization");
assert!(json.get("category").is_none());
assert!(json.get("min_ironflow_version").is_none());
assert!(json.get("authors").is_none());
assert!(json.get("latest_version").is_none());
}
}