use axum::{
Json, Router,
extract::{Path, State},
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
routing::post,
};
use serde::Deserialize;
use serde_json::{Value, json};
use uuid::Uuid;
use super::graphql::{AppState, tenant_dispatch};
use crate::{
async_operations::AsyncOperationsRuntime,
extractors::OptionalSecurityContext,
routes::idempotency::{IdempotencyCheck, IdempotencyScope, StoredResponse, hash_body},
};
#[derive(Clone)]
pub struct AsyncOperationsState {
pub runtime: AsyncOperationsRuntime,
pub app: AppState,
}
pub fn router(state: AsyncOperationsState) -> Router {
Router::new()
.route("/operations/v1/{id_or_operation}", post(submit).get(status).delete(cancel))
.with_state(state)
}
#[cfg(test)]
mod router_construction {
use super::*;
#[tokio::test]
#[allow(clippy::unwrap_used)] async fn async_operations_router_constructs() {
use std::sync::Arc;
use fraiseql_core::runtime::Executor;
use fraiseql_test_utils::failing_adapter::FailingAdapter;
let app = AppState::new(Arc::new(Executor::new(
fraiseql_core::schema::CompiledSchema::new(),
Arc::new(FailingAdapter::new()),
)));
let runtime = AsyncOperationsRuntime {
store: Arc::new(crate::async_operations::AsyncOperationStore::new(
sqlx::PgPool::connect_lazy("postgres://test:test@localhost/test").unwrap(),
)),
config: Arc::new(crate::server_config::AsyncOperationsConfig::default()),
};
let _router = router(AsyncOperationsState { runtime, app });
}
}
#[derive(Debug, Deserialize)]
struct SubmitBody {
query: String,
#[serde(default)]
variables: Option<Value>,
}
fn error_response(status: StatusCode, message: &str) -> Response {
(status, Json(json!({ "error": message }))).into_response()
}
fn op_json(op: &crate::async_operations::AsyncOperation) -> Value {
json!({
"op_id": op.op_id,
"operation": op.operation,
"status": op.state,
"cancellation_requested": op.cancellation_requested,
"attempts": op.attempts,
"created_at": op.created_at,
"started_at": op.started_at,
"finished_at": op.finished_at,
"result": op.result,
"error": op.error,
})
}
async fn submit(
State(state): State<AsyncOperationsState>,
Path(operation): Path<String>,
OptionalSecurityContext(ctx): OptionalSecurityContext,
headers: HeaderMap,
Json(body): Json<SubmitBody>,
) -> Response {
let Some(ctx) = ctx else {
return error_response(
StatusCode::UNAUTHORIZED,
"async operations require an authenticated caller — the submission snapshots your \
security context for the background execution",
);
};
#[cfg(feature = "auth")]
let ctx = {
let mut ctx = ctx;
match crate::identity::resolve_request_identity(
state.app.identity_resolver.as_deref(),
Some(&mut ctx),
)
.await
{
crate::identity::EnrichmentOutcome::Proceed => ctx,
crate::identity::EnrichmentOutcome::Denied => {
return error_response(
StatusCode::FORBIDDEN,
crate::identity::EnrichmentOutcome::DENIED_MESSAGE,
);
},
crate::identity::EnrichmentOutcome::Unavailable => {
return error_response(
StatusCode::SERVICE_UNAVAILABLE,
crate::identity::EnrichmentOutcome::UNAVAILABLE_MESSAGE,
);
},
}
};
if !state.runtime.config.operations.iter().any(|o| o == &operation) {
return error_response(
StatusCode::NOT_FOUND,
"unknown async operation — the [async_operations] allowlist does not include it",
);
}
let parsed = match fraiseql_core::graphql::parse_query(&body.query) {
Ok(p) => p,
Err(e) => {
return error_response(StatusCode::BAD_REQUEST, &format!("invalid GraphQL: {e}"));
},
};
if parsed.operation_type == "subscription" {
return error_response(
StatusCode::BAD_REQUEST,
"subscriptions cannot run as async operations — they have no final result to poll",
);
}
if parsed.root_field != operation {
return error_response(
StatusCode::BAD_REQUEST,
"the document's root field does not match the operation named in the path",
);
}
let tenant_key = match tenant_dispatch::resolve_tenant_key(&state.app, Some(&ctx), &headers) {
Ok(k) => k,
Err(e) => return error_response(tenant_refusal_status(&e), &e.to_string()),
};
let idempotency = headers.get("idempotency-key").and_then(|v| v.to_str().ok()).map(|k| {
let scope = IdempotencyScope {
tenant: tenant_key.clone(),
principal: Some(ctx.user_id.to_string()),
method: "POST".to_string(),
path: format!("/operations/v1/{operation}"),
};
let body_hash = hash_body(&json!({ "query": body.query, "variables": body.variables }));
(scope.key(k), body_hash)
});
if let Some((ref key, body_hash)) = idempotency {
match state.app.idempotency_store.check(key, body_hash).await {
IdempotencyCheck::Replay(stored) => {
let status = StatusCode::from_u16(stored.status).unwrap_or(StatusCode::ACCEPTED);
return (status, Json(stored.body.unwrap_or(Value::Null))).into_response();
},
IdempotencyCheck::Conflict => {
return error_response(
StatusCode::UNPROCESSABLE_ENTITY,
"Idempotency-Key was already used with a different request body",
);
},
IdempotencyCheck::New => {},
}
}
let cost = tenant_dispatch::estimate_request_cost(
&body.query,
body.variables.as_ref(),
&state.app.executor(),
);
if let Err(e) =
tenant_dispatch::charge_cost_budget(&state.app, tenant_key.as_deref(), Some(&ctx), cost)
{
return error_response(StatusCode::TOO_MANY_REQUESTS, &e.to_string());
}
let ctx_json = match serde_json::to_value(&ctx) {
Ok(v) => v,
Err(e) => {
return error_response(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("could not snapshot the security context: {e}"),
);
},
};
let op_id = match state
.runtime
.store
.submit(
tenant_key.as_deref(),
ctx.user_id.as_str(),
&operation,
&body.query,
body.variables.as_ref(),
&ctx_json,
state.runtime.config.max_attempts,
)
.await
{
Ok(id) => id,
Err(e) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
};
let response_body = json!({ "op_id": op_id, "status": "queued" });
if let Some((key, body_hash)) = idempotency {
state
.app
.idempotency_store
.store(
key,
body_hash,
StoredResponse {
status: StatusCode::ACCEPTED.as_u16(),
headers: Vec::new(),
body: Some(response_body.clone()),
},
)
.await;
}
(StatusCode::ACCEPTED, Json(response_body)).into_response()
}
async fn status(
State(state): State<AsyncOperationsState>,
Path(op_id): Path<String>,
OptionalSecurityContext(ctx): OptionalSecurityContext,
headers: HeaderMap,
) -> Response {
let (ctx, op_id) = match caller_and_id(ctx, &op_id) {
Ok(pair) => pair,
Err(resp) => return resp,
};
let tenant_key = match tenant_dispatch::resolve_tenant_key(&state.app, Some(&ctx), &headers) {
Ok(k) => k,
Err(e) => return error_response(tenant_refusal_status(&e), &e.to_string()),
};
match state
.runtime
.store
.get_scoped(op_id, ctx.user_id.as_str(), tenant_key.as_deref())
.await
{
Ok(Some(op)) => (StatusCode::OK, Json(op_json(&op))).into_response(),
Ok(None) => error_response(StatusCode::NOT_FOUND, "no such operation"),
Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
async fn cancel(
State(state): State<AsyncOperationsState>,
Path(op_id): Path<String>,
OptionalSecurityContext(ctx): OptionalSecurityContext,
headers: HeaderMap,
) -> Response {
let (ctx, op_id) = match caller_and_id(ctx, &op_id) {
Ok(pair) => pair,
Err(resp) => return resp,
};
let tenant_key = match tenant_dispatch::resolve_tenant_key(&state.app, Some(&ctx), &headers) {
Ok(k) => k,
Err(e) => return error_response(tenant_refusal_status(&e), &e.to_string()),
};
let submitter = ctx.user_id.as_str();
let tenant = tenant_key.as_deref();
match state.runtime.store.cancel_queued(op_id, submitter, tenant).await {
Ok(true) => {
return (StatusCode::OK, Json(json!({ "op_id": op_id, "status": "cancelled" })))
.into_response();
},
Ok(false) => {},
Err(e) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
match state.runtime.store.request_cancel(op_id, submitter, tenant).await {
Ok(true) => (
StatusCode::ACCEPTED,
Json(json!({
"op_id": op_id,
"status": "cancel_requested",
"detail": "the operation is running; cancellation is honoured at the next safe \
point — poll the status to observe the outcome",
})),
)
.into_response(),
Ok(false) => match state.runtime.store.get_scoped(op_id, submitter, tenant).await {
Ok(Some(op)) => error_response(
StatusCode::CONFLICT,
&format!("operation is already terminal ({}); nothing to cancel", op.state),
),
Ok(None) => error_response(StatusCode::NOT_FOUND, "no such operation"),
Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
},
Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
#[allow(clippy::result_large_err)] fn caller_and_id(
ctx: Option<fraiseql_core::security::SecurityContext>,
op_id: &str,
) -> std::result::Result<(fraiseql_core::security::SecurityContext, Uuid), Response> {
let Some(ctx) = ctx else {
return Err(error_response(
StatusCode::UNAUTHORIZED,
"async operations require an authenticated caller",
));
};
let Ok(op_id) = Uuid::parse_str(op_id) else {
return Err(error_response(StatusCode::BAD_REQUEST, "op_id must be a UUID"));
};
Ok((ctx, op_id))
}
fn tenant_refusal_status(error: &fraiseql_error::FraiseQLError) -> StatusCode {
StatusCode::from_u16(error.status_code()).unwrap_or(StatusCode::BAD_REQUEST)
}