391 lines
11 KiB
Rust
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)))
|
|
}
|