Files
crank/apps/admin-api/src/routes/operations.rs
T

391 lines
11 KiB
Rust

use axum::{
Json,
extract::{Extension, Path, Query, State, rejection::StringRejection},
http::{HeaderMap, StatusCode, header},
response::IntoResponse,
};
use crank_registry::OperationStateExpectation;
use serde::Deserialize;
use serde_json::{Value, json};
use crate::{
error::ApiError,
request_context::RequestContext,
service::{
ExportQuery, GenerateDraftPayload, ImportQuery, NewVersionPayload, OperationPayload,
PublishPayload, TestRunPayload, UpdateOperationPayload,
},
state::AppState,
};
#[derive(Deserialize)]
pub struct WorkspacePath {
pub workspace_id: String,
}
#[derive(Deserialize)]
pub struct WorkspaceOperationPath {
pub workspace_id: String,
pub operation_id: String,
}
#[derive(Deserialize)]
pub struct WorkspaceOperationVersionPath {
pub workspace_id: String,
pub operation_id: String,
pub version: u32,
}
pub async fn list_operations(
Path(path): Path<WorkspacePath>,
State(state): State<AppState>,
) -> Result<Json<Value>, ApiError> {
let items = state
.service
.list_operations(&path.workspace_id.as_str().into())
.await?;
let total = items.len();
Ok(Json(json!({
"items": items,
"page": 1,
"page_size": total,
"total": total
})))
}
pub async fn create_operation(
Path(path): Path<WorkspacePath>,
State(state): State<AppState>,
Json(payload): Json<OperationPayload>,
) -> Result<Json<Value>, ApiError> {
let created = state
.service
.create_operation(&path.workspace_id.as_str().into(), payload)
.await?;
Ok(Json(json!(created)))
}
pub async fn analyze_operation_quality(
Path(path): Path<WorkspacePath>,
State(state): State<AppState>,
Json(payload): Json<OperationPayload>,
) -> Result<Json<Value>, ApiError> {
let report = state
.service
.analyze_operation_quality(&path.workspace_id.as_str().into(), payload)
.await?;
Ok(Json(json!(report)))
}
pub async fn get_operation(
Path(path): Path<WorkspaceOperationPath>,
State(state): State<AppState>,
) -> Result<impl IntoResponse, ApiError> {
let operation = state
.service
.get_operation(
&path.workspace_id.as_str().into(),
&path.operation_id.as_str().into(),
)
.await?;
let etag = crate::service::AdminService::operation_state_etag(&operation);
let mut headers = HeaderMap::new();
headers.insert(
header::ETAG,
header::HeaderValue::from_str(&etag)
.map_err(|_| ApiError::internal("invalid operation etag"))?,
);
Ok((headers, Json(json!(operation))))
}
pub async fn update_operation(
Path(path): Path<WorkspaceOperationPath>,
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<UpdateOperationPayload>,
) -> Result<Json<Value>, ApiError> {
let expected = require_operation_precondition(&state, &path, &headers).await?;
let result = state
.service
.update_operation(
&path.workspace_id.as_str().into(),
&path.operation_id.as_str().into(),
payload,
&expected,
)
.await?;
Ok(Json(json!(result)))
}
pub async fn delete_operation(
Path(path): Path<WorkspaceOperationPath>,
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<Value>, ApiError> {
let expected = require_operation_precondition(&state, &path, &headers).await?;
let result = state
.service
.delete_operation(
&path.workspace_id.as_str().into(),
&path.operation_id.as_str().into(),
&expected,
)
.await?;
Ok(Json(json!(result)))
}
pub async fn get_operation_version(
Path(path): Path<WorkspaceOperationVersionPath>,
State(state): State<AppState>,
) -> Result<impl IntoResponse, ApiError> {
let version = state
.service
.get_operation_version(
&path.workspace_id.as_str().into(),
&path.operation_id.as_str().into(),
path.version,
)
.await?;
let etag = crate::service::AdminService::operation_version_etag(&version)?;
let mut headers = HeaderMap::new();
headers.insert(
header::ETAG,
header::HeaderValue::from_str(&etag)
.map_err(|_| ApiError::internal("invalid operation version etag"))?,
);
Ok((headers, Json(json!(version))))
}
pub async fn create_version(
Path(path): Path<WorkspaceOperationPath>,
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<NewVersionPayload>,
) -> Result<Json<Value>, ApiError> {
let expected = require_operation_precondition(&state, &path, &headers).await?;
let created = state
.service
.create_version(
&path.workspace_id.as_str().into(),
&path.operation_id.as_str().into(),
payload,
&expected,
)
.await?;
Ok(Json(json!(created)))
}
pub async fn publish_operation(
Path(path): Path<WorkspaceOperationPath>,
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<PublishPayload>,
) -> Result<Json<Value>, ApiError> {
let expected = require_operation_precondition(&state, &path, &headers).await?;
let published = state
.service
.publish_operation(
&path.workspace_id.as_str().into(),
&path.operation_id.as_str().into(),
payload.version,
&expected,
)
.await?;
Ok(Json(json!(published)))
}
pub async fn archive_operation(
Path(path): Path<WorkspaceOperationPath>,
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<Value>, ApiError> {
let expected = require_operation_precondition(&state, &path, &headers).await?;
let archived = state
.service
.archive_operation(
&path.workspace_id.as_str().into(),
&path.operation_id.as_str().into(),
&expected,
)
.await?;
Ok(Json(json!(archived)))
}
async fn require_operation_precondition(
state: &AppState,
path: &WorkspaceOperationPath,
headers: &HeaderMap,
) -> Result<OperationStateExpectation, ApiError> {
let detail = state
.service
.get_operation(
&path.workspace_id.as_str().into(),
&path.operation_id.as_str().into(),
)
.await?;
let expected = crate::service::AdminService::operation_state_etag(&detail);
let provided = headers
.get(header::IF_MATCH)
.and_then(|value| value.to_str().ok())
.ok_or_else(|| {
ApiError::precondition_required_with_context(
"If-Match is required for Operation mutation",
json!({
"error_code": "operation_precondition_required",
"current_version": detail.current_draft_version,
"recovery": "reload"
}),
)
})?;
if provided != expected {
return Err(ApiError::conflict_with_context(
"Operation state changed; reload before retrying",
json!({
"error_code": "operation_stale_version",
"current_version": detail.current_draft_version,
"recovery": "reload"
}),
));
}
Ok(OperationStateExpectation {
current_draft_version: detail.current_draft_version,
status: detail.status,
latest_published_version: detail.latest_published_version,
})
}
pub async fn run_test(
Path(path): Path<WorkspaceOperationPath>,
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Json(payload): Json<TestRunPayload>,
) -> Result<Json<Value>, ApiError> {
let result = state
.service
.run_test(
&path.workspace_id.as_str().into(),
&path.operation_id.as_str().into(),
payload,
&request_context.correlation,
)
.await?;
Ok(Json(json!(result)))
}
pub async fn upload_input_json(
Path(path): Path<WorkspaceOperationPath>,
State(state): State<AppState>,
Json(payload): Json<Value>,
) -> Result<Json<Value>, ApiError> {
let sample = state
.service
.save_json_sample(
&path.workspace_id.as_str().into(),
&path.operation_id.as_str().into(),
crank_registry::SampleKind::InputJson,
&payload,
)
.await?;
Ok(Json(json!({
"sample_id": sample.id.as_str(),
"sample_kind": sample.sample_kind,
"version": sample.version
})))
}
pub async fn upload_output_json(
Path(path): Path<WorkspaceOperationPath>,
State(state): State<AppState>,
Json(payload): Json<Value>,
) -> Result<Json<Value>, ApiError> {
let sample = state
.service
.save_json_sample(
&path.workspace_id.as_str().into(),
&path.operation_id.as_str().into(),
crank_registry::SampleKind::OutputJson,
&payload,
)
.await?;
Ok(Json(json!({
"sample_id": sample.id.as_str(),
"sample_kind": sample.sample_kind,
"version": sample.version
})))
}
pub async fn generate_draft(
Path(path): Path<WorkspaceOperationPath>,
State(state): State<AppState>,
Json(payload): Json<GenerateDraftPayload>,
) -> Result<Json<Value>, ApiError> {
let draft = state
.service
.generate_draft(
&path.workspace_id.as_str().into(),
&path.operation_id.as_str().into(),
payload,
)
.await?;
Ok(Json(json!(draft)))
}
pub async fn export_operation(
Path(path): Path<WorkspaceOperationPath>,
Query(query): Query<ExportQuery>,
State(state): State<AppState>,
) -> Result<impl IntoResponse, ApiError> {
let yaml = state
.service
.export_operation(
&path.workspace_id.as_str().into(),
&path.operation_id.as_str().into(),
query,
)
.await?;
let mut headers = HeaderMap::new();
headers.insert(
header::CONTENT_TYPE,
header::HeaderValue::from_static("application/yaml"),
);
Ok((StatusCode::OK, headers, yaml))
}
pub async fn import_operation(
Path(path): Path<WorkspacePath>,
Query(query): Query<ImportQuery>,
State(state): State<AppState>,
headers: HeaderMap,
body: Result<String, StringRejection>,
) -> Result<Json<Value>, ApiError> {
let body = body.map_err(|rejection| match rejection {
StringRejection::InvalidUtf8(_) => ApiError::unprocessable_with_context(
"operation yaml is not valid UTF-8",
json!({ "error_code": "operation_yaml_invalid" }),
),
StringRejection::FailedToBufferBody(_) => ApiError::payload_too_large_with_context(
"operation yaml exceeds the 256 KiB limit",
json!({ "error_code": "operation_yaml_too_large" }),
),
_ => ApiError::unprocessable_with_context(
"operation yaml body is invalid",
json!({ "error_code": "operation_yaml_invalid" }),
),
})?;
let imported = state
.service
.import_operation(
&path.workspace_id.as_str().into(),
query,
&body,
headers
.get(header::IF_MATCH)
.and_then(|value| value.to_str().ok()),
)
.await?;
Ok(Json(json!(imported)))
}