use async_graphql::{
Context, EmptySubscription, InputObject, Object, Result as GqlResult, Schema, SimpleObject, ID,
};
use axum::extract::State;
use axum::http::HeaderMap;
use chrono::{DateTime, Utc};
use std::sync::Arc;
use super::auth;
use super::server::AppState;
use crate::formats::ModelFormat;
pub type VaultSchema = Schema<QueryRoot, MutationRoot, EmptySubscription>;
pub fn build_schema(state: Arc<AppState>) -> VaultSchema {
Schema::build(QueryRoot, MutationRoot, EmptySubscription)
.data(state)
.finish()
}
#[derive(SimpleObject, Clone)]
pub struct Model {
pub name: String,
pub version_count: i32,
pub latest_version: Option<i32>,
pub total_size: i64,
}
#[derive(SimpleObject, Clone)]
pub struct ModelVersion {
pub version: i32,
pub checkpoint_id: String,
pub created_at: DateTime<Utc>,
pub format: String,
pub size_bytes: i64,
pub checksum: String,
pub parent_version: Option<i32>,
}
#[derive(SimpleObject, Clone)]
pub struct LineageEntry {
pub version: i32,
pub checkpoint_id: String,
pub timestamp: DateTime<Utc>,
pub parent_id: Option<String>,
}
#[derive(SimpleObject, Clone)]
pub struct VaultStats {
pub model_count: i32,
pub version_count: i32,
pub total_size: i64,
pub status: String,
}
#[derive(SimpleObject, Clone)]
pub struct AuditEntry {
pub id: ID,
pub timestamp: DateTime<Utc>,
pub event_type: String,
pub description: String,
pub model_name: Option<String>,
pub success: bool,
}
#[derive(SimpleObject, Clone)]
pub struct ConversionPath {
pub source: String,
pub target: String,
pub direct: bool,
pub via: Vec<String>,
}
#[derive(InputObject)]
pub struct StoreModelInput {
pub name: String,
pub format: String,
pub data_base64: String,
pub description: Option<String>,
pub framework: Option<String>,
}
#[derive(InputObject)]
pub struct ConvertModelInput {
pub name: String,
pub version: Option<i32>,
pub target_format: String,
pub new_name: Option<String>,
}
#[derive(SimpleObject)]
pub struct StoreResult {
pub name: String,
pub version: i32,
pub checkpoint_id: String,
pub size_bytes: i64,
pub checksum: String,
}
#[derive(SimpleObject)]
pub struct DeleteResult {
pub success: bool,
pub versions_deleted: i32,
}
#[derive(SimpleObject)]
pub struct ConversionResult {
pub success: bool,
pub new_name: String,
pub version: i32,
pub duration_ms: i64,
}
pub struct QueryRoot;
#[Object]
impl QueryRoot {
async fn models(&self, ctx: &Context<'_>) -> GqlResult<Vec<Model>> {
let state = ctx.data::<Arc<AppState>>()?;
let vault = state.vault.read().await;
let models = vault
.list_models()
.into_iter()
.map(|name| {
let versions = vault.list_versions(&name);
let latest = versions.iter().map(|v| v.version as i32).max();
let total_size: i64 = versions.iter().map(|v| v.size_bytes as i64).sum();
Model {
name,
version_count: versions.len() as i32,
latest_version: latest,
total_size,
}
})
.collect();
Ok(models)
}
async fn model(&self, ctx: &Context<'_>, name: String) -> GqlResult<Option<Model>> {
let state = ctx.data::<Arc<AppState>>()?;
let vault = state.vault.read().await;
let models = vault.list_models();
if !models.contains(&name) {
return Ok(None);
}
let versions = vault.list_versions(&name);
let latest = versions.iter().map(|v| v.version as i32).max();
let total_size: i64 = versions.iter().map(|v| v.size_bytes as i64).sum();
Ok(Some(Model {
name,
version_count: versions.len() as i32,
latest_version: latest,
total_size,
}))
}
async fn versions(&self, ctx: &Context<'_>, model: String) -> GqlResult<Vec<ModelVersion>> {
let state = ctx.data::<Arc<AppState>>()?;
let vault = state.vault.read().await;
let versions = vault
.list_versions(&model)
.into_iter()
.map(|v| ModelVersion {
version: v.version as i32,
checkpoint_id: v.checkpoint_id.clone(),
created_at: v.timestamp,
format: v.format.clone(),
size_bytes: v.size_bytes as i64,
checksum: v.checksum_sha256.clone(),
parent_version: v.parent_version.map(|p| p as i32),
})
.collect();
Ok(versions)
}
async fn lineage(
&self,
ctx: &Context<'_>,
model: String,
version: i32,
) -> GqlResult<Vec<LineageEntry>> {
let state = ctx.data::<Arc<AppState>>()?;
let vault = state.vault.read().await;
let lineage = vault
.get_lineage(&model, version as u32)
.into_iter()
.map(|l| LineageEntry {
version: l.version as i32,
checkpoint_id: l.checkpoint_id.clone(),
timestamp: l.timestamp,
parent_id: l.parent_version.map(|v| format!("v{}", v)),
})
.collect();
Ok(lineage)
}
async fn stats(&self, ctx: &Context<'_>) -> GqlResult<VaultStats> {
let state = ctx.data::<Arc<AppState>>()?;
let vault = state.vault.read().await;
let models = vault.list_models();
let mut total_versions = 0;
let mut total_size: i64 = 0;
for model in &models {
let versions = vault.list_versions(model);
total_versions += versions.len();
total_size += versions.iter().map(|v| v.size_bytes as i64).sum::<i64>();
}
Ok(VaultStats {
model_count: models.len() as i32,
version_count: total_versions as i32,
total_size,
status: if vault.is_unlocked() {
"unlocked".into()
} else {
"locked".into()
},
})
}
async fn audit_log(
&self,
_ctx: &Context<'_>,
#[graphql(default = 100)] _limit: i32,
) -> GqlResult<Vec<AuditEntry>> {
Ok(Vec::new())
}
async fn conversions(&self, _ctx: &Context<'_>) -> GqlResult<Vec<ConversionPath>> {
let formats = [
"safetensors",
"pytorch",
"onnx",
"gguf",
"tflite",
"coreml",
"tensorrt",
];
let mut paths = Vec::new();
for source in &formats {
for target in &formats {
if source != target {
paths.push(ConversionPath {
source: source.to_string(),
target: target.to_string(),
direct: true,
via: vec![],
});
}
}
}
Ok(paths)
}
async fn health(&self) -> GqlResult<String> {
Ok("ok".into())
}
async fn version(&self) -> GqlResult<String> {
Ok(env!("CARGO_PKG_VERSION").into())
}
}
pub struct MutationRoot;
fn require_gql_auth(ctx: &Context<'_>) -> GqlResult<()> {
let state = ctx.data::<Arc<AppState>>()?;
let headers = ctx.data::<HeaderMap>()?;
let auth_header = headers
.get(axum::http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.ok_or_else(|| async_graphql::Error::new("Missing Authorization header"))?;
let token = auth_header
.strip_prefix("Bearer ")
.ok_or_else(|| async_graphql::Error::new("Invalid Authorization format"))?;
if token.is_empty() || token.len() > 4096 || token.chars().any(|c| c.is_control()) {
return Err(async_graphql::Error::new("Malformed token"));
}
auth::verify_token(token, &state.config.jwt_secret)
.map_err(|_| async_graphql::Error::new("Invalid or expired token"))?;
Ok(())
}
#[Object]
impl MutationRoot {
async fn store_model(
&self,
ctx: &Context<'_>,
input: StoreModelInput,
) -> GqlResult<StoreResult> {
require_gql_auth(ctx)?;
use base64::{engine::general_purpose::STANDARD as B64, Engine};
let state = ctx.data::<Arc<AppState>>()?;
let data = B64
.decode(&input.data_base64)
.map_err(|e| async_graphql::Error::new(format!("Invalid base64: {e}")))?;
let format = ModelFormat::from_stored(&input.format);
let mut metadata = crate::formats::ModelMetadata::new(input.name.clone(), format);
if let Some(desc) = input.description {
metadata = metadata.with_description(desc);
}
if let Some(fw) = input.framework {
metadata = metadata.with_framework(fw);
}
let mut vault = state.vault.write().await;
let version = vault.store_model(&input.name, data, metadata, None)?;
Ok(StoreResult {
name: input.name,
version: version.version as i32,
checkpoint_id: version.checkpoint_id,
size_bytes: version.size_bytes as i64,
checksum: version.checksum_sha256,
})
}
async fn delete_model(&self, ctx: &Context<'_>, name: String) -> GqlResult<DeleteResult> {
require_gql_auth(ctx)?;
let state = ctx.data::<Arc<AppState>>()?;
let mut vault = state.vault.write().await;
let versions: Vec<u32> = vault
.list_versions(&name)
.iter()
.map(|v| v.version)
.collect();
let count = versions.len();
for version in versions {
vault.delete_version(&name, version)?;
}
Ok(DeleteResult {
success: true,
versions_deleted: count as i32,
})
}
async fn delete_version(
&self,
ctx: &Context<'_>,
name: String,
version: i32,
) -> GqlResult<DeleteResult> {
require_gql_auth(ctx)?;
let state = ctx.data::<Arc<AppState>>()?;
let mut vault = state.vault.write().await;
vault.delete_version(&name, version as u32)?;
Ok(DeleteResult {
success: true,
versions_deleted: 1,
})
}
async fn convert_model(
&self,
ctx: &Context<'_>,
_input: ConvertModelInput,
) -> GqlResult<ConversionResult> {
require_gql_auth(ctx)?;
Err(async_graphql::Error::new(
"Model conversion is available via REST API at /api/v1/convert",
))
}
async fn unlock(&self, ctx: &Context<'_>, passphrase: String) -> GqlResult<bool> {
let state = ctx.data::<Arc<AppState>>()?;
let mut vault = state.vault.write().await;
vault.unlock(passphrase.into_bytes())?;
Ok(true)
}
async fn lock(&self, ctx: &Context<'_>) -> GqlResult<bool> {
require_gql_auth(ctx)?;
let state = ctx.data::<Arc<AppState>>()?;
let mut vault = state.vault.write().await;
vault.lock();
Ok(true)
}
}
pub async fn graphql_handler(
State(schema): State<VaultSchema>,
headers: HeaderMap,
body: axum::body::Bytes,
) -> axum::response::Response {
use axum::http::StatusCode;
use axum::response::IntoResponse;
let request_str = match std::str::from_utf8(&body) {
Ok(s) => s,
Err(_) => {
return (StatusCode::BAD_REQUEST, "Invalid UTF-8 in request body").into_response()
}
};
let gql_request: async_graphql::Request = match serde_json::from_str(request_str) {
Ok(r) => r,
Err(e) => {
return (
StatusCode::BAD_REQUEST,
format!("Invalid GraphQL request: {e}"),
)
.into_response()
}
};
let gql_request = gql_request.data(headers);
let response = schema.execute(gql_request).await;
let json = serde_json::to_string(&response).unwrap_or_default();
(
StatusCode::OK,
[(axum::http::header::CONTENT_TYPE, "application/json")],
json,
)
.into_response()
}
pub async fn graphql_playground() -> impl axum::response::IntoResponse {
axum::response::Html(async_graphql::http::playground_source(
async_graphql::http::GraphQLPlaygroundConfig::new("/graphql"),
))
}