use axum::body::Body;
use axum::extract::{Path, State};
use axum::http::header;
use axum::response::{IntoResponse, Response};
use futures_util::TryStreamExt;
use ironflow_auth::extractor::Authenticated;
use ironflow_store::models::Artifact;
use uuid::Uuid;
use crate::error::ApiError;
use crate::state::AppState;
#[cfg_attr(
feature = "openapi",
utoipa::path(
get,
path = "/api/v1/runs/{id}/steps/{step_id}/artifacts/{name}",
tags = ["runs"],
params(
("id" = Uuid, Path, description = "Run ID"),
("step_id" = Uuid, Path, description = "Step ID"),
("name" = String, Path, description = "Artifact name"),
),
responses(
(status = 200, description = "Artifact content", content_type = "application/octet-stream"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Run, step or artifact not found"),
(status = 501, description = "Artifact storage is not configured")
),
security(("Bearer" = []))
)
)]
pub async fn download_artifact(
_auth: Authenticated,
State(state): State<AppState>,
Path((run_id, step_id, name)): Path<(Uuid, Uuid, String)>,
) -> Result<impl IntoResponse, ApiError> {
let blob_store = state.blob_store_or_501()?;
let artifact = state
.store
.get_artifact(step_id, &name)
.await?
.filter(|artifact| artifact.run_id == run_id)
.ok_or_else(|| ApiError::ArtifactNotFound(name.clone()))?;
let stream = blob_store
.get(&artifact.storage_key)
.await
.map_err(|err| match err {
ironflow_artifacts::error::ArtifactError::NotFound(_) => {
ApiError::ArtifactNotFound(name.clone())
}
other => ApiError::Internal(other.to_string()),
})?;
let body = Body::from_stream(stream.map_err(std::io::Error::other));
Ok(artifact_response(&artifact, body))
}
fn artifact_response(artifact: &Artifact, body: Body) -> Response {
(
[
(header::CONTENT_TYPE, artifact.content_type.clone()),
(header::CONTENT_LENGTH, artifact.size_bytes.to_string()),
(
header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{}\"", artifact.name),
),
],
body,
)
.into_response()
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use axum::Router;
use axum::body::to_bytes;
use axum::http::{Request, StatusCode};
use axum::routing::get;
use ironflow_artifacts::blob_store::BlobStore;
use ironflow_artifacts::local::LocalBlobStore;
use ironflow_artifacts::stream_from_bytes;
use ironflow_store::models::{NewArtifact, NewStep, StepKind, step_trace_id};
use tempfile::TempDir;
use tower::ServiceExt;
use super::*;
use crate::routes::test_helpers::{auth_header, create_run, test_state};
struct Fixture {
state: AppState,
run_id: Uuid,
step_id: Uuid,
_dir: TempDir,
}
async fn fixture() -> Fixture {
let dir = TempDir::new().expect("temp dir");
let blob: Arc<dyn BlobStore> = Arc::new(LocalBlobStore::new(dir.path()));
let state = test_state().with_blob_store(blob.clone());
let run = create_run(&state).await;
let step = state
.store
.create_step(NewStep {
run_id: run.id,
trace_id: step_trace_id(run.id, "build", 0),
name: "build".to_string(),
kind: StepKind::Shell,
position: 0,
input: None,
is_error_handler: false,
})
.await
.expect("create step");
let id = Uuid::now_v7();
let key = format!("artifacts/{}/{}/{id}", run.id, step.id);
let digest = blob
.put(&key, stream_from_bytes(b"<html/>".to_vec()))
.await
.expect("put blob");
state
.store
.create_artifact(NewArtifact {
id,
run_id: run.id,
step_id: step.id,
name: "report.html".to_string(),
storage_key: key,
content_type: "text/html".to_string(),
size_bytes: digest.size_bytes,
sha256: digest.sha256,
})
.await
.expect("record artifact");
Fixture {
state,
run_id: run.id,
step_id: step.id,
_dir: dir,
}
}
fn app(state: AppState) -> Router {
Router::new()
.route(
"/runs/{id}/steps/{step_id}/artifacts/{name}",
get(download_artifact),
)
.with_state(state)
}
fn request(state: &AppState, uri: String) -> Request<Body> {
Request::builder()
.uri(uri)
.header("authorization", auth_header(state))
.body(Body::empty())
.expect("request")
}
#[tokio::test]
async fn serves_the_content_with_its_recorded_type() {
let fixture = fixture().await;
let uri = format!(
"/runs/{}/steps/{}/artifacts/report.html",
fixture.run_id, fixture.step_id
);
let req = request(&fixture.state, uri);
let resp = app(fixture.state).oneshot(req).await.expect("response");
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(resp.headers()[header::CONTENT_TYPE], "text/html");
assert_eq!(resp.headers()[header::CONTENT_LENGTH], "7");
assert_eq!(
resp.headers()[header::CONTENT_DISPOSITION],
"attachment; filename=\"report.html\""
);
let body = to_bytes(resp.into_body(), usize::MAX).await.expect("body");
assert_eq!(&body[..], b"<html/>");
}
#[tokio::test]
async fn an_unknown_name_is_not_found() {
let fixture = fixture().await;
let uri = format!(
"/runs/{}/steps/{}/artifacts/nope.html",
fixture.run_id, fixture.step_id
);
let req = request(&fixture.state, uri);
let resp = app(fixture.state).oneshot(req).await.expect("response");
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn an_artifact_of_another_run_is_not_found() {
let fixture = fixture().await;
let other_run = create_run(&fixture.state).await;
let uri = format!(
"/runs/{}/steps/{}/artifacts/report.html",
other_run.id, fixture.step_id
);
let req = request(&fixture.state, uri);
let resp = app(fixture.state).oneshot(req).await.expect("response");
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn an_artifact_of_another_step_is_not_found() {
let fixture = fixture().await;
let uri = format!(
"/runs/{}/steps/{}/artifacts/report.html",
fixture.run_id,
Uuid::now_v7()
);
let req = request(&fixture.state, uri);
let resp = app(fixture.state).oneshot(req).await.expect("response");
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn an_anonymous_request_is_rejected() {
let fixture = fixture().await;
let uri = format!(
"/runs/{}/steps/{}/artifacts/report.html",
fixture.run_id, fixture.step_id
);
let req = Request::builder()
.uri(uri)
.body(Body::empty())
.expect("request");
let resp = app(fixture.state).oneshot(req).await.expect("response");
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn without_a_backend_the_route_reports_not_implemented() {
let state = test_state();
let run = create_run(&state).await;
let uri = format!(
"/runs/{}/steps/{}/artifacts/report.html",
run.id,
Uuid::now_v7()
);
let req = request(&state, uri);
let resp = app(state).oneshot(req).await.expect("response");
assert_eq!(resp.status(), StatusCode::NOT_IMPLEMENTED);
}
#[tokio::test]
async fn a_record_whose_blob_vanished_is_not_found() {
let fixture = fixture().await;
let artifact = fixture
.state
.store
.get_artifact(fixture.step_id, "report.html")
.await
.expect("get")
.expect("present");
fixture
.state
.blob_store
.as_ref()
.expect("blob store")
.delete(&artifact.storage_key)
.await
.expect("delete blob");
let uri = format!(
"/runs/{}/steps/{}/artifacts/report.html",
fixture.run_id, fixture.step_id
);
let req = request(&fixture.state, uri);
let resp = app(fixture.state).oneshot(req).await.expect("response");
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
}