use axum::{
extract::{Path, Query, State},
http::StatusCode,
Json,
};
use chrono::Utc;
use serde::Deserialize;
use crate::db::models::{
CatalogEntries, CatalogEntriesRequest, CatalogEntryRequest, CatalogEntryResponse,
CatalogRegisterRequest, CatalogRegisterResponse,
};
use crate::error::{AppError, AppResult};
use crate::services::ui_schema::{infer_ui_schema, UiSchemaResponse};
use crate::services::CatalogService;
pub async fn register(
service: State<CatalogService>,
request: Json<CatalogRegisterRequest>,
) -> AppResult<(StatusCode, Json<CatalogRegisterResponse>)> {
let started_at = std::time::Instant::now();
let result = register_inner(service, request).await;
let status_label = if result.is_ok() { "ok" } else { "error" };
crate::metrics::record_write_request(
crate::metrics::endpoint::CATALOG_REGISTER,
status_label,
started_at.elapsed().as_secs_f64(),
);
result
}
async fn register_inner(
State(service): State<CatalogService>,
Json(request): Json<CatalogRegisterRequest>,
) -> AppResult<(StatusCode, Json<CatalogRegisterResponse>)> {
let response = service.register(request).await?;
Ok((StatusCode::OK, Json(response)))
}
pub async fn list(
State(service): State<CatalogService>,
Json(request): Json<CatalogEntriesRequest>,
) -> AppResult<Json<CatalogEntries>> {
let entries = service.list(request.resource_type.as_deref()).await?;
Ok(Json(entries))
}
pub async fn get_resource(
State(service): State<CatalogService>,
Json(request): Json<CatalogEntryRequest>,
) -> AppResult<Json<CatalogEntryResponse>> {
let entry = service.get_resource(request).await?;
Ok(Json(entry.into()))
}
#[derive(Debug, Deserialize)]
pub struct UiSchemaQuery {
#[serde(default)]
pub version: Option<String>,
}
pub async fn ui_schema(
State(service): State<CatalogService>,
Path(tail): Path<String>,
Query(query): Query<UiSchemaQuery>,
) -> AppResult<Json<UiSchemaResponse>> {
let suffix = "/ui_schema";
let Some(catalog_path) = tail.strip_suffix(suffix) else {
return Err(AppError::NotFound(format!(
"no route matched GET /api/catalog/{tail}"
)));
};
if catalog_path.is_empty() {
return Err(AppError::Validation(
"ui_schema path must not be empty".to_string(),
));
}
let request = CatalogEntryRequest {
catalog_id: None,
path: Some(catalog_path.to_string()),
version: Some(
query
.version
.filter(|v| !v.is_empty())
.unwrap_or_else(|| "latest".to_string()),
),
};
let entry = service.get_resource(request).await?;
let metadata = parse_metadata(&entry.content);
let fields = infer_ui_schema(&entry.content);
let response = UiSchemaResponse {
path: entry.path,
version: entry.version,
kind: entry.kind.to_lowercase(),
title: metadata.name,
description_markdown: metadata.description,
exposed_in_ui: metadata.exposed_in_ui,
fields,
generated_at: Utc::now(),
};
Ok(Json(response))
}
#[derive(Debug, Default)]
struct CatalogMetadata {
name: Option<String>,
description: Option<String>,
exposed_in_ui: bool,
}
fn parse_metadata(yaml_text: &str) -> CatalogMetadata {
let parsed: serde_yaml::Value = match serde_yaml::from_str(yaml_text) {
Ok(v) => v,
Err(_) => return CatalogMetadata::default(),
};
let metadata = match parsed.get("metadata") {
Some(serde_yaml::Value::Mapping(m)) => m,
_ => return CatalogMetadata::default(),
};
CatalogMetadata {
name: metadata
.get(serde_yaml::Value::String("name".to_string()))
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
description: metadata
.get(serde_yaml::Value::String("description".to_string()))
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
exposed_in_ui: metadata
.get(serde_yaml::Value::String("exposed_in_ui".to_string()))
.and_then(|v| v.as_bool())
.unwrap_or(false),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_metadata_name_description_flag() {
let yaml = "\
apiVersion: v1
kind: Playbook
metadata:
name: test-playbook
description: A test
exposed_in_ui: true
workload:
foo: bar
";
let meta = parse_metadata(yaml);
assert_eq!(meta.name.as_deref(), Some("test-playbook"));
assert_eq!(meta.description.as_deref(), Some("A test"));
assert!(meta.exposed_in_ui);
}
#[test]
fn parse_metadata_missing_block_returns_default() {
let yaml = "workload:\n foo: bar\n";
let meta = parse_metadata(yaml);
assert!(meta.name.is_none());
assert!(meta.description.is_none());
assert!(!meta.exposed_in_ui);
}
#[test]
fn parse_metadata_malformed_yaml_returns_default() {
let yaml = "metadata:\n name: 'unterminated\n";
let meta = parse_metadata(yaml);
assert!(meta.name.is_none());
}
}