use aion_core::WorkflowId;
use aion_proto::{ProtoWorkflowId, WireError};
use axum::{
Json,
extract::{Path, Query, State, rejection::QueryRejection},
http::{HeaderMap, HeaderValue, StatusCode, header},
response::{IntoResponse, Response},
};
use serde::Deserialize;
use super::auth::HttpCaller;
use super::error::HttpWireError;
use crate::ServerState;
use crate::api::handlers;
const CACHE_CONTROL: &str = "private, max-age=31536000, immutable";
#[derive(Debug, Deserialize)]
pub(crate) struct RunDocumentQuery {
namespace: String,
}
pub(crate) async fn get_run_document(
State(state): State<ServerState>,
HttpCaller(caller): HttpCaller,
Path((workflow_id, content_hash)): Path<(String, String)>,
query: Result<Query<RunDocumentQuery>, QueryRejection>,
headers: HeaderMap,
) -> Result<Response, HttpWireError> {
let Query(query) = query.map_err(|rejection| {
HttpWireError(WireError::invalid_input(format!(
"the `namespace` query parameter is required: {rejection}"
)))
})?;
let workflow_id: WorkflowId = ProtoWorkflowId { uuid: workflow_id }
.try_into()
.map_err(HttpWireError)?;
let access = handlers::authorize_run_document(
state.namespace_guard(),
&caller,
&query.namespace,
&workflow_id,
&content_hash,
)
.await
.map_err(HttpWireError)?;
let etag = HeaderValue::from_str(&format!("\"{}\"", access.content_hash)).map_err(|error| {
HttpWireError(WireError::backend(format!(
"content hash is not a valid ETag: {error}"
)))
})?;
let cache_control = HeaderValue::from_static(CACHE_CONTROL);
if if_none_match_names(&headers, &etag) {
return Ok((
StatusCode::NOT_MODIFIED,
[(header::ETAG, etag), (header::CACHE_CONTROL, cache_control)],
)
.into_response());
}
let document = access.read().await.map_err(HttpWireError)?;
Ok((
StatusCode::OK,
[(header::ETAG, etag), (header::CACHE_CONTROL, cache_control)],
Json(document),
)
.into_response())
}
fn if_none_match_names(headers: &HeaderMap, etag: &HeaderValue) -> bool {
let Ok(strong) = etag.to_str() else {
return false;
};
headers
.get_all(header::IF_NONE_MATCH)
.iter()
.filter_map(|value| value.to_str().ok())
.flat_map(|value| value.split(','))
.map(str::trim)
.any(|candidate| {
candidate == "*" || candidate.strip_prefix("W/").unwrap_or(candidate) == strong
})
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use aion::EngineBuilder;
use aion_core::{Event, PackageVersion, RunId, WorkflowId};
use aion_package::AwlSource;
use aion_store::{EventStore, InMemoryStore, WriteToken};
use axum::{body, http::Request, http::StatusCode};
use tower::ServiceExt;
use super::super::router::workflow_router;
use super::super::test_support::{read_json, runtime_config, server_state};
use crate::awl::deployed::fixtures::{DOCUMENT, manifest, record};
use crate::config::{DeployConfig, NamespaceMode};
use crate::test_support::{EngineUnderTest, StateUnderTest};
use crate::{NamespaceResolver, StaticScheduleNamespaces, StaticWorkflowNamespaces};
const NAMESPACE: &str = "tenant-a";
fn workflow_id() -> WorkflowId {
WorkflowId::new(uuid::Uuid::from_u128(0x51))
}
async fn state_with_run(
started_under_archive: bool,
) -> Result<(StateUnderTest, String), Box<dyn std::error::Error>> {
let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
let engine = EngineUnderTest::new(Arc::new(
EngineBuilder::new()
.stop_drain_timeout(std::time::Duration::from_secs(5))
.store_arc(Arc::clone(&store))
.in_memory_visibility()
.scheduler_threads(1)
.build()
.await?,
));
let row = record(
manifest("fixture"),
Some(AwlSource::new(
"fixture.awl",
DOCUMENT,
std::iter::empty::<(String, Vec<u8>)>(),
)),
1_700_000_000,
)?;
let archive_hash = row.content_hash.clone();
store.put_package(row).await?;
let started_under = if started_under_archive {
archive_hash.clone()
} else {
"b".repeat(64)
};
store
.append(
WriteToken::recorder(),
&workflow_id(),
&[Event::WorkflowStarted {
envelope: aion_core::EventEnvelope {
seq: 1,
recorded_at: chrono::Utc::now(),
workflow_id: workflow_id(),
},
workflow_type: "fixture".to_owned(),
input: aion_core::Payload::from_json(&serde_json::json!({}))?,
run_id: RunId::new(uuid::Uuid::from_u128(1)),
parent_run_id: None,
parent_workflow_id: None,
package_version: PackageVersion::new(started_under.clone()),
}],
0,
)
.await?;
let ownership = StaticWorkflowNamespaces::default();
ownership.record(workflow_id(), NAMESPACE)?;
let resolver = NamespaceResolver::from_parts(
NamespaceMode::SharedEngine,
Some(engine.handle()),
Arc::new(ownership),
Arc::new(StaticScheduleNamespaces::default()),
);
let mut config = runtime_config();
config.auth.enabled = false;
config.deploy = DeployConfig {
enabled: false,
max_archive_bytes: Some(1024 * 1024),
max_inflated_bytes: Some(4 * 1024 * 1024),
};
Ok((server_state(engine, resolver, config).await?, started_under))
}
fn request(
uri: &str,
if_none_match: Option<&str>,
) -> Result<Request<body::Body>, axum::http::Error> {
let mut builder = Request::builder()
.method("GET")
.uri(uri)
.header("x-aion-subject", "operator");
if let Some(value) = if_none_match {
builder = builder.header("if-none-match", value);
}
builder.body(body::Body::empty())
}
fn document_uri(hash: &str) -> String {
format!(
"/workflows/{}/document/{hash}?namespace={NAMESPACE}",
workflow_id()
)
}
#[tokio::test]
async fn the_run_document_is_served_with_deploy_off_and_is_cacheable_forever()
-> Result<(), Box<dyn std::error::Error>> {
let (state, hash) = state_with_run(true).await?;
let router = workflow_router(state.clone());
let response = router
.clone()
.oneshot(request(&document_uri(&hash), None)?)
.await?;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response.headers().get("etag").and_then(|v| v.to_str().ok()),
Some(format!("\"{hash}\"").as_str())
);
assert_eq!(
response
.headers()
.get("cache-control")
.and_then(|v| v.to_str().ok()),
Some(super::CACHE_CONTROL)
);
let document: serde_json::Value = read_json(response).await?;
assert_eq!(document["content_hash"], hash);
assert_eq!(document["workflow_type"], "fixture");
assert_eq!(document["source"], DOCUMENT);
assert!(
document["projection"].is_object(),
"the check projection rides along: {document}"
);
let catalog = router
.clone()
.oneshot(request(&format!("/awl/deployed/fixture/{hash}"), None)?)
.await?;
assert_eq!(catalog.status(), StatusCode::NOT_FOUND);
Ok(())
}
#[tokio::test]
async fn a_conditional_request_naming_the_hash_is_not_modified()
-> Result<(), Box<dyn std::error::Error>> {
let (state, hash) = state_with_run(true).await?;
let router = workflow_router(state.clone());
for validator in [
format!("\"{hash}\""),
format!("W/\"{hash}\""),
format!("\"other\", \"{hash}\""),
"*".to_owned(),
] {
let response = router
.clone()
.oneshot(request(&document_uri(&hash), Some(&validator))?)
.await?;
assert_eq!(response.status(), StatusCode::NOT_MODIFIED, "{validator}");
assert_eq!(
response.headers().get("etag").and_then(|v| v.to_str().ok()),
Some(format!("\"{hash}\"").as_str())
);
}
let response = router
.oneshot(request(&document_uri(&hash), Some("\"stale\""))?)
.await?;
assert_eq!(response.status(), StatusCode::OK);
Ok(())
}
#[tokio::test]
async fn a_conditional_request_is_refused_before_it_can_be_not_modified()
-> Result<(), Box<dyn std::error::Error>> {
let (state, hash) = state_with_run(true).await?;
let router = workflow_router(state.clone());
let foreign = "c".repeat(64);
let response = router
.clone()
.oneshot(request(
&document_uri(&foreign),
Some(&format!("\"{foreign}\"")),
)?)
.await?;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let error: serde_json::Value = read_json(response).await?;
assert_eq!(error["error_type"], "RunPackageNotRecorded");
let response = router
.oneshot(request(
&format!(
"/workflows/{}/document/{hash}?namespace=tenant-b",
workflow_id()
),
Some(&format!("\"{hash}\"")),
)?)
.await?;
assert_ne!(response.status(), StatusCode::NOT_MODIFIED);
assert_ne!(response.status(), StatusCode::OK);
Ok(())
}
#[tokio::test]
async fn refusals_are_typed_404s_and_a_missing_namespace_is_400()
-> Result<(), Box<dyn std::error::Error>> {
let (state, hash) = state_with_run(true).await?;
let router = workflow_router(state.clone());
let foreign = "c".repeat(64);
let response = router
.clone()
.oneshot(request(&document_uri(&foreign), None)?)
.await?;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let error: serde_json::Value = read_json(response).await?;
assert_eq!(error["error_type"], "RunPackageNotRecorded");
let response = router
.clone()
.oneshot(request(
&format!("/workflows/{}/document/{hash}", workflow_id()),
None,
)?)
.await?;
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let response = router
.clone()
.oneshot(request(&document_uri("not-a-hash"), None)?)
.await?;
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let response = router
.oneshot(request(
&format!("/workflows/not-a-uuid/document/{hash}?namespace={NAMESPACE}"),
None,
)?)
.await?;
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
Ok(())
}
#[tokio::test]
async fn a_recorded_hash_with_no_archive_here_is_deployed_version_not_found()
-> Result<(), Box<dyn std::error::Error>> {
let (state, hash) = state_with_run(false).await?;
let router = workflow_router(state.clone());
let response = router.oneshot(request(&document_uri(&hash), None)?).await?;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let error: serde_json::Value = read_json(response).await?;
assert_eq!(error["error_type"], "DeployedVersionNotFound");
Ok(())
}
}