WIP
This commit is contained in:
@@ -26,7 +26,7 @@ async-trait = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
|
||||
# Web framework
|
||||
axum = { workspace = true }
|
||||
axum = { workspace = true, features = ["multipart"] }
|
||||
tower = { workspace = true }
|
||||
tower-http = { workspace = true }
|
||||
|
||||
@@ -69,7 +69,6 @@ jsonschema = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
|
||||
# Authentication
|
||||
jsonwebtoken = { version = "10.2", features = ["rust_crypto"] }
|
||||
argon2 = { workspace = true }
|
||||
rand = "0.9"
|
||||
|
||||
|
||||
@@ -1,389 +1,11 @@
|
||||
//! JWT token generation and validation
|
||||
//!
|
||||
//! This module re-exports all JWT functionality from `attune_common::auth::jwt`.
|
||||
//! The canonical implementation lives in the common crate so that all services
|
||||
//! (API, worker, sensor) share the same token types and signing logic.
|
||||
|
||||
use chrono::{Duration, Utc};
|
||||
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum JwtError {
|
||||
#[error("Failed to encode JWT: {0}")]
|
||||
EncodeError(String),
|
||||
#[error("Failed to decode JWT: {0}")]
|
||||
DecodeError(String),
|
||||
#[error("Token has expired")]
|
||||
Expired,
|
||||
#[error("Invalid token")]
|
||||
Invalid,
|
||||
}
|
||||
|
||||
/// JWT Claims structure
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Claims {
|
||||
/// Subject (identity ID)
|
||||
pub sub: String,
|
||||
/// Identity login
|
||||
pub login: String,
|
||||
/// Issued at (Unix timestamp)
|
||||
pub iat: i64,
|
||||
/// Expiration time (Unix timestamp)
|
||||
pub exp: i64,
|
||||
/// Token type (access or refresh)
|
||||
#[serde(default)]
|
||||
pub token_type: TokenType,
|
||||
/// Optional scope (e.g., "sensor", "service")
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub scope: Option<String>,
|
||||
/// Optional metadata (e.g., trigger_types for sensors)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum TokenType {
|
||||
Access,
|
||||
Refresh,
|
||||
Sensor,
|
||||
}
|
||||
|
||||
impl Default for TokenType {
|
||||
fn default() -> Self {
|
||||
Self::Access
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for JWT tokens
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct JwtConfig {
|
||||
/// Secret key for signing tokens
|
||||
pub secret: String,
|
||||
/// Access token expiration duration (in seconds)
|
||||
pub access_token_expiration: i64,
|
||||
/// Refresh token expiration duration (in seconds)
|
||||
pub refresh_token_expiration: i64,
|
||||
}
|
||||
|
||||
impl Default for JwtConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
secret: "insecure_default_secret_change_in_production".to_string(),
|
||||
access_token_expiration: 3600, // 1 hour
|
||||
refresh_token_expiration: 604800, // 7 days
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a JWT access token
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `identity_id` - The identity ID
|
||||
/// * `login` - The identity login
|
||||
/// * `config` - JWT configuration
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Result<String, JwtError>` - The encoded JWT token
|
||||
pub fn generate_access_token(
|
||||
identity_id: i64,
|
||||
login: &str,
|
||||
config: &JwtConfig,
|
||||
) -> Result<String, JwtError> {
|
||||
generate_token(identity_id, login, config, TokenType::Access)
|
||||
}
|
||||
|
||||
/// Generate a JWT refresh token
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `identity_id` - The identity ID
|
||||
/// * `login` - The identity login
|
||||
/// * `config` - JWT configuration
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Result<String, JwtError>` - The encoded JWT token
|
||||
pub fn generate_refresh_token(
|
||||
identity_id: i64,
|
||||
login: &str,
|
||||
config: &JwtConfig,
|
||||
) -> Result<String, JwtError> {
|
||||
generate_token(identity_id, login, config, TokenType::Refresh)
|
||||
}
|
||||
|
||||
/// Generate a JWT token
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `identity_id` - The identity ID
|
||||
/// * `login` - The identity login
|
||||
/// * `config` - JWT configuration
|
||||
/// * `token_type` - Type of token to generate
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Result<String, JwtError>` - The encoded JWT token
|
||||
pub fn generate_token(
|
||||
identity_id: i64,
|
||||
login: &str,
|
||||
config: &JwtConfig,
|
||||
token_type: TokenType,
|
||||
) -> Result<String, JwtError> {
|
||||
let now = Utc::now();
|
||||
let expiration = match token_type {
|
||||
TokenType::Access => config.access_token_expiration,
|
||||
TokenType::Refresh => config.refresh_token_expiration,
|
||||
TokenType::Sensor => 86400, // Sensor tokens handled separately via generate_sensor_token()
|
||||
};
|
||||
|
||||
let exp = (now + Duration::seconds(expiration)).timestamp();
|
||||
|
||||
let claims = Claims {
|
||||
sub: identity_id.to_string(),
|
||||
login: login.to_string(),
|
||||
iat: now.timestamp(),
|
||||
exp,
|
||||
token_type,
|
||||
scope: None,
|
||||
metadata: None,
|
||||
};
|
||||
|
||||
encode(
|
||||
&Header::default(),
|
||||
&claims,
|
||||
&EncodingKey::from_secret(config.secret.as_bytes()),
|
||||
)
|
||||
.map_err(|e| JwtError::EncodeError(e.to_string()))
|
||||
}
|
||||
|
||||
/// Generate a sensor token with specific trigger types
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `identity_id` - The identity ID for the sensor
|
||||
/// * `sensor_ref` - The sensor reference (e.g., "sensor:core.timer")
|
||||
/// * `trigger_types` - List of trigger types this sensor can create events for
|
||||
/// * `config` - JWT configuration
|
||||
/// * `ttl_seconds` - Time to live in seconds (default: 24 hours)
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Result<String, JwtError>` - The encoded JWT token
|
||||
pub fn generate_sensor_token(
|
||||
identity_id: i64,
|
||||
sensor_ref: &str,
|
||||
trigger_types: Vec<String>,
|
||||
config: &JwtConfig,
|
||||
ttl_seconds: Option<i64>,
|
||||
) -> Result<String, JwtError> {
|
||||
let now = Utc::now();
|
||||
let expiration = ttl_seconds.unwrap_or(86400); // Default: 24 hours
|
||||
let exp = (now + Duration::seconds(expiration)).timestamp();
|
||||
|
||||
let metadata = serde_json::json!({
|
||||
"trigger_types": trigger_types,
|
||||
});
|
||||
|
||||
let claims = Claims {
|
||||
sub: identity_id.to_string(),
|
||||
login: sensor_ref.to_string(),
|
||||
iat: now.timestamp(),
|
||||
exp,
|
||||
token_type: TokenType::Sensor,
|
||||
scope: Some("sensor".to_string()),
|
||||
metadata: Some(metadata),
|
||||
};
|
||||
|
||||
encode(
|
||||
&Header::default(),
|
||||
&claims,
|
||||
&EncodingKey::from_secret(config.secret.as_bytes()),
|
||||
)
|
||||
.map_err(|e| JwtError::EncodeError(e.to_string()))
|
||||
}
|
||||
|
||||
/// Validate and decode a JWT token
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `token` - The JWT token string
|
||||
/// * `config` - JWT configuration
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Result<Claims, JwtError>` - The decoded claims if valid
|
||||
pub fn validate_token(token: &str, config: &JwtConfig) -> Result<Claims, JwtError> {
|
||||
let validation = Validation::default();
|
||||
|
||||
decode::<Claims>(
|
||||
token,
|
||||
&DecodingKey::from_secret(config.secret.as_bytes()),
|
||||
&validation,
|
||||
)
|
||||
.map(|data| data.claims)
|
||||
.map_err(|e| {
|
||||
if e.to_string().contains("ExpiredSignature") {
|
||||
JwtError::Expired
|
||||
} else {
|
||||
JwtError::DecodeError(e.to_string())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Extract token from Authorization header
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `auth_header` - The Authorization header value
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Option<&str>` - The token if present and valid format
|
||||
pub fn extract_token_from_header(auth_header: &str) -> Option<&str> {
|
||||
if auth_header.starts_with("Bearer ") {
|
||||
Some(&auth_header[7..])
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_config() -> JwtConfig {
|
||||
JwtConfig {
|
||||
secret: "test_secret_key_for_testing".to_string(),
|
||||
access_token_expiration: 3600,
|
||||
refresh_token_expiration: 604800,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_and_validate_access_token() {
|
||||
let config = test_config();
|
||||
let token =
|
||||
generate_access_token(123, "testuser", &config).expect("Failed to generate token");
|
||||
|
||||
let claims = validate_token(&token, &config).expect("Failed to validate token");
|
||||
|
||||
assert_eq!(claims.sub, "123");
|
||||
assert_eq!(claims.login, "testuser");
|
||||
assert_eq!(claims.token_type, TokenType::Access);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_and_validate_refresh_token() {
|
||||
let config = test_config();
|
||||
let token =
|
||||
generate_refresh_token(456, "anotheruser", &config).expect("Failed to generate token");
|
||||
|
||||
let claims = validate_token(&token, &config).expect("Failed to validate token");
|
||||
|
||||
assert_eq!(claims.sub, "456");
|
||||
assert_eq!(claims.login, "anotheruser");
|
||||
assert_eq!(claims.token_type, TokenType::Refresh);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_token() {
|
||||
let config = test_config();
|
||||
let result = validate_token("invalid.token.here", &config);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_token_with_wrong_secret() {
|
||||
let config = test_config();
|
||||
let token = generate_access_token(789, "user", &config).expect("Failed to generate token");
|
||||
|
||||
let wrong_config = JwtConfig {
|
||||
secret: "different_secret".to_string(),
|
||||
..config
|
||||
};
|
||||
|
||||
let result = validate_token(&token, &wrong_config);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expired_token() {
|
||||
// Create a token that's already expired by setting exp in the past
|
||||
let now = Utc::now().timestamp();
|
||||
let expired_claims = Claims {
|
||||
sub: "999".to_string(),
|
||||
login: "expireduser".to_string(),
|
||||
iat: now - 3600,
|
||||
exp: now - 1800, // Expired 30 minutes ago
|
||||
token_type: TokenType::Access,
|
||||
scope: None,
|
||||
metadata: None,
|
||||
};
|
||||
|
||||
let config = test_config();
|
||||
|
||||
let expired_token = encode(
|
||||
&Header::default(),
|
||||
&expired_claims,
|
||||
&EncodingKey::from_secret(config.secret.as_bytes()),
|
||||
)
|
||||
.expect("Failed to encode token");
|
||||
|
||||
// Validate the expired token
|
||||
let result = validate_token(&expired_token, &config);
|
||||
assert!(matches!(result, Err(JwtError::Expired)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_token_from_header() {
|
||||
let header = "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9";
|
||||
let token = extract_token_from_header(header);
|
||||
assert_eq!(token, Some("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"));
|
||||
|
||||
let invalid_header = "Token abc123";
|
||||
let token = extract_token_from_header(invalid_header);
|
||||
assert_eq!(token, None);
|
||||
|
||||
let no_token = "Bearer ";
|
||||
let token = extract_token_from_header(no_token);
|
||||
assert_eq!(token, Some(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_claims_serialization() {
|
||||
let claims = Claims {
|
||||
sub: "123".to_string(),
|
||||
login: "testuser".to_string(),
|
||||
iat: 1234567890,
|
||||
exp: 1234571490,
|
||||
token_type: TokenType::Access,
|
||||
scope: None,
|
||||
metadata: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&claims).expect("Failed to serialize");
|
||||
let deserialized: Claims = serde_json::from_str(&json).expect("Failed to deserialize");
|
||||
|
||||
assert_eq!(claims.sub, deserialized.sub);
|
||||
assert_eq!(claims.login, deserialized.login);
|
||||
assert_eq!(claims.token_type, deserialized.token_type);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_sensor_token() {
|
||||
let config = test_config();
|
||||
let trigger_types = vec!["core.timer".to_string(), "core.webhook".to_string()];
|
||||
|
||||
let token = generate_sensor_token(
|
||||
999,
|
||||
"sensor:core.timer",
|
||||
trigger_types.clone(),
|
||||
&config,
|
||||
Some(86400),
|
||||
)
|
||||
.expect("Failed to generate sensor token");
|
||||
|
||||
let claims = validate_token(&token, &config).expect("Failed to validate token");
|
||||
|
||||
assert_eq!(claims.sub, "999");
|
||||
assert_eq!(claims.login, "sensor:core.timer");
|
||||
assert_eq!(claims.token_type, TokenType::Sensor);
|
||||
assert_eq!(claims.scope, Some("sensor".to_string()));
|
||||
|
||||
let metadata = claims.metadata.expect("Metadata should be present");
|
||||
let trigger_types_from_token = metadata["trigger_types"]
|
||||
.as_array()
|
||||
.expect("trigger_types should be an array");
|
||||
|
||||
assert_eq!(trigger_types_from_token.len(), 2);
|
||||
}
|
||||
}
|
||||
pub use attune_common::auth::jwt::{
|
||||
extract_token_from_header, generate_access_token, generate_execution_token,
|
||||
generate_refresh_token, generate_sensor_token, generate_token, validate_token, Claims,
|
||||
JwtConfig, JwtError, TokenType,
|
||||
};
|
||||
|
||||
@@ -10,7 +10,9 @@ use axum::{
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::jwt::{extract_token_from_header, validate_token, Claims, JwtConfig, TokenType};
|
||||
use attune_common::auth::jwt::{
|
||||
extract_token_from_header, validate_token, Claims, JwtConfig, TokenType,
|
||||
};
|
||||
|
||||
/// Authentication middleware state
|
||||
#[derive(Clone)]
|
||||
@@ -105,8 +107,11 @@ impl axum::extract::FromRequestParts<crate::state::SharedState> for RequireAuth
|
||||
_ => AuthError::InvalidToken,
|
||||
})?;
|
||||
|
||||
// Allow both access tokens and sensor tokens
|
||||
if claims.token_type != TokenType::Access && claims.token_type != TokenType::Sensor {
|
||||
// Allow access, sensor, and execution-scoped tokens
|
||||
if claims.token_type != TokenType::Access
|
||||
&& claims.token_type != TokenType::Sensor
|
||||
&& claims.token_type != TokenType::Execution
|
||||
{
|
||||
return Err(AuthError::InvalidToken);
|
||||
}
|
||||
|
||||
@@ -154,7 +159,7 @@ mod tests {
|
||||
login: "testuser".to_string(),
|
||||
iat: 1234567890,
|
||||
exp: 1234571490,
|
||||
token_type: super::super::jwt::TokenType::Access,
|
||||
token_type: TokenType::Access,
|
||||
scope: None,
|
||||
metadata: None,
|
||||
};
|
||||
|
||||
471
crates/api/src/dto/artifact.rs
Normal file
471
crates/api/src/dto/artifact.rs
Normal file
@@ -0,0 +1,471 @@
|
||||
//! Artifact DTOs for API requests and responses
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value as JsonValue;
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
|
||||
use attune_common::models::enums::{ArtifactType, OwnerType, RetentionPolicyType};
|
||||
|
||||
// ============================================================================
|
||||
// Artifact DTOs
|
||||
// ============================================================================
|
||||
|
||||
/// Request DTO for creating a new artifact
|
||||
#[derive(Debug, Clone, Deserialize, ToSchema)]
|
||||
pub struct CreateArtifactRequest {
|
||||
/// Artifact reference (unique identifier, e.g. "build.log", "test.results")
|
||||
#[schema(example = "mypack.build_log")]
|
||||
pub r#ref: String,
|
||||
|
||||
/// Owner scope type
|
||||
#[schema(example = "action")]
|
||||
pub scope: OwnerType,
|
||||
|
||||
/// Owner identifier (ref string of the owning entity)
|
||||
#[schema(example = "mypack.deploy")]
|
||||
pub owner: String,
|
||||
|
||||
/// Artifact type
|
||||
#[schema(example = "file_text")]
|
||||
pub r#type: ArtifactType,
|
||||
|
||||
/// Retention policy type
|
||||
#[serde(default = "default_retention_policy")]
|
||||
#[schema(example = "versions")]
|
||||
pub retention_policy: RetentionPolicyType,
|
||||
|
||||
/// Retention limit (number of versions, days, hours, or minutes depending on policy)
|
||||
#[serde(default = "default_retention_limit")]
|
||||
#[schema(example = 5)]
|
||||
pub retention_limit: i32,
|
||||
|
||||
/// Human-readable name
|
||||
#[schema(example = "Build Log")]
|
||||
pub name: Option<String>,
|
||||
|
||||
/// Optional description
|
||||
#[schema(example = "Output log from the build action")]
|
||||
pub description: Option<String>,
|
||||
|
||||
/// MIME content type (e.g. "text/plain", "application/json")
|
||||
#[schema(example = "text/plain")]
|
||||
pub content_type: Option<String>,
|
||||
|
||||
/// Execution ID that produced this artifact
|
||||
#[schema(example = 42)]
|
||||
pub execution: Option<i64>,
|
||||
|
||||
/// Initial structured data (for progress-type artifacts or metadata)
|
||||
#[schema(value_type = Option<Object>)]
|
||||
pub data: Option<JsonValue>,
|
||||
}
|
||||
|
||||
fn default_retention_policy() -> RetentionPolicyType {
|
||||
RetentionPolicyType::Versions
|
||||
}
|
||||
|
||||
fn default_retention_limit() -> i32 {
|
||||
5
|
||||
}
|
||||
|
||||
/// Request DTO for updating an existing artifact
|
||||
#[derive(Debug, Clone, Deserialize, ToSchema)]
|
||||
pub struct UpdateArtifactRequest {
|
||||
/// Updated owner scope
|
||||
pub scope: Option<OwnerType>,
|
||||
|
||||
/// Updated owner identifier
|
||||
pub owner: Option<String>,
|
||||
|
||||
/// Updated artifact type
|
||||
pub r#type: Option<ArtifactType>,
|
||||
|
||||
/// Updated retention policy
|
||||
pub retention_policy: Option<RetentionPolicyType>,
|
||||
|
||||
/// Updated retention limit
|
||||
pub retention_limit: Option<i32>,
|
||||
|
||||
/// Updated name
|
||||
pub name: Option<String>,
|
||||
|
||||
/// Updated description
|
||||
pub description: Option<String>,
|
||||
|
||||
/// Updated content type
|
||||
pub content_type: Option<String>,
|
||||
|
||||
/// Updated structured data (replaces existing data entirely)
|
||||
pub data: Option<JsonValue>,
|
||||
}
|
||||
|
||||
/// Request DTO for appending to a progress-type artifact
|
||||
#[derive(Debug, Clone, Deserialize, ToSchema)]
|
||||
pub struct AppendProgressRequest {
|
||||
/// The entry to append to the progress data array.
|
||||
/// Can be any JSON value (string, object, number, etc.)
|
||||
#[schema(value_type = Object, example = json!({"step": "compile", "status": "done", "duration_ms": 1234}))]
|
||||
pub entry: JsonValue,
|
||||
}
|
||||
|
||||
/// Request DTO for setting the full data payload on an artifact
|
||||
#[derive(Debug, Clone, Deserialize, ToSchema)]
|
||||
pub struct SetDataRequest {
|
||||
/// The data to set (replaces existing data entirely)
|
||||
#[schema(value_type = Object)]
|
||||
pub data: JsonValue,
|
||||
}
|
||||
|
||||
/// Response DTO for artifact information
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct ArtifactResponse {
|
||||
/// Artifact ID
|
||||
#[schema(example = 1)]
|
||||
pub id: i64,
|
||||
|
||||
/// Artifact reference
|
||||
#[schema(example = "mypack.build_log")]
|
||||
pub r#ref: String,
|
||||
|
||||
/// Owner scope type
|
||||
pub scope: OwnerType,
|
||||
|
||||
/// Owner identifier
|
||||
#[schema(example = "mypack.deploy")]
|
||||
pub owner: String,
|
||||
|
||||
/// Artifact type
|
||||
pub r#type: ArtifactType,
|
||||
|
||||
/// Retention policy
|
||||
pub retention_policy: RetentionPolicyType,
|
||||
|
||||
/// Retention limit
|
||||
#[schema(example = 5)]
|
||||
pub retention_limit: i32,
|
||||
|
||||
/// Human-readable name
|
||||
#[schema(example = "Build Log")]
|
||||
pub name: Option<String>,
|
||||
|
||||
/// Description
|
||||
pub description: Option<String>,
|
||||
|
||||
/// MIME content type
|
||||
#[schema(example = "text/plain")]
|
||||
pub content_type: Option<String>,
|
||||
|
||||
/// Size of the latest version in bytes
|
||||
pub size_bytes: Option<i64>,
|
||||
|
||||
/// Execution that produced this artifact
|
||||
pub execution: Option<i64>,
|
||||
|
||||
/// Structured data (progress entries, metadata, etc.)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub data: Option<JsonValue>,
|
||||
|
||||
/// Creation timestamp
|
||||
pub created: DateTime<Utc>,
|
||||
|
||||
/// Last update timestamp
|
||||
pub updated: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Simplified artifact for list endpoints
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct ArtifactSummary {
|
||||
/// Artifact ID
|
||||
pub id: i64,
|
||||
|
||||
/// Artifact reference
|
||||
pub r#ref: String,
|
||||
|
||||
/// Artifact type
|
||||
pub r#type: ArtifactType,
|
||||
|
||||
/// Human-readable name
|
||||
pub name: Option<String>,
|
||||
|
||||
/// MIME content type
|
||||
pub content_type: Option<String>,
|
||||
|
||||
/// Size of latest version in bytes
|
||||
pub size_bytes: Option<i64>,
|
||||
|
||||
/// Execution that produced this artifact
|
||||
pub execution: Option<i64>,
|
||||
|
||||
/// Owner scope
|
||||
pub scope: OwnerType,
|
||||
|
||||
/// Owner identifier
|
||||
pub owner: String,
|
||||
|
||||
/// Creation timestamp
|
||||
pub created: DateTime<Utc>,
|
||||
|
||||
/// Last update timestamp
|
||||
pub updated: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Query parameters for filtering artifacts
|
||||
#[derive(Debug, Clone, Deserialize, IntoParams)]
|
||||
pub struct ArtifactQueryParams {
|
||||
/// Filter by owner scope type
|
||||
pub scope: Option<OwnerType>,
|
||||
|
||||
/// Filter by owner identifier
|
||||
pub owner: Option<String>,
|
||||
|
||||
/// Filter by artifact type
|
||||
pub r#type: Option<ArtifactType>,
|
||||
|
||||
/// Filter by execution ID
|
||||
pub execution: Option<i64>,
|
||||
|
||||
/// Search by name (case-insensitive substring match)
|
||||
pub name: Option<String>,
|
||||
|
||||
/// Page number (1-based)
|
||||
#[serde(default = "default_page")]
|
||||
#[param(example = 1, minimum = 1)]
|
||||
pub page: u32,
|
||||
|
||||
/// Items per page
|
||||
#[serde(default = "default_per_page")]
|
||||
#[param(example = 20, minimum = 1, maximum = 100)]
|
||||
pub per_page: u32,
|
||||
}
|
||||
|
||||
impl ArtifactQueryParams {
|
||||
pub fn offset(&self) -> u32 {
|
||||
(self.page.saturating_sub(1)) * self.per_page
|
||||
}
|
||||
|
||||
pub fn limit(&self) -> u32 {
|
||||
self.per_page.min(100)
|
||||
}
|
||||
}
|
||||
|
||||
fn default_page() -> u32 {
|
||||
1
|
||||
}
|
||||
|
||||
fn default_per_page() -> u32 {
|
||||
20
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ArtifactVersion DTOs
|
||||
// ============================================================================
|
||||
|
||||
/// Request DTO for creating a new artifact version with JSON content
|
||||
#[derive(Debug, Clone, Deserialize, ToSchema)]
|
||||
pub struct CreateVersionJsonRequest {
|
||||
/// Structured JSON content for this version
|
||||
#[schema(value_type = Object)]
|
||||
pub content: JsonValue,
|
||||
|
||||
/// MIME content type override (defaults to "application/json")
|
||||
pub content_type: Option<String>,
|
||||
|
||||
/// Free-form metadata about this version
|
||||
#[schema(value_type = Option<Object>)]
|
||||
pub meta: Option<JsonValue>,
|
||||
|
||||
/// Who created this version (e.g. action ref, identity, "system")
|
||||
pub created_by: Option<String>,
|
||||
}
|
||||
|
||||
/// Response DTO for an artifact version (without binary content)
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct ArtifactVersionResponse {
|
||||
/// Version ID
|
||||
pub id: i64,
|
||||
|
||||
/// Parent artifact ID
|
||||
pub artifact: i64,
|
||||
|
||||
/// Version number (1-based)
|
||||
pub version: i32,
|
||||
|
||||
/// MIME content type
|
||||
pub content_type: Option<String>,
|
||||
|
||||
/// Size of content in bytes
|
||||
pub size_bytes: Option<i64>,
|
||||
|
||||
/// Structured JSON content (if this version has JSON data)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub content_json: Option<JsonValue>,
|
||||
|
||||
/// Free-form metadata
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub meta: Option<JsonValue>,
|
||||
|
||||
/// Who created this version
|
||||
pub created_by: Option<String>,
|
||||
|
||||
/// Creation timestamp
|
||||
pub created: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Simplified version for list endpoints
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct ArtifactVersionSummary {
|
||||
/// Version ID
|
||||
pub id: i64,
|
||||
|
||||
/// Version number
|
||||
pub version: i32,
|
||||
|
||||
/// MIME content type
|
||||
pub content_type: Option<String>,
|
||||
|
||||
/// Size of content in bytes
|
||||
pub size_bytes: Option<i64>,
|
||||
|
||||
/// Who created this version
|
||||
pub created_by: Option<String>,
|
||||
|
||||
/// Creation timestamp
|
||||
pub created: DateTime<Utc>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Conversions
|
||||
// ============================================================================
|
||||
|
||||
impl From<attune_common::models::artifact::Artifact> for ArtifactResponse {
|
||||
fn from(a: attune_common::models::artifact::Artifact) -> Self {
|
||||
Self {
|
||||
id: a.id,
|
||||
r#ref: a.r#ref,
|
||||
scope: a.scope,
|
||||
owner: a.owner,
|
||||
r#type: a.r#type,
|
||||
retention_policy: a.retention_policy,
|
||||
retention_limit: a.retention_limit,
|
||||
name: a.name,
|
||||
description: a.description,
|
||||
content_type: a.content_type,
|
||||
size_bytes: a.size_bytes,
|
||||
execution: a.execution,
|
||||
data: a.data,
|
||||
created: a.created,
|
||||
updated: a.updated,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<attune_common::models::artifact::Artifact> for ArtifactSummary {
|
||||
fn from(a: attune_common::models::artifact::Artifact) -> Self {
|
||||
Self {
|
||||
id: a.id,
|
||||
r#ref: a.r#ref,
|
||||
r#type: a.r#type,
|
||||
name: a.name,
|
||||
content_type: a.content_type,
|
||||
size_bytes: a.size_bytes,
|
||||
execution: a.execution,
|
||||
scope: a.scope,
|
||||
owner: a.owner,
|
||||
created: a.created,
|
||||
updated: a.updated,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<attune_common::models::artifact_version::ArtifactVersion> for ArtifactVersionResponse {
|
||||
fn from(v: attune_common::models::artifact_version::ArtifactVersion) -> Self {
|
||||
Self {
|
||||
id: v.id,
|
||||
artifact: v.artifact,
|
||||
version: v.version,
|
||||
content_type: v.content_type,
|
||||
size_bytes: v.size_bytes,
|
||||
content_json: v.content_json,
|
||||
meta: v.meta,
|
||||
created_by: v.created_by,
|
||||
created: v.created,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<attune_common::models::artifact_version::ArtifactVersion> for ArtifactVersionSummary {
|
||||
fn from(v: attune_common::models::artifact_version::ArtifactVersion) -> Self {
|
||||
Self {
|
||||
id: v.id,
|
||||
version: v.version,
|
||||
content_type: v.content_type,
|
||||
size_bytes: v.size_bytes,
|
||||
created_by: v.created_by,
|
||||
created: v.created,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_query_params_defaults() {
|
||||
let json = r#"{}"#;
|
||||
let params: ArtifactQueryParams = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(params.page, 1);
|
||||
assert_eq!(params.per_page, 20);
|
||||
assert!(params.scope.is_none());
|
||||
assert!(params.r#type.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_query_params_offset() {
|
||||
let params = ArtifactQueryParams {
|
||||
scope: None,
|
||||
owner: None,
|
||||
r#type: None,
|
||||
execution: None,
|
||||
name: None,
|
||||
page: 3,
|
||||
per_page: 20,
|
||||
};
|
||||
assert_eq!(params.offset(), 40);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_query_params_limit_cap() {
|
||||
let params = ArtifactQueryParams {
|
||||
scope: None,
|
||||
owner: None,
|
||||
r#type: None,
|
||||
execution: None,
|
||||
name: None,
|
||||
page: 1,
|
||||
per_page: 200,
|
||||
};
|
||||
assert_eq!(params.limit(), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_request_defaults() {
|
||||
let json = r#"{
|
||||
"ref": "test.artifact",
|
||||
"scope": "system",
|
||||
"owner": "",
|
||||
"type": "file_text"
|
||||
}"#;
|
||||
let req: CreateArtifactRequest = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(req.retention_policy, RetentionPolicyType::Versions);
|
||||
assert_eq!(req.retention_limit, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_append_progress_request() {
|
||||
let json = r#"{"entry": {"step": "build", "status": "done"}}"#;
|
||||
let req: AppendProgressRequest = serde_json::from_str(json).unwrap();
|
||||
assert!(req.entry.is_object());
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
pub mod action;
|
||||
pub mod analytics;
|
||||
pub mod artifact;
|
||||
pub mod auth;
|
||||
pub mod common;
|
||||
pub mod event;
|
||||
@@ -21,6 +22,11 @@ pub use analytics::{
|
||||
ExecutionStatusTimeSeriesResponse, ExecutionThroughputResponse, FailureRateResponse,
|
||||
TimeSeriesPoint,
|
||||
};
|
||||
pub use artifact::{
|
||||
AppendProgressRequest, ArtifactQueryParams, ArtifactResponse, ArtifactSummary,
|
||||
ArtifactVersionResponse, ArtifactVersionSummary, CreateArtifactRequest,
|
||||
CreateVersionJsonRequest, SetDataRequest, UpdateArtifactRequest,
|
||||
};
|
||||
pub use auth::{
|
||||
ChangePasswordRequest, CurrentUserResponse, LoginRequest, RefreshTokenRequest, RegisterRequest,
|
||||
TokenResponse,
|
||||
|
||||
978
crates/api/src/routes/artifacts.rs
Normal file
978
crates/api/src/routes/artifacts.rs
Normal file
@@ -0,0 +1,978 @@
|
||||
//! Artifact management API routes
|
||||
//!
|
||||
//! Provides endpoints for:
|
||||
//! - CRUD operations on artifacts (metadata + data)
|
||||
//! - File upload (binary) and download for file-type artifacts
|
||||
//! - JSON content versioning for structured artifacts
|
||||
//! - Progress append for progress-type artifacts (streaming updates)
|
||||
//! - Listing artifacts by execution
|
||||
//! - Version history and retrieval
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::{Multipart, Path, Query, State},
|
||||
http::{header, StatusCode},
|
||||
response::IntoResponse,
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
use attune_common::models::enums::ArtifactType;
|
||||
use attune_common::repositories::{
|
||||
artifact::{
|
||||
ArtifactRepository, ArtifactSearchFilters, ArtifactVersionRepository, CreateArtifactInput,
|
||||
CreateArtifactVersionInput, UpdateArtifactInput,
|
||||
},
|
||||
Create, Delete, FindById, FindByRef, Update,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
auth::middleware::RequireAuth,
|
||||
dto::{
|
||||
artifact::{
|
||||
AppendProgressRequest, ArtifactQueryParams, ArtifactResponse, ArtifactSummary,
|
||||
ArtifactVersionResponse, ArtifactVersionSummary, CreateArtifactRequest,
|
||||
CreateVersionJsonRequest, SetDataRequest, UpdateArtifactRequest,
|
||||
},
|
||||
common::{PaginatedResponse, PaginationParams},
|
||||
ApiResponse, SuccessResponse,
|
||||
},
|
||||
middleware::{ApiError, ApiResult},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Artifact CRUD
|
||||
// ============================================================================
|
||||
|
||||
/// List artifacts with pagination and optional filters
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/artifacts",
|
||||
tag = "artifacts",
|
||||
params(ArtifactQueryParams),
|
||||
responses(
|
||||
(status = 200, description = "List of artifacts", body = PaginatedResponse<ArtifactSummary>),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn list_artifacts(
|
||||
RequireAuth(_user): RequireAuth,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(query): Query<ArtifactQueryParams>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
let filters = ArtifactSearchFilters {
|
||||
scope: query.scope,
|
||||
owner: query.owner.clone(),
|
||||
r#type: query.r#type,
|
||||
execution: query.execution,
|
||||
name_contains: query.name.clone(),
|
||||
limit: query.limit(),
|
||||
offset: query.offset(),
|
||||
};
|
||||
|
||||
let result = ArtifactRepository::search(&state.db, &filters).await?;
|
||||
|
||||
let items: Vec<ArtifactSummary> = result.rows.into_iter().map(ArtifactSummary::from).collect();
|
||||
|
||||
let pagination = PaginationParams {
|
||||
page: query.page,
|
||||
page_size: query.per_page,
|
||||
};
|
||||
|
||||
let response = PaginatedResponse::new(items, &pagination, result.total as u64);
|
||||
Ok((StatusCode::OK, Json(response)))
|
||||
}
|
||||
|
||||
/// Get a single artifact by ID
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/artifacts/{id}",
|
||||
tag = "artifacts",
|
||||
params(("id" = i64, Path, description = "Artifact ID")),
|
||||
responses(
|
||||
(status = 200, description = "Artifact details", body = inline(ApiResponse<ArtifactResponse>)),
|
||||
(status = 404, description = "Artifact not found"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn get_artifact(
|
||||
RequireAuth(_user): RequireAuth,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<i64>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
let artifact = ArtifactRepository::find_by_id(&state.db, id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(ApiResponse::new(ArtifactResponse::from(artifact))),
|
||||
))
|
||||
}
|
||||
|
||||
/// Get a single artifact by ref
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/artifacts/ref/{ref}",
|
||||
tag = "artifacts",
|
||||
params(("ref" = String, Path, description = "Artifact reference")),
|
||||
responses(
|
||||
(status = 200, description = "Artifact details", body = inline(ApiResponse<ArtifactResponse>)),
|
||||
(status = 404, description = "Artifact not found"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn get_artifact_by_ref(
|
||||
RequireAuth(_user): RequireAuth,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(artifact_ref): Path<String>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
let artifact = ArtifactRepository::find_by_ref(&state.db, &artifact_ref)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Artifact '{}' not found", artifact_ref)))?;
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(ApiResponse::new(ArtifactResponse::from(artifact))),
|
||||
))
|
||||
}
|
||||
|
||||
/// Create a new artifact
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/artifacts",
|
||||
tag = "artifacts",
|
||||
request_body = CreateArtifactRequest,
|
||||
responses(
|
||||
(status = 201, description = "Artifact created", body = inline(ApiResponse<ArtifactResponse>)),
|
||||
(status = 400, description = "Validation error"),
|
||||
(status = 409, description = "Artifact with same ref already exists"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn create_artifact(
|
||||
RequireAuth(_user): RequireAuth,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(request): Json<CreateArtifactRequest>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// Validate ref is not empty
|
||||
if request.r#ref.trim().is_empty() {
|
||||
return Err(ApiError::BadRequest(
|
||||
"Artifact ref must not be empty".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Check for duplicate ref
|
||||
if ArtifactRepository::find_by_ref(&state.db, &request.r#ref)
|
||||
.await?
|
||||
.is_some()
|
||||
{
|
||||
return Err(ApiError::Conflict(format!(
|
||||
"Artifact with ref '{}' already exists",
|
||||
request.r#ref
|
||||
)));
|
||||
}
|
||||
|
||||
let input = CreateArtifactInput {
|
||||
r#ref: request.r#ref,
|
||||
scope: request.scope,
|
||||
owner: request.owner,
|
||||
r#type: request.r#type,
|
||||
retention_policy: request.retention_policy,
|
||||
retention_limit: request.retention_limit,
|
||||
name: request.name,
|
||||
description: request.description,
|
||||
content_type: request.content_type,
|
||||
execution: request.execution,
|
||||
data: request.data,
|
||||
};
|
||||
|
||||
let artifact = ArtifactRepository::create(&state.db, input).await?;
|
||||
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(ApiResponse::with_message(
|
||||
ArtifactResponse::from(artifact),
|
||||
"Artifact created successfully",
|
||||
)),
|
||||
))
|
||||
}
|
||||
|
||||
/// Update an existing artifact
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/api/v1/artifacts/{id}",
|
||||
tag = "artifacts",
|
||||
params(("id" = i64, Path, description = "Artifact ID")),
|
||||
request_body = UpdateArtifactRequest,
|
||||
responses(
|
||||
(status = 200, description = "Artifact updated", body = inline(ApiResponse<ArtifactResponse>)),
|
||||
(status = 404, description = "Artifact not found"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn update_artifact(
|
||||
RequireAuth(_user): RequireAuth,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<i64>,
|
||||
Json(request): Json<UpdateArtifactRequest>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// Verify artifact exists
|
||||
ArtifactRepository::find_by_id(&state.db, id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
|
||||
|
||||
let input = UpdateArtifactInput {
|
||||
r#ref: None, // Ref is immutable after creation
|
||||
scope: request.scope,
|
||||
owner: request.owner,
|
||||
r#type: request.r#type,
|
||||
retention_policy: request.retention_policy,
|
||||
retention_limit: request.retention_limit,
|
||||
name: request.name,
|
||||
description: request.description,
|
||||
content_type: request.content_type,
|
||||
size_bytes: None, // Managed by version creation trigger
|
||||
data: request.data,
|
||||
};
|
||||
|
||||
let updated = ArtifactRepository::update(&state.db, id, input).await?;
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(ApiResponse::with_message(
|
||||
ArtifactResponse::from(updated),
|
||||
"Artifact updated successfully",
|
||||
)),
|
||||
))
|
||||
}
|
||||
|
||||
/// Delete an artifact (cascades to all versions)
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/v1/artifacts/{id}",
|
||||
tag = "artifacts",
|
||||
params(("id" = i64, Path, description = "Artifact ID")),
|
||||
responses(
|
||||
(status = 200, description = "Artifact deleted", body = SuccessResponse),
|
||||
(status = 404, description = "Artifact not found"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn delete_artifact(
|
||||
RequireAuth(_user): RequireAuth,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<i64>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
let deleted = ArtifactRepository::delete(&state.db, id).await?;
|
||||
if !deleted {
|
||||
return Err(ApiError::NotFound(format!(
|
||||
"Artifact with ID {} not found",
|
||||
id
|
||||
)));
|
||||
}
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(SuccessResponse::new("Artifact deleted successfully")),
|
||||
))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Artifacts by Execution
|
||||
// ============================================================================
|
||||
|
||||
/// List all artifacts for a given execution
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/executions/{execution_id}/artifacts",
|
||||
tag = "artifacts",
|
||||
params(("execution_id" = i64, Path, description = "Execution ID")),
|
||||
responses(
|
||||
(status = 200, description = "List of artifacts for execution", body = inline(ApiResponse<Vec<ArtifactSummary>>)),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn list_artifacts_by_execution(
|
||||
RequireAuth(_user): RequireAuth,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(execution_id): Path<i64>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
let artifacts = ArtifactRepository::find_by_execution(&state.db, execution_id).await?;
|
||||
let items: Vec<ArtifactSummary> = artifacts.into_iter().map(ArtifactSummary::from).collect();
|
||||
|
||||
Ok((StatusCode::OK, Json(ApiResponse::new(items))))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Progress Artifacts
|
||||
// ============================================================================
|
||||
|
||||
/// Append an entry to a progress-type artifact's data array.
|
||||
///
|
||||
/// The entry is atomically appended to `artifact.data` (initialized as `[]` if null).
|
||||
/// This is the primary mechanism for actions to stream progress updates.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/artifacts/{id}/progress",
|
||||
tag = "artifacts",
|
||||
params(("id" = i64, Path, description = "Artifact ID (must be progress type)")),
|
||||
request_body = AppendProgressRequest,
|
||||
responses(
|
||||
(status = 200, description = "Entry appended", body = inline(ApiResponse<ArtifactResponse>)),
|
||||
(status = 400, description = "Artifact is not a progress type"),
|
||||
(status = 404, description = "Artifact not found"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn append_progress(
|
||||
RequireAuth(_user): RequireAuth,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<i64>,
|
||||
Json(request): Json<AppendProgressRequest>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
let artifact = ArtifactRepository::find_by_id(&state.db, id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
|
||||
|
||||
if artifact.r#type != ArtifactType::Progress {
|
||||
return Err(ApiError::BadRequest(format!(
|
||||
"Artifact '{}' is type {:?}, not progress. Use version endpoints for file artifacts.",
|
||||
artifact.r#ref, artifact.r#type
|
||||
)));
|
||||
}
|
||||
|
||||
let updated = ArtifactRepository::append_progress(&state.db, id, &request.entry).await?;
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(ApiResponse::with_message(
|
||||
ArtifactResponse::from(updated),
|
||||
"Progress entry appended",
|
||||
)),
|
||||
))
|
||||
}
|
||||
|
||||
/// Set the full data payload on an artifact (replaces existing data).
|
||||
///
|
||||
/// Useful for resetting progress, updating metadata, or setting structured content.
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/api/v1/artifacts/{id}/data",
|
||||
tag = "artifacts",
|
||||
params(("id" = i64, Path, description = "Artifact ID")),
|
||||
request_body = SetDataRequest,
|
||||
responses(
|
||||
(status = 200, description = "Data set", body = inline(ApiResponse<ArtifactResponse>)),
|
||||
(status = 404, description = "Artifact not found"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn set_artifact_data(
|
||||
RequireAuth(_user): RequireAuth,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<i64>,
|
||||
Json(request): Json<SetDataRequest>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// Verify exists
|
||||
ArtifactRepository::find_by_id(&state.db, id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
|
||||
|
||||
let updated = ArtifactRepository::set_data(&state.db, id, &request.data).await?;
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(ApiResponse::with_message(
|
||||
ArtifactResponse::from(updated),
|
||||
"Artifact data updated",
|
||||
)),
|
||||
))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Version Management
|
||||
// ============================================================================
|
||||
|
||||
/// List all versions for an artifact (without binary content)
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/artifacts/{id}/versions",
|
||||
tag = "artifacts",
|
||||
params(("id" = i64, Path, description = "Artifact ID")),
|
||||
responses(
|
||||
(status = 200, description = "List of versions", body = inline(ApiResponse<Vec<ArtifactVersionSummary>>)),
|
||||
(status = 404, description = "Artifact not found"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn list_versions(
|
||||
RequireAuth(_user): RequireAuth,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<i64>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// Verify artifact exists
|
||||
ArtifactRepository::find_by_id(&state.db, id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
|
||||
|
||||
let versions = ArtifactVersionRepository::list_by_artifact(&state.db, id).await?;
|
||||
let items: Vec<ArtifactVersionSummary> = versions
|
||||
.into_iter()
|
||||
.map(ArtifactVersionSummary::from)
|
||||
.collect();
|
||||
|
||||
Ok((StatusCode::OK, Json(ApiResponse::new(items))))
|
||||
}
|
||||
|
||||
/// Get a specific version's metadata and JSON content (no binary)
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/artifacts/{id}/versions/{version}",
|
||||
tag = "artifacts",
|
||||
params(
|
||||
("id" = i64, Path, description = "Artifact ID"),
|
||||
("version" = i32, Path, description = "Version number"),
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Version details", body = inline(ApiResponse<ArtifactVersionResponse>)),
|
||||
(status = 404, description = "Artifact or version not found"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn get_version(
|
||||
RequireAuth(_user): RequireAuth,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path((id, version)): Path<(i64, i32)>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// Verify artifact exists
|
||||
ArtifactRepository::find_by_id(&state.db, id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
|
||||
|
||||
let ver = ArtifactVersionRepository::find_by_version(&state.db, id, version)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
ApiError::NotFound(format!("Version {} not found for artifact {}", version, id))
|
||||
})?;
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(ApiResponse::new(ArtifactVersionResponse::from(ver))),
|
||||
))
|
||||
}
|
||||
|
||||
/// Get the latest version's metadata and JSON content
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/artifacts/{id}/versions/latest",
|
||||
tag = "artifacts",
|
||||
params(("id" = i64, Path, description = "Artifact ID")),
|
||||
responses(
|
||||
(status = 200, description = "Latest version", body = inline(ApiResponse<ArtifactVersionResponse>)),
|
||||
(status = 404, description = "Artifact not found or no versions"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn get_latest_version(
|
||||
RequireAuth(_user): RequireAuth,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<i64>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
ArtifactRepository::find_by_id(&state.db, id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
|
||||
|
||||
let ver = ArtifactVersionRepository::find_latest(&state.db, id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("No versions found for artifact {}", id)))?;
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(ApiResponse::new(ArtifactVersionResponse::from(ver))),
|
||||
))
|
||||
}
|
||||
|
||||
/// Create a new version with JSON content
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/artifacts/{id}/versions",
|
||||
tag = "artifacts",
|
||||
params(("id" = i64, Path, description = "Artifact ID")),
|
||||
request_body = CreateVersionJsonRequest,
|
||||
responses(
|
||||
(status = 201, description = "Version created", body = inline(ApiResponse<ArtifactVersionResponse>)),
|
||||
(status = 404, description = "Artifact not found"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn create_version_json(
|
||||
RequireAuth(_user): RequireAuth,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<i64>,
|
||||
Json(request): Json<CreateVersionJsonRequest>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
ArtifactRepository::find_by_id(&state.db, id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
|
||||
|
||||
let input = CreateArtifactVersionInput {
|
||||
artifact: id,
|
||||
content_type: Some(
|
||||
request
|
||||
.content_type
|
||||
.unwrap_or_else(|| "application/json".to_string()),
|
||||
),
|
||||
content: None,
|
||||
content_json: Some(request.content),
|
||||
meta: request.meta,
|
||||
created_by: request.created_by,
|
||||
};
|
||||
|
||||
let version = ArtifactVersionRepository::create(&state.db, input).await?;
|
||||
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(ApiResponse::with_message(
|
||||
ArtifactVersionResponse::from(version),
|
||||
"Version created successfully",
|
||||
)),
|
||||
))
|
||||
}
|
||||
|
||||
/// Upload a binary file as a new version (multipart/form-data)
|
||||
///
|
||||
/// The file is sent as a multipart form field named `file`. Optional fields:
|
||||
/// - `content_type`: MIME type override (auto-detected from filename if omitted)
|
||||
/// - `meta`: JSON metadata string
|
||||
/// - `created_by`: Creator identifier
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/artifacts/{id}/versions/upload",
|
||||
tag = "artifacts",
|
||||
params(("id" = i64, Path, description = "Artifact ID")),
|
||||
request_body(content = String, content_type = "multipart/form-data"),
|
||||
responses(
|
||||
(status = 201, description = "File version created", body = inline(ApiResponse<ArtifactVersionResponse>)),
|
||||
(status = 400, description = "Missing file field"),
|
||||
(status = 404, description = "Artifact not found"),
|
||||
(status = 413, description = "File too large"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn upload_version(
|
||||
RequireAuth(_user): RequireAuth,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<i64>,
|
||||
mut multipart: Multipart,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
ArtifactRepository::find_by_id(&state.db, id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
|
||||
|
||||
let mut file_data: Option<Vec<u8>> = None;
|
||||
let mut content_type: Option<String> = None;
|
||||
let mut meta: Option<serde_json::Value> = None;
|
||||
let mut created_by: Option<String> = None;
|
||||
let mut file_content_type: Option<String> = None;
|
||||
|
||||
// 50 MB limit
|
||||
const MAX_FILE_SIZE: usize = 50 * 1024 * 1024;
|
||||
|
||||
while let Some(field) = multipart
|
||||
.next_field()
|
||||
.await
|
||||
.map_err(|e| ApiError::BadRequest(format!("Multipart error: {}", e)))?
|
||||
{
|
||||
let name = field.name().unwrap_or("").to_string();
|
||||
match name.as_str() {
|
||||
"file" => {
|
||||
// Capture content type from the multipart field itself
|
||||
file_content_type = field.content_type().map(|s| s.to_string());
|
||||
|
||||
let bytes = field
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| ApiError::BadRequest(format!("Failed to read file: {}", e)))?;
|
||||
|
||||
if bytes.len() > MAX_FILE_SIZE {
|
||||
return Err(ApiError::BadRequest(format!(
|
||||
"File exceeds maximum size of {} bytes",
|
||||
MAX_FILE_SIZE
|
||||
)));
|
||||
}
|
||||
|
||||
file_data = Some(bytes.to_vec());
|
||||
}
|
||||
"content_type" => {
|
||||
let text = field
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| ApiError::BadRequest(format!("Failed to read field: {}", e)))?;
|
||||
if !text.is_empty() {
|
||||
content_type = Some(text);
|
||||
}
|
||||
}
|
||||
"meta" => {
|
||||
let text = field
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| ApiError::BadRequest(format!("Failed to read field: {}", e)))?;
|
||||
if !text.is_empty() {
|
||||
meta =
|
||||
Some(serde_json::from_str(&text).map_err(|e| {
|
||||
ApiError::BadRequest(format!("Invalid meta JSON: {}", e))
|
||||
})?);
|
||||
}
|
||||
}
|
||||
"created_by" => {
|
||||
let text = field
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| ApiError::BadRequest(format!("Failed to read field: {}", e)))?;
|
||||
if !text.is_empty() {
|
||||
created_by = Some(text);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Skip unknown fields
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let file_bytes = file_data.ok_or_else(|| {
|
||||
ApiError::BadRequest("Missing required 'file' field in multipart upload".to_string())
|
||||
})?;
|
||||
|
||||
// Resolve content type: explicit > multipart header > fallback
|
||||
let resolved_ct = content_type
|
||||
.or(file_content_type)
|
||||
.unwrap_or_else(|| "application/octet-stream".to_string());
|
||||
|
||||
let input = CreateArtifactVersionInput {
|
||||
artifact: id,
|
||||
content_type: Some(resolved_ct),
|
||||
content: Some(file_bytes),
|
||||
content_json: None,
|
||||
meta,
|
||||
created_by,
|
||||
};
|
||||
|
||||
let version = ArtifactVersionRepository::create(&state.db, input).await?;
|
||||
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(ApiResponse::with_message(
|
||||
ArtifactVersionResponse::from(version),
|
||||
"File version uploaded successfully",
|
||||
)),
|
||||
))
|
||||
}
|
||||
|
||||
/// Download the binary content of a specific version
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/artifacts/{id}/versions/{version}/download",
|
||||
tag = "artifacts",
|
||||
params(
|
||||
("id" = i64, Path, description = "Artifact ID"),
|
||||
("version" = i32, Path, description = "Version number"),
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Binary file content", content_type = "application/octet-stream"),
|
||||
(status = 404, description = "Artifact, version, or content not found"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn download_version(
|
||||
RequireAuth(_user): RequireAuth,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path((id, version)): Path<(i64, i32)>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
let artifact = ArtifactRepository::find_by_id(&state.db, id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
|
||||
|
||||
let ver = ArtifactVersionRepository::find_by_version_with_content(&state.db, id, version)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
ApiError::NotFound(format!("Version {} not found for artifact {}", version, id))
|
||||
})?;
|
||||
|
||||
// For binary content
|
||||
if let Some(bytes) = ver.content {
|
||||
let ct = ver
|
||||
.content_type
|
||||
.unwrap_or_else(|| "application/octet-stream".to_string());
|
||||
|
||||
let filename = format!(
|
||||
"{}_v{}.{}",
|
||||
artifact.r#ref.replace('.', "_"),
|
||||
version,
|
||||
extension_from_content_type(&ct)
|
||||
);
|
||||
|
||||
return Ok((
|
||||
StatusCode::OK,
|
||||
[
|
||||
(header::CONTENT_TYPE, ct),
|
||||
(
|
||||
header::CONTENT_DISPOSITION,
|
||||
format!("attachment; filename=\"{}\"", filename),
|
||||
),
|
||||
],
|
||||
Body::from(bytes),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
|
||||
// For JSON content, serialize and return
|
||||
if let Some(json) = ver.content_json {
|
||||
let bytes = serde_json::to_vec_pretty(&json).map_err(|e| {
|
||||
ApiError::InternalServerError(format!("Failed to serialize JSON: {}", e))
|
||||
})?;
|
||||
|
||||
let ct = ver
|
||||
.content_type
|
||||
.unwrap_or_else(|| "application/json".to_string());
|
||||
|
||||
let filename = format!("{}_v{}.json", artifact.r#ref.replace('.', "_"), version,);
|
||||
|
||||
return Ok((
|
||||
StatusCode::OK,
|
||||
[
|
||||
(header::CONTENT_TYPE, ct),
|
||||
(
|
||||
header::CONTENT_DISPOSITION,
|
||||
format!("attachment; filename=\"{}\"", filename),
|
||||
),
|
||||
],
|
||||
Body::from(bytes),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
|
||||
Err(ApiError::NotFound(format!(
|
||||
"Version {} of artifact {} has no downloadable content",
|
||||
version, id
|
||||
)))
|
||||
}
|
||||
|
||||
/// Download the latest version's content
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/artifacts/{id}/download",
|
||||
tag = "artifacts",
|
||||
params(("id" = i64, Path, description = "Artifact ID")),
|
||||
responses(
|
||||
(status = 200, description = "Binary file content of latest version", content_type = "application/octet-stream"),
|
||||
(status = 404, description = "Artifact not found or no versions"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn download_latest(
|
||||
RequireAuth(_user): RequireAuth,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<i64>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
let artifact = ArtifactRepository::find_by_id(&state.db, id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
|
||||
|
||||
let ver = ArtifactVersionRepository::find_latest_with_content(&state.db, id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("No versions found for artifact {}", id)))?;
|
||||
|
||||
let version = ver.version;
|
||||
|
||||
// For binary content
|
||||
if let Some(bytes) = ver.content {
|
||||
let ct = ver
|
||||
.content_type
|
||||
.unwrap_or_else(|| "application/octet-stream".to_string());
|
||||
|
||||
let filename = format!(
|
||||
"{}_v{}.{}",
|
||||
artifact.r#ref.replace('.', "_"),
|
||||
version,
|
||||
extension_from_content_type(&ct)
|
||||
);
|
||||
|
||||
return Ok((
|
||||
StatusCode::OK,
|
||||
[
|
||||
(header::CONTENT_TYPE, ct),
|
||||
(
|
||||
header::CONTENT_DISPOSITION,
|
||||
format!("attachment; filename=\"{}\"", filename),
|
||||
),
|
||||
],
|
||||
Body::from(bytes),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
|
||||
// For JSON content
|
||||
if let Some(json) = ver.content_json {
|
||||
let bytes = serde_json::to_vec_pretty(&json).map_err(|e| {
|
||||
ApiError::InternalServerError(format!("Failed to serialize JSON: {}", e))
|
||||
})?;
|
||||
|
||||
let ct = ver
|
||||
.content_type
|
||||
.unwrap_or_else(|| "application/json".to_string());
|
||||
|
||||
let filename = format!("{}_v{}.json", artifact.r#ref.replace('.', "_"), version,);
|
||||
|
||||
return Ok((
|
||||
StatusCode::OK,
|
||||
[
|
||||
(header::CONTENT_TYPE, ct),
|
||||
(
|
||||
header::CONTENT_DISPOSITION,
|
||||
format!("attachment; filename=\"{}\"", filename),
|
||||
),
|
||||
],
|
||||
Body::from(bytes),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
|
||||
Err(ApiError::NotFound(format!(
|
||||
"Latest version of artifact {} has no downloadable content",
|
||||
id
|
||||
)))
|
||||
}
|
||||
|
||||
/// Delete a specific version by version number
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/v1/artifacts/{id}/versions/{version}",
|
||||
tag = "artifacts",
|
||||
params(
|
||||
("id" = i64, Path, description = "Artifact ID"),
|
||||
("version" = i32, Path, description = "Version number"),
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Version deleted", body = SuccessResponse),
|
||||
(status = 404, description = "Artifact or version not found"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn delete_version(
|
||||
RequireAuth(_user): RequireAuth,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path((id, version)): Path<(i64, i32)>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// Verify artifact exists
|
||||
ArtifactRepository::find_by_id(&state.db, id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Artifact with ID {} not found", id)))?;
|
||||
|
||||
// Find the version by artifact + version number
|
||||
let ver = ArtifactVersionRepository::find_by_version(&state.db, id, version)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
ApiError::NotFound(format!("Version {} not found for artifact {}", version, id))
|
||||
})?;
|
||||
|
||||
ArtifactVersionRepository::delete(&state.db, ver.id).await?;
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(SuccessResponse::new("Version deleted successfully")),
|
||||
))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
/// Derive a simple file extension from a MIME content type
|
||||
fn extension_from_content_type(ct: &str) -> &str {
|
||||
match ct {
|
||||
"text/plain" => "txt",
|
||||
"text/html" => "html",
|
||||
"text/css" => "css",
|
||||
"text/csv" => "csv",
|
||||
"text/xml" => "xml",
|
||||
"application/json" => "json",
|
||||
"application/xml" => "xml",
|
||||
"application/pdf" => "pdf",
|
||||
"application/zip" => "zip",
|
||||
"application/gzip" => "gz",
|
||||
"application/octet-stream" => "bin",
|
||||
"image/png" => "png",
|
||||
"image/jpeg" => "jpg",
|
||||
"image/gif" => "gif",
|
||||
"image/svg+xml" => "svg",
|
||||
"image/webp" => "webp",
|
||||
_ => "bin",
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Router
|
||||
// ============================================================================
|
||||
|
||||
/// Register artifact routes
|
||||
pub fn routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
// Artifact CRUD
|
||||
.route("/artifacts", get(list_artifacts).post(create_artifact))
|
||||
.route(
|
||||
"/artifacts/{id}",
|
||||
get(get_artifact)
|
||||
.put(update_artifact)
|
||||
.delete(delete_artifact),
|
||||
)
|
||||
.route("/artifacts/ref/{ref}", get(get_artifact_by_ref))
|
||||
// Progress / data
|
||||
.route("/artifacts/{id}/progress", post(append_progress))
|
||||
.route(
|
||||
"/artifacts/{id}/data",
|
||||
axum::routing::put(set_artifact_data),
|
||||
)
|
||||
// Download (latest)
|
||||
.route("/artifacts/{id}/download", get(download_latest))
|
||||
// Version management
|
||||
.route(
|
||||
"/artifacts/{id}/versions",
|
||||
get(list_versions).post(create_version_json),
|
||||
)
|
||||
.route("/artifacts/{id}/versions/latest", get(get_latest_version))
|
||||
.route("/artifacts/{id}/versions/upload", post(upload_version))
|
||||
.route(
|
||||
"/artifacts/{id}/versions/{version}",
|
||||
get(get_version).delete(delete_version),
|
||||
)
|
||||
.route(
|
||||
"/artifacts/{id}/versions/{version}/download",
|
||||
get(download_version),
|
||||
)
|
||||
// By execution
|
||||
.route(
|
||||
"/executions/{execution_id}/artifacts",
|
||||
get(list_artifacts_by_execution),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_artifact_routes_structure() {
|
||||
let _router = routes();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extension_from_content_type() {
|
||||
assert_eq!(extension_from_content_type("text/plain"), "txt");
|
||||
assert_eq!(extension_from_content_type("application/json"), "json");
|
||||
assert_eq!(extension_from_content_type("image/png"), "png");
|
||||
assert_eq!(extension_from_content_type("unknown/type"), "bin");
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
pub mod actions;
|
||||
pub mod analytics;
|
||||
pub mod artifacts;
|
||||
pub mod auth;
|
||||
pub mod events;
|
||||
pub mod executions;
|
||||
@@ -17,6 +18,7 @@ pub mod workflows;
|
||||
|
||||
pub use actions::routes as action_routes;
|
||||
pub use analytics::routes as analytics_routes;
|
||||
pub use artifacts::routes as artifact_routes;
|
||||
pub use auth::routes as auth_routes;
|
||||
pub use events::routes as event_routes;
|
||||
pub use executions::routes as execution_routes;
|
||||
|
||||
@@ -57,8 +57,7 @@ impl Server {
|
||||
.merge(routes::webhook_routes())
|
||||
.merge(routes::history_routes())
|
||||
.merge(routes::analytics_routes())
|
||||
// TODO: Add more route modules here
|
||||
// etc.
|
||||
.merge(routes::artifact_routes())
|
||||
.with_state(self.state.clone());
|
||||
|
||||
// Auth routes at root level (not versioned for frontend compatibility)
|
||||
|
||||
Reference in New Issue
Block a user