feat: complete Epic 1 production foundation
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, Path, Query, State},
|
||||
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};
|
||||
|
||||
@@ -79,7 +80,7 @@ pub async fn analyze_operation_quality(
|
||||
pub async fn get_operation(
|
||||
Path(path): Path<WorkspaceOperationPath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let operation = state
|
||||
.service
|
||||
.get_operation(
|
||||
@@ -87,20 +88,30 @@ pub async fn get_operation(
|
||||
&path.operation_id.as_str().into(),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(operation)))
|
||||
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)))
|
||||
@@ -109,12 +120,15 @@ pub async fn update_operation(
|
||||
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)))
|
||||
@@ -123,7 +137,7 @@ pub async fn delete_operation(
|
||||
pub async fn get_operation_version(
|
||||
Path(path): Path<WorkspaceOperationVersionPath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let version = state
|
||||
.service
|
||||
.get_operation_version(
|
||||
@@ -132,20 +146,30 @@ pub async fn get_operation_version(
|
||||
path.version,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(version)))
|
||||
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)))
|
||||
@@ -154,14 +178,17 @@ pub async fn create_version(
|
||||
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)))
|
||||
@@ -170,17 +197,63 @@ pub async fn publish_operation(
|
||||
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>,
|
||||
@@ -285,11 +358,33 @@ pub async fn import_operation(
|
||||
Path(path): Path<WorkspacePath>,
|
||||
Query(query): Query<ImportQuery>,
|
||||
State(state): State<AppState>,
|
||||
body: String,
|
||||
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)
|
||||
.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)))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user