use super::models::*;
use crate::db::models::User;
use crate::db::{extensions as db_extensions, projects};
use crate::server::state::AppState;
use axum::{
extract::{Extension as AxumExtension, Path, State},
http::StatusCode,
Json,
};
pub async fn list_extension_types(
State(state): State<AppState>,
AxumExtension(_user): AxumExtension<User>,
) -> Result<Json<ListExtensionTypesResponse>, (StatusCode, String)> {
let extension_types: Vec<ExtensionTypeMetadata> = state
.extension_registry
.iter()
.map(|(_registry_key, extension)| ExtensionTypeMetadata {
extension_type: extension.extension_type().to_string(),
display_name: extension.display_name().to_string(),
description: extension.description().to_string(),
documentation: extension.documentation().to_string(),
spec_schema: extension.spec_schema(),
})
.collect();
Ok(Json(ListExtensionTypesResponse { extension_types }))
}
pub async fn create_extension(
State(state): State<AppState>,
AxumExtension(user): AxumExtension<User>,
Path((project_name, extension_name)): Path<(String, String)>,
Json(payload): Json<CreateExtensionRequest>,
) -> Result<Json<CreateExtensionResponse>, (StatusCode, String)> {
let project = projects::find_by_name(&state.db_pool, &project_name)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "Project not found".to_string()))?;
let has_access = check_project_access(&state, &user, project.id).await?;
if !has_access {
return Err((StatusCode::FORBIDDEN, "Access denied".to_string()));
}
let extension = state
.extension_registry
.get(&payload.extension_type)
.ok_or((
StatusCode::BAD_REQUEST,
format!("Unknown extension type: {}", payload.extension_type),
))?;
extension
.validate_spec(&payload.spec)
.await
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid spec: {}", e)))?;
let _ext_record = db_extensions::create(
&state.db_pool,
project.id,
&extension_name,
&payload.extension_type,
&payload.spec,
)
.await
.map_err(|e| {
let error_msg = e.to_string();
if error_msg.contains("duplicate key") || error_msg.contains("unique constraint") {
(
StatusCode::CONFLICT,
format!("Extension '{}' already exists", extension_name),
)
} else {
(StatusCode::INTERNAL_SERVER_ERROR, error_msg)
}
})?;
extension
.on_spec_updated(
&serde_json::json!({}),
&payload.spec,
project.id,
&extension_name,
&state.db_pool,
)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let ext_record =
db_extensions::find_by_project_and_name(&state.db_pool, project.id, &extension_name)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "Extension not found".to_string()))?;
let status_summary = extension.format_status(&ext_record.status);
Ok(Json(CreateExtensionResponse {
extension: Extension {
extension: ext_record.extension,
extension_type: extension.extension_type().to_string(),
spec: ext_record.spec,
status: ext_record.status,
status_summary,
created: ext_record.created_at.to_rfc3339(),
updated: ext_record.updated_at.to_rfc3339(),
},
}))
}
pub async fn update_extension(
State(state): State<AppState>,
AxumExtension(user): AxumExtension<User>,
Path((project_name, extension_name)): Path<(String, String)>,
Json(payload): Json<UpdateExtensionRequest>,
) -> Result<Json<UpdateExtensionResponse>, (StatusCode, String)> {
let project = projects::find_by_name(&state.db_pool, &project_name)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "Project not found".to_string()))?;
let has_access = check_project_access(&state, &user, project.id).await?;
if !has_access {
return Err((StatusCode::FORBIDDEN, "Access denied".to_string()));
}
let existing =
db_extensions::find_by_project_and_name(&state.db_pool, project.id, &extension_name)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "Extension not found".to_string()))?;
let extension = state
.extension_registry
.get(&existing.extension_type)
.ok_or((
StatusCode::BAD_REQUEST,
format!("Unknown extension type: {}", existing.extension_type),
))?;
extension
.validate_spec(&payload.spec)
.await
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid spec: {}", e)))?;
let _ext_record = db_extensions::upsert(
&state.db_pool,
project.id,
&extension_name,
&existing.extension_type,
&payload.spec,
)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
extension
.on_spec_updated(
&existing.spec,
&payload.spec,
project.id,
&extension_name,
&state.db_pool,
)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let ext_record =
db_extensions::find_by_project_and_name(&state.db_pool, project.id, &extension_name)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "Extension not found".to_string()))?;
let status_summary = extension.format_status(&ext_record.status);
Ok(Json(UpdateExtensionResponse {
extension: Extension {
extension: ext_record.extension,
extension_type: extension.extension_type().to_string(),
spec: ext_record.spec,
status: ext_record.status,
status_summary,
created: ext_record.created_at.to_rfc3339(),
updated: ext_record.updated_at.to_rfc3339(),
},
}))
}
pub async fn patch_extension(
State(state): State<AppState>,
AxumExtension(user): AxumExtension<User>,
Path((project_name, extension_name)): Path<(String, String)>,
Json(payload): Json<UpdateExtensionRequest>,
) -> Result<Json<UpdateExtensionResponse>, (StatusCode, String)> {
let project = projects::find_by_name(&state.db_pool, &project_name)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "Project not found".to_string()))?;
let has_access = check_project_access(&state, &user, project.id).await?;
if !has_access {
return Err((StatusCode::FORBIDDEN, "Access denied".to_string()));
}
let existing =
db_extensions::find_by_project_and_name(&state.db_pool, project.id, &extension_name)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "Extension not found".to_string()))?;
let merged_spec = merge_json_with_nulls(&existing.spec, &payload.spec);
let extension = state
.extension_registry
.get(&existing.extension_type)
.ok_or((
StatusCode::BAD_REQUEST,
format!("Unknown extension type: {}", existing.extension_type),
))?;
extension.validate_spec(&merged_spec).await.map_err(|e| {
(
StatusCode::BAD_REQUEST,
format!("Invalid spec after merge: {}", e),
)
})?;
let _ext_record = db_extensions::upsert(
&state.db_pool,
project.id,
&extension_name,
&existing.extension_type,
&merged_spec,
)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
extension
.on_spec_updated(
&existing.spec,
&merged_spec,
project.id,
&extension_name,
&state.db_pool,
)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let ext_record =
db_extensions::find_by_project_and_name(&state.db_pool, project.id, &extension_name)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "Extension not found".to_string()))?;
let status_summary = extension.format_status(&ext_record.status);
Ok(Json(UpdateExtensionResponse {
extension: Extension {
extension: ext_record.extension,
extension_type: extension.extension_type().to_string(),
spec: ext_record.spec,
status: ext_record.status,
status_summary,
created: ext_record.created_at.to_rfc3339(),
updated: ext_record.updated_at.to_rfc3339(),
},
}))
}
pub async fn list_extensions(
State(state): State<AppState>,
AxumExtension(user): AxumExtension<User>,
Path(project_name): Path<String>,
) -> Result<Json<ListExtensionsResponse>, (StatusCode, String)> {
let project = projects::find_by_name(&state.db_pool, &project_name)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "Project not found".to_string()))?;
let has_access = check_project_access(&state, &user, project.id).await?;
if !has_access {
return Err((StatusCode::FORBIDDEN, "Access denied".to_string()));
}
let extensions = db_extensions::list_by_project(&state.db_pool, project.id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let extensions: Vec<Extension> = extensions
.into_iter()
.map(|e| {
let status_summary = state
.extension_registry
.get(&e.extension_type)
.map(|ext| ext.format_status(&e.status))
.unwrap_or_else(|| "Unknown".to_string());
Extension {
extension: e.extension,
extension_type: e.extension_type,
spec: e.spec,
status: e.status,
status_summary,
created: e.created_at.to_rfc3339(),
updated: e.updated_at.to_rfc3339(),
}
})
.collect();
Ok(Json(ListExtensionsResponse { extensions }))
}
pub async fn get_extension(
State(state): State<AppState>,
AxumExtension(user): AxumExtension<User>,
Path((project_name, extension_name)): Path<(String, String)>,
) -> Result<Json<Extension>, (StatusCode, String)> {
let project = projects::find_by_name(&state.db_pool, &project_name)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "Project not found".to_string()))?;
let has_access = check_project_access(&state, &user, project.id).await?;
if !has_access {
return Err((StatusCode::FORBIDDEN, "Access denied".to_string()));
}
let ext = db_extensions::find_by_project_and_name(&state.db_pool, project.id, &extension_name)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "Extension not found".to_string()))?;
let status_summary = state
.extension_registry
.get(&ext.extension_type)
.map(|ext_provider| ext_provider.format_status(&ext.status))
.unwrap_or_else(|| "Unknown".to_string());
Ok(Json(Extension {
extension: ext.extension,
extension_type: ext.extension_type,
spec: ext.spec,
status: ext.status,
status_summary,
created: ext.created_at.to_rfc3339(),
updated: ext.updated_at.to_rfc3339(),
}))
}
pub async fn delete_extension(
State(state): State<AppState>,
AxumExtension(user): AxumExtension<User>,
Path((project_name, extension_name)): Path<(String, String)>,
) -> Result<StatusCode, (StatusCode, String)> {
let project = projects::find_by_name(&state.db_pool, &project_name)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "Project not found".to_string()))?;
let has_access = check_project_access(&state, &user, project.id).await?;
if !has_access {
return Err((StatusCode::FORBIDDEN, "Access denied".to_string()));
}
db_extensions::mark_deleted(&state.db_pool, project.id, &extension_name)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(StatusCode::NO_CONTENT)
}
async fn check_project_access(
state: &AppState,
user: &User,
project_id: uuid::Uuid,
) -> Result<bool, (StatusCode, String)> {
if state.is_admin(&user.email) {
return Ok(true);
}
let accessible_projects = projects::list_accessible_by_user(&state.db_pool, user.id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(accessible_projects.iter().any(|p| p.id == project_id))
}
fn merge_json_with_nulls(
existing: &serde_json::Value,
update: &serde_json::Value,
) -> serde_json::Value {
use serde_json::Value;
match (existing, update) {
(Value::Object(existing_map), Value::Object(update_map)) => {
let mut result = existing_map.clone();
for (key, value) in update_map.iter() {
if value.is_null() {
result.remove(key);
} else if let Some(existing_value) = existing_map.get(key) {
result.insert(key.clone(), merge_json_with_nulls(existing_value, value));
} else {
result.insert(key.clone(), value.clone());
}
}
Value::Object(result)
}
_ => {
update.clone()
}
}
}