feat: complete Epic 1 production foundation
This commit is contained in:
@@ -1,15 +1,19 @@
|
||||
use axum::{
|
||||
Json,
|
||||
Extension, Json,
|
||||
extract::{Path, State},
|
||||
http::{HeaderMap, header},
|
||||
response::IntoResponse,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{
|
||||
auth::AuthenticatedSession,
|
||||
error::ApiError,
|
||||
request_context::RequestContext,
|
||||
service::{
|
||||
AgentCatalogPayload, AgentPayload, PlatformApiKeyPayload, PublishPayload,
|
||||
ToolSearchPreviewPayload, UpdateAgentPayload,
|
||||
AdminAuditContext, AgentCatalogPayload, AgentPayload, PlatformApiKeyPayload,
|
||||
PublishPayload, ToolSearchPreviewPayload, UpdateAgentPayload,
|
||||
},
|
||||
state::AppState,
|
||||
};
|
||||
@@ -77,7 +81,7 @@ pub async fn create_agent(
|
||||
pub async fn get_agent(
|
||||
Path(path): Path<WorkspaceAgentPath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let agent = state
|
||||
.service
|
||||
.get_agent(
|
||||
@@ -85,20 +89,30 @@ pub async fn get_agent(
|
||||
&path.agent_id.as_str().into(),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(agent)))
|
||||
let etag = crate::service::AdminService::agent_state_etag(&agent);
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
header::ETAG,
|
||||
header::HeaderValue::from_str(&etag)
|
||||
.map_err(|_| ApiError::internal("invalid agent etag"))?,
|
||||
);
|
||||
Ok((headers, Json(json!(agent))))
|
||||
}
|
||||
|
||||
pub async fn update_agent(
|
||||
Path(path): Path<WorkspaceAgentPath>,
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(payload): Json<UpdateAgentPayload>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let expected_state = require_agent_precondition(&state, &path, &headers).await?;
|
||||
let updated = state
|
||||
.service
|
||||
.update_agent(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.agent_id.as_str().into(),
|
||||
payload,
|
||||
Some(&expected_state),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(updated)))
|
||||
@@ -107,12 +121,15 @@ pub async fn update_agent(
|
||||
pub async fn delete_agent(
|
||||
Path(path): Path<WorkspaceAgentPath>,
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let expected_state = require_agent_precondition(&state, &path, &headers).await?;
|
||||
let deleted = state
|
||||
.service
|
||||
.delete_agent(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.agent_id.as_str().into(),
|
||||
Some(&expected_state),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(deleted)))
|
||||
@@ -136,30 +153,79 @@ pub async fn get_agent_version(
|
||||
pub async fn save_agent_bindings(
|
||||
Path(path): Path<WorkspaceAgentPath>,
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(payload): Json<AgentCatalogPayload>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let expected_state = require_agent_precondition(&state, &path, &headers).await?;
|
||||
let record = state
|
||||
.service
|
||||
.save_agent_bindings(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.agent_id.as_str().into(),
|
||||
payload,
|
||||
Some(&expected_state),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(record)))
|
||||
}
|
||||
|
||||
async fn require_agent_precondition(
|
||||
state: &AppState,
|
||||
path: &WorkspaceAgentPath,
|
||||
headers: &HeaderMap,
|
||||
) -> Result<crank_registry::AgentStateExpectation, ApiError> {
|
||||
let agent = state
|
||||
.service
|
||||
.get_agent(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.agent_id.as_str().into(),
|
||||
)
|
||||
.await?;
|
||||
let expected = crate::service::AdminService::agent_state_etag(&agent);
|
||||
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 Agent mutation",
|
||||
json!({
|
||||
"error_code": "agent_precondition_required",
|
||||
"current_version": agent.current_draft_version,
|
||||
"latest_published_version": agent.latest_published_version,
|
||||
"catalog_revision": agent.catalog_revision,
|
||||
"recovery": "reload"
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
if provided != expected {
|
||||
return Err(ApiError::conflict_with_context(
|
||||
"Agent state changed; reload before retrying",
|
||||
json!({
|
||||
"error_code": "agent_stale_revision",
|
||||
"current_version": agent.current_draft_version,
|
||||
"latest_published_version": agent.latest_published_version,
|
||||
"catalog_revision": agent.catalog_revision,
|
||||
"recovery": "reload"
|
||||
}),
|
||||
));
|
||||
}
|
||||
crate::service::AdminService::agent_state_expectation(&agent)
|
||||
}
|
||||
|
||||
pub async fn publish_agent(
|
||||
Path(path): Path<WorkspaceAgentPath>,
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(payload): Json<PublishPayload>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let expected_state = require_agent_precondition(&state, &path, &headers).await?;
|
||||
let published = state
|
||||
.service
|
||||
.publish_agent(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.agent_id.as_str().into(),
|
||||
payload.version,
|
||||
Some(&expected_state),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(published)))
|
||||
@@ -168,12 +234,15 @@ pub async fn publish_agent(
|
||||
pub async fn unpublish_agent(
|
||||
Path(path): Path<WorkspaceAgentPath>,
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let expected_state = require_agent_precondition(&state, &path, &headers).await?;
|
||||
let updated = state
|
||||
.service
|
||||
.unpublish_agent(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.agent_id.as_str().into(),
|
||||
Some(&expected_state),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(updated)))
|
||||
@@ -182,12 +251,15 @@ pub async fn unpublish_agent(
|
||||
pub async fn archive_agent(
|
||||
Path(path): Path<WorkspaceAgentPath>,
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let expected_state = require_agent_precondition(&state, &path, &headers).await?;
|
||||
let updated = state
|
||||
.service
|
||||
.archive_agent(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.agent_id.as_str().into(),
|
||||
Some(&expected_state),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(updated)))
|
||||
@@ -210,14 +282,19 @@ pub async fn list_agent_platform_api_keys(
|
||||
pub async fn create_agent_platform_api_key(
|
||||
Path(path): Path<WorkspaceAgentPath>,
|
||||
State(state): State<AppState>,
|
||||
Extension(session): Extension<AuthenticatedSession>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Json(payload): Json<PlatformApiKeyPayload>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let audit_context =
|
||||
AdminAuditContext::from_session_and_correlation(&session, &request_context.correlation);
|
||||
let created = state
|
||||
.service
|
||||
.create_agent_platform_api_key(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.agent_id.as_str().into(),
|
||||
payload,
|
||||
Some(&audit_context),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(created)))
|
||||
@@ -226,13 +303,18 @@ pub async fn create_agent_platform_api_key(
|
||||
pub async fn revoke_agent_platform_api_key(
|
||||
Path(path): Path<WorkspaceAgentPlatformApiKeyPath>,
|
||||
State(state): State<AppState>,
|
||||
Extension(session): Extension<AuthenticatedSession>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
) -> Result<axum::http::StatusCode, ApiError> {
|
||||
let audit_context =
|
||||
AdminAuditContext::from_session_and_correlation(&session, &request_context.correlation);
|
||||
state
|
||||
.service
|
||||
.revoke_agent_platform_api_key(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.agent_id.as_str().into(),
|
||||
&path.key_id.as_str().into(),
|
||||
Some(&audit_context),
|
||||
)
|
||||
.await?;
|
||||
Ok(axum::http::StatusCode::NO_CONTENT)
|
||||
@@ -241,13 +323,18 @@ pub async fn revoke_agent_platform_api_key(
|
||||
pub async fn delete_agent_platform_api_key(
|
||||
Path(path): Path<WorkspaceAgentPlatformApiKeyPath>,
|
||||
State(state): State<AppState>,
|
||||
Extension(session): Extension<AuthenticatedSession>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
) -> Result<axum::http::StatusCode, ApiError> {
|
||||
let audit_context =
|
||||
AdminAuditContext::from_session_and_correlation(&session, &request_context.correlation);
|
||||
state
|
||||
.service
|
||||
.delete_agent_platform_api_key(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.agent_id.as_str().into(),
|
||||
&path.key_id.as_str().into(),
|
||||
Some(&audit_context),
|
||||
)
|
||||
.await?;
|
||||
Ok(axum::http::StatusCode::NO_CONTENT)
|
||||
|
||||
@@ -5,18 +5,52 @@ use serde_json::json;
|
||||
use crate::{
|
||||
auth::{AuthenticatedSession, cleared_session_cookie, extract_session_token, session_cookie},
|
||||
error::ApiError,
|
||||
rate_limit::ClientIdentityBucket,
|
||||
service::{
|
||||
ChangePasswordPayload, LoginPayload, UpdateCurrentWorkspacePayload, UpdateProfilePayload,
|
||||
ChangePasswordPayload, CompleteBootstrapPayload, LoginPayload,
|
||||
UpdateCurrentWorkspacePayload, UpdateProfilePayload,
|
||||
},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
pub async fn bootstrap_status(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
Ok(Json(serde_json::json!(
|
||||
state.service.bootstrap_status().await?
|
||||
)))
|
||||
}
|
||||
|
||||
pub async fn complete_bootstrap(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Json(payload): Json<CompleteBootstrapPayload>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let (session_data, session) = state.service.complete_bootstrap(payload).await?;
|
||||
let cookie_value = format!(
|
||||
"{}.{}",
|
||||
session_data.session_id.as_str(),
|
||||
session_data.value
|
||||
);
|
||||
let jar = jar.add(session_cookie(state.service.auth_settings(), &cookie_value));
|
||||
|
||||
Ok((jar, Json(serde_json::json!(session))))
|
||||
}
|
||||
|
||||
pub async fn login(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
client_bucket: Option<Extension<ClientIdentityBucket>>,
|
||||
Json(payload): Json<LoginPayload>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let (session_data, session) = state.service.login(payload).await?;
|
||||
let client_bucket = client_bucket
|
||||
.as_ref()
|
||||
.map(|Extension(bucket)| bucket.0.as_str())
|
||||
.unwrap_or("anonymous:/api/auth/login");
|
||||
let (session_data, session) = state
|
||||
.service
|
||||
.login_with_client_bucket(payload, client_bucket)
|
||||
.await?;
|
||||
let cookie_value = format!(
|
||||
"{}.{}",
|
||||
session_data.session_id.as_str(),
|
||||
@@ -54,6 +88,22 @@ pub async fn get_session(
|
||||
Ok(Json(serde_json::json!(session)))
|
||||
}
|
||||
|
||||
pub async fn refresh_session_csrf(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let (session_id, session_value) = extract_session_token(&jar)
|
||||
.ok_or_else(|| ApiError::unauthorized("authentication required"))?;
|
||||
state
|
||||
.service
|
||||
.get_session(&session_id, &session_value)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::unauthorized("session is invalid or expired"))?;
|
||||
let csrf_token = state.service.rotate_session_csrf_token(&session_id).await?;
|
||||
|
||||
Ok(Json(json!({ "csrf_token": csrf_token })))
|
||||
}
|
||||
|
||||
pub async fn get_profile(
|
||||
Extension(session): Extension<AuthenticatedSession>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
@@ -73,6 +123,7 @@ pub async fn update_profile(
|
||||
.service
|
||||
.update_profile(
|
||||
&session.user.id,
|
||||
&session.session_id,
|
||||
session.current_workspace_id.as_ref(),
|
||||
payload,
|
||||
)
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
use axum::{
|
||||
Json,
|
||||
Extension, Json,
|
||||
extract::{Path, State},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{error::ApiError, service::AuthProfilePayload, state::AppState};
|
||||
use crate::{
|
||||
auth::AuthenticatedSession,
|
||||
error::ApiError,
|
||||
request_context::RequestContext,
|
||||
service::{AdminAuditContext, AuthProfilePayload},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct WorkspacePath {
|
||||
@@ -32,11 +38,19 @@ pub async fn list_auth_profiles(
|
||||
pub async fn create_auth_profile(
|
||||
Path(path): Path<WorkspacePath>,
|
||||
State(state): State<AppState>,
|
||||
Extension(session): Extension<AuthenticatedSession>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Json(payload): Json<AuthProfilePayload>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let audit_context =
|
||||
AdminAuditContext::from_session_and_correlation(&session, &request_context.correlation);
|
||||
let profile = state
|
||||
.service
|
||||
.create_auth_profile(&path.workspace_id.as_str().into(), payload)
|
||||
.create_auth_profile(
|
||||
&path.workspace_id.as_str().into(),
|
||||
payload,
|
||||
Some(&audit_context),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(profile)))
|
||||
}
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, Query, State},
|
||||
http::{
|
||||
HeaderValue, StatusCode,
|
||||
header::{CONTENT_DISPOSITION, CONTENT_TYPE},
|
||||
},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{
|
||||
error::ApiError,
|
||||
routes::access::WorkspacePath,
|
||||
service::{ApprovalsQuery, LogsQuery, UsageRequestQuery},
|
||||
service::{ApprovalDecisionPayload, ApprovalsQuery, LogsQuery, UsageRequestQuery},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
@@ -44,7 +49,49 @@ pub async fn list_logs(
|
||||
.service
|
||||
.list_logs(&path.workspace_id.as_str().into(), query)
|
||||
.await?;
|
||||
Ok(Json(json!({ "items": items })))
|
||||
Ok(Json(json!(items)))
|
||||
}
|
||||
|
||||
pub async fn export_logs_csv(
|
||||
Path(path): Path<WorkspacePath>,
|
||||
Query(query): Query<LogsQuery>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let csv = state
|
||||
.service
|
||||
.export_logs_csv(&path.workspace_id.as_str().into(), query)
|
||||
.await?;
|
||||
let mut response = (StatusCode::OK, csv).into_response();
|
||||
response.headers_mut().insert(
|
||||
CONTENT_TYPE,
|
||||
HeaderValue::from_static("text/csv; charset=utf-8"),
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
CONTENT_DISPOSITION,
|
||||
HeaderValue::from_static("attachment; filename=\"crank-invocation-history.csv\""),
|
||||
);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub async fn export_usage_csv(
|
||||
Path(path): Path<WorkspacePath>,
|
||||
Query(query): Query<UsageRequestQuery>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let csv = state
|
||||
.service
|
||||
.export_usage_csv(&path.workspace_id.as_str().into(), query)
|
||||
.await?;
|
||||
let mut response = (StatusCode::OK, csv).into_response();
|
||||
response.headers_mut().insert(
|
||||
CONTENT_TYPE,
|
||||
HeaderValue::from_static("text/csv; charset=utf-8"),
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
CONTENT_DISPOSITION,
|
||||
HeaderValue::from_static("attachment; filename=\"crank-usage.csv\""),
|
||||
);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub async fn get_log(
|
||||
@@ -87,6 +134,38 @@ pub async fn get_approval(
|
||||
Ok(Json(json!(item)))
|
||||
}
|
||||
|
||||
pub async fn approve_approval(
|
||||
Path(path): Path<WorkspaceApprovalPath>,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<ApprovalDecisionPayload>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let item = state
|
||||
.service
|
||||
.approve_approval(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.approval_id.as_str().into(),
|
||||
payload,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(item)))
|
||||
}
|
||||
|
||||
pub async fn deny_approval(
|
||||
Path(path): Path<WorkspaceApprovalPath>,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<ApprovalDecisionPayload>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let item = state
|
||||
.service
|
||||
.deny_approval(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.approval_id.as_str().into(),
|
||||
payload,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(item)))
|
||||
}
|
||||
|
||||
pub async fn get_usage(
|
||||
Path(path): Path<WorkspacePath>,
|
||||
Query(query): Query<UsageRequestQuery>,
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
error::ApiError,
|
||||
service::{
|
||||
OnboardingEventPayload, OnboardingEventResponse, OnboardingResponse,
|
||||
ResetOnboardingSelectionPayload, ResetOnboardingSelectionResponse,
|
||||
},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct WorkspaceOnboardingPath {
|
||||
pub workspace_id: String,
|
||||
}
|
||||
|
||||
pub async fn reset_onboarding_selection(
|
||||
Path(path): Path<WorkspaceOnboardingPath>,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<ResetOnboardingSelectionPayload>,
|
||||
) -> Result<Json<ResetOnboardingSelectionResponse>, ApiError> {
|
||||
Ok(Json(
|
||||
state
|
||||
.service
|
||||
.reset_onboarding_selection(
|
||||
&path.workspace_id.as_str().into(),
|
||||
payload.expected_revision,
|
||||
)
|
||||
.await?,
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn get_onboarding(
|
||||
Path(path): Path<WorkspaceOnboardingPath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<OnboardingResponse>, ApiError> {
|
||||
Ok(Json(
|
||||
state
|
||||
.service
|
||||
.get_onboarding(&path.workspace_id.as_str().into())
|
||||
.await?,
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn record_onboarding_event(
|
||||
Path(path): Path<WorkspaceOnboardingPath>,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<OnboardingEventPayload>,
|
||||
) -> Result<Json<OnboardingEventResponse>, ApiError> {
|
||||
Ok(Json(
|
||||
state
|
||||
.service
|
||||
.record_onboarding_event(&path.workspace_id.as_str().into(), payload)
|
||||
.await?,
|
||||
))
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ use serde_json::{Value, json};
|
||||
use crate::{
|
||||
auth::AuthenticatedSession,
|
||||
error::ApiError,
|
||||
service::{RotateSecretPayload, SecretPayload},
|
||||
request_context::RequestContext,
|
||||
service::{AdminAuditContext, RotateSecretPayload, SecretPayload},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
@@ -38,14 +39,18 @@ pub async fn create_secret(
|
||||
Path(path): Path<WorkspacePath>,
|
||||
State(state): State<AppState>,
|
||||
Extension(session): Extension<AuthenticatedSession>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Json(payload): Json<SecretPayload>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let audit_context =
|
||||
AdminAuditContext::from_session_and_correlation(&session, &request_context.correlation);
|
||||
let secret = state
|
||||
.service
|
||||
.create_secret(
|
||||
&path.workspace_id.as_str().into(),
|
||||
Some(&session.user.id),
|
||||
payload,
|
||||
Some(&audit_context),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(secret)))
|
||||
@@ -69,8 +74,11 @@ pub async fn rotate_secret(
|
||||
Path(path): Path<WorkspaceSecretPath>,
|
||||
State(state): State<AppState>,
|
||||
Extension(session): Extension<AuthenticatedSession>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Json(payload): Json<RotateSecretPayload>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let audit_context =
|
||||
AdminAuditContext::from_session_and_correlation(&session, &request_context.correlation);
|
||||
let secret = state
|
||||
.service
|
||||
.rotate_secret(
|
||||
@@ -78,6 +86,7 @@ pub async fn rotate_secret(
|
||||
&path.secret_id.as_str().into(),
|
||||
Some(&session.user.id),
|
||||
payload,
|
||||
Some(&audit_context),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(secret)))
|
||||
@@ -86,12 +95,17 @@ pub async fn rotate_secret(
|
||||
pub async fn delete_secret(
|
||||
Path(path): Path<WorkspaceSecretPath>,
|
||||
State(state): State<AppState>,
|
||||
Extension(session): Extension<AuthenticatedSession>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let audit_context =
|
||||
AdminAuditContext::from_session_and_correlation(&session, &request_context.correlation);
|
||||
state
|
||||
.service
|
||||
.delete_secret(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.secret_id.as_str().into(),
|
||||
Some(&audit_context),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!({ "ok": true })))
|
||||
|
||||
Reference in New Issue
Block a user