use axum::{
Json,
extract::{Path, State},
http::HeaderMap,
};
use fusillade::{ResponseStepStore, Storage};
use onwards::StoreError;
use serde::{Deserialize, Serialize};
use sqlx_pool_router::PoolProvider;
use std::collections::HashSet;
use utoipa::ToSchema;
use crate::AppState;
use crate::errors::{Error, Result};
#[derive(Debug, Serialize, Deserialize, ToSchema)]
#[schema(example = json!({
"id": "resp_abc123",
"object": "response",
"deleted": true
}))]
pub struct ResponseDeleted {
#[schema(example = "resp_abc123")]
pub id: String,
#[serde(rename = "object")]
#[schema(example = "response")]
pub object_type: ResponseDeletedObjectType,
#[schema(example = true)]
pub deleted: bool,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum ResponseDeletedObjectType {
Response,
}
#[tracing::instrument(skip_all)]
pub async fn get_response<P: PoolProvider>(
State(state): State<AppState<P>>,
headers: HeaderMap,
Path(response_id): Path<String>,
) -> Result<Json<serde_json::Value>> {
let api_key = headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.strip_prefix("Bearer "))
.ok_or_else(|| Error::Unauthenticated { message: None })?;
let owner_id: String = sqlx::query_scalar("SELECT user_id::text FROM public.api_keys WHERE secret = $1 AND is_deleted = false LIMIT 1")
.bind(api_key)
.fetch_optional(state.db.read())
.await
.map_err(|e| Error::Database(e.into()))?
.ok_or_else(|| Error::Unauthenticated { message: None })?;
let uuid_str = response_id.strip_prefix("resp_").unwrap_or(&response_id);
let head_step_uuid = uuid::Uuid::parse_str(uuid_str).map_err(|_| Error::NotFound {
resource: "response".to_string(),
id: response_id.clone(),
})?;
let auth_request_id = match state.response_step_manager.as_ref() {
Some(step_manager) => match step_manager.get_step(fusillade::StepId(head_step_uuid)).await {
Ok(Some(head_step)) => head_step.request_id.unwrap_or(fusillade::RequestId(head_step_uuid)),
Ok(None) => fusillade::RequestId(head_step_uuid),
Err(e) => return Err(Error::Database(crate::db::errors::DbError::Other(anyhow::anyhow!("{e}")))),
},
None => fusillade::RequestId(head_step_uuid),
};
let detail = state
.request_manager
.get_request_detail(auth_request_id)
.await
.map_err(|e| match e {
fusillade::FusilladeError::RequestNotFound(_) => Error::NotFound {
resource: "response".to_string(),
id: response_id.clone(),
},
_ => Error::Database(crate::db::errors::DbError::Other(anyhow::anyhow!("{e}"))),
})?;
let owner = detail.created_by.as_str();
if owner != owner_id {
return Err(Error::NotFound {
resource: "response".to_string(),
id: response_id,
});
}
let resp = state
.response_store
.get_response(&response_id)
.await
.map_err(|e| match e {
StoreError::NotFound(_) => Error::NotFound {
resource: "response".to_string(),
id: response_id.clone(),
},
_ => Error::Database(crate::db::errors::DbError::Other(anyhow::anyhow!("{e}"))),
})?
.ok_or_else(|| Error::NotFound {
resource: "response".to_string(),
id: response_id,
})?;
Ok(Json(resp))
}
#[utoipa::path(
delete,
path = "/responses/{response_id}",
tag = "responses-api",
summary = "Delete response",
description = "Hard-deletes a response and every fusillade row that backs it (\
including the dedicated request_templates row carrying the prompt body). \
Provided for right-to-erasure compliance.
Token / cost analytics in `http_analytics` and billing transactions in \
`credits_transactions` are preserved — they are denormalized off the fusillade \
schema and survive the erasure.",
params(
("response_id" = String, Path, description = "The response ID returned when the response was created (with or without the `resp_` prefix).")
),
responses(
(status = 200, description = "Response deleted.", body = ResponseDeleted),
(status = 401, description = "Invalid or missing API key. Ensure your `Authorization` header is set to `Bearer YOUR_API_KEY`."),
(status = 404, description = "Response not found or you don't have access to it."),
(status = 500, description = "An unexpected error occurred. Retry the request or contact support if the issue persists.")
),
security(("BearerAuth" = []))
)]
#[tracing::instrument(skip_all, fields(response_id = %response_id))]
pub async fn delete_response<P: PoolProvider>(
State(state): State<AppState<P>>,
headers: HeaderMap,
Path(response_id): Path<String>,
) -> Result<Json<ResponseDeleted>> {
let api_key = headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.strip_prefix("Bearer "))
.ok_or_else(|| Error::Unauthenticated { message: None })?;
let owner_id: String = sqlx::query_scalar("SELECT user_id::text FROM public.api_keys WHERE secret = $1 AND is_deleted = false LIMIT 1")
.bind(api_key)
.fetch_optional(state.db.read())
.await
.map_err(|e| Error::Database(e.into()))?
.ok_or_else(|| Error::Unauthenticated { message: None })?;
let uuid_str = response_id.strip_prefix("resp_").unwrap_or(&response_id);
let head_step_uuid = uuid::Uuid::parse_str(uuid_str).map_err(|_| Error::NotFound {
resource: "response".to_string(),
id: response_id.clone(),
})?;
let (auth_request_id, request_ids_to_delete): (fusillade::RequestId, Vec<fusillade::RequestId>) =
match state.response_step_manager.as_ref() {
Some(step_manager) => match step_manager
.get_step(fusillade::StepId(head_step_uuid))
.await
.map_err(|e| Error::Database(crate::db::errors::DbError::Other(anyhow::anyhow!("{e}"))))?
{
Some(head_step) => {
let chain = step_manager
.list_chain(fusillade::StepId(head_step_uuid))
.await
.map_err(|e| Error::Database(crate::db::errors::DbError::Other(anyhow::anyhow!("{e}"))))?;
let ids: Vec<fusillade::RequestId> = chain
.iter()
.filter_map(|s| s.request_id)
.collect::<HashSet<_>>()
.into_iter()
.collect();
let auth_id = head_step.request_id.unwrap_or(fusillade::RequestId(head_step_uuid));
let ids = if ids.is_empty() { vec![auth_id] } else { ids };
(auth_id, ids)
}
None => {
let id = fusillade::RequestId(head_step_uuid);
(id, vec![id])
}
},
None => {
let id = fusillade::RequestId(head_step_uuid);
(id, vec![id])
}
};
let detail = state
.request_manager
.get_request_detail(auth_request_id)
.await
.map_err(|e| match e {
fusillade::FusilladeError::RequestNotFound(_) => Error::NotFound {
resource: "response".to_string(),
id: response_id.clone(),
},
_ => Error::Database(crate::db::errors::DbError::Other(anyhow::anyhow!("{e}"))),
})?;
if detail.created_by.as_str() != owner_id {
return Err(Error::NotFound {
resource: "response".to_string(),
id: response_id,
});
}
let total = request_ids_to_delete.len();
let mut failed: Vec<fusillade::RequestId> = Vec::new();
for id in request_ids_to_delete {
match state.request_manager.delete_request(id).await {
Ok(()) => {}
Err(fusillade::FusilladeError::RequestNotFound(_)) => {}
Err(e) => {
tracing::error!(
response_id = %response_id,
request_id = %*id,
error = %e,
"delete_response: per-row delete failed; continuing to maximize erasure progress",
);
failed.push(id);
}
}
}
if !failed.is_empty() {
tracing::error!(
response_id = %response_id,
failed_count = failed.len(),
total_count = total,
"delete_response: partial failure; client may retry the DELETE to clean up remaining rows",
);
return Err(Error::Database(crate::db::errors::DbError::Other(anyhow::anyhow!(
"deleted {}/{} backing rows for response {}; {} remain — retry to complete erasure",
total - failed.len(),
total,
response_id,
failed.len(),
))));
}
Ok(Json(ResponseDeleted {
id: response_id,
object_type: ResponseDeletedObjectType::Response,
deleted: true,
}))
}