use async_graphql::indexmap::IndexMap;
use axum::{
body::Bytes,
extract::{Query, State},
http::{header, HeaderMap, StatusCode},
response::{IntoResponse, Response},
Json,
};
use oxirs_core::Store;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use std::time::Instant;
use tracing::{debug, info, warn};
#[derive(Debug, Clone, Deserialize)]
pub struct ShaclParams {
#[serde(default = "default_graph")]
pub graph: String,
pub target: Option<String>,
}
fn default_graph() -> String {
"default".to_string()
}
#[derive(Debug, thiserror::Error)]
pub enum ShaclError {
#[error("Graph not found: {0}")]
GraphNotFound(String),
#[error("Bad request: {0}")]
BadRequest(String),
#[error("Parse error: {0}")]
ParseError(String),
#[error("Validation error: {0}")]
ValidationError(String),
#[error("Store error: {0}")]
StoreError(String),
#[error("Internal error: {0}")]
Internal(String),
}
impl ShaclError {
fn status_code(&self) -> StatusCode {
match self {
ShaclError::GraphNotFound(_) => StatusCode::NOT_FOUND,
ShaclError::BadRequest(_) => StatusCode::BAD_REQUEST,
ShaclError::ParseError(_) => StatusCode::BAD_REQUEST,
ShaclError::ValidationError(_) => StatusCode::BAD_REQUEST,
ShaclError::StoreError(_) => StatusCode::INTERNAL_SERVER_ERROR,
ShaclError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
}
impl IntoResponse for ShaclError {
fn into_response(self) -> Response {
let status = self.status_code();
let message = self.to_string();
(
status,
Json(serde_json::json!({
"error": message,
"status": status.as_u16(),
})),
)
.into_response()
}
}
#[derive(Debug, Clone, Serialize)]
pub struct ValidationSummary {
pub conforms: bool,
pub error_count: usize,
pub graph: String,
pub duration_ms: u64,
pub target_node: Option<String>,
}
pub async fn handle_shacl_validation<S: Store + Send + Sync + 'static>(
Query(params): Query<ShaclParams>,
State(store): State<Arc<S>>,
headers: HeaderMap,
body: Bytes,
) -> Result<Response, ShaclError> {
let start = Instant::now();
info!(
"SHACL validation request: graph={}, target={:?}",
params.graph, params.target
);
let content_type = headers
.get(header::CONTENT_TYPE)
.and_then(|h| h.to_str().ok())
.unwrap_or("text/turtle");
if !content_type.contains("turtle") {
return Err(ShaclError::BadRequest(format!(
"Expected text/turtle, got {}",
content_type
)));
}
let shapes_text = std::str::from_utf8(&body)
.map_err(|e| ShaclError::ParseError(format!("UTF-8 error: {}", e)))?;
debug!("Parsing SHACL shapes graph ({} bytes)", shapes_text.len());
let parser = oxirs_core::parser::Parser::new(oxirs_core::parser::RdfFormat::Turtle);
let shapes_triples: Vec<oxirs_core::model::Triple> = parser
.parse_str_to_quads(shapes_text)
.map_err(|e| ShaclError::ParseError(format!("Turtle parse error: {}", e)))?
.into_iter()
.map(|quad| quad.to_triple())
.collect();
info!("Parsed {} shape triples", shapes_triples.len());
let data_triples = get_graph_triples(¶ms.graph, store.as_ref())?;
info!(
"Retrieved {} data triples from graph '{}'",
data_triples.len(),
params.graph
);
let conforms = validate_shapes(&shapes_triples, &data_triples, params.target.as_deref())?;
let duration = start.elapsed();
info!(
"SHACL validation completed: conforms={}, duration={:?}",
conforms, duration
);
let report =
build_validation_report(conforms, ¶ms.graph, params.target.as_deref(), duration);
let report_turtle = serialize_validation_report(&report)?;
let status = if conforms {
StatusCode::OK
} else {
StatusCode::BAD_REQUEST
};
Response::builder()
.status(status)
.header(header::CONTENT_TYPE, "text/turtle")
.header("X-SHACL-Conforms", conforms.to_string())
.header("X-Duration-Ms", duration.as_millis().to_string())
.body(axum::body::Body::from(report_turtle))
.map_err(|e| ShaclError::Internal(format!("Response build error: {}", e)))
}
fn get_graph_triples<S: Store>(
graph_param: &str,
store: &S,
) -> Result<Vec<oxirs_core::model::Triple>, ShaclError> {
use oxirs_core::model::GraphName;
let graph_name = match graph_param {
"default" => GraphName::DefaultGraph,
"union" => {
return store
.find_quads(None, None, None, None)
.map(|quads| {
quads
.into_iter()
.map(|q| {
oxirs_core::model::Triple::new(
q.subject().clone(),
q.predicate().clone(),
q.object().clone(),
)
})
.collect()
})
.map_err(|e| ShaclError::StoreError(e.to_string()));
}
uri => {
let node = oxirs_core::model::NamedNode::new(uri)
.map_err(|e| ShaclError::BadRequest(format!("Invalid graph URI: {}", e)))?;
GraphName::NamedNode(node)
}
};
let quads = store
.find_quads(None, None, None, Some(&graph_name))
.map_err(|e| ShaclError::StoreError(e.to_string()))?;
if quads.is_empty() {
return Err(ShaclError::GraphNotFound(graph_param.to_string()));
}
Ok(quads
.into_iter()
.map(|q| {
oxirs_core::model::Triple::new(
q.subject().clone(),
q.predicate().clone(),
q.object().clone(),
)
})
.collect())
}
fn validate_shapes(
shapes_triples: &[oxirs_core::model::Triple],
data_triples: &[oxirs_core::model::Triple],
target_node: Option<&str>,
) -> Result<bool, ShaclError> {
debug!(
"Validating {} data triples against {} shape triples",
data_triples.len(),
shapes_triples.len()
);
if let Some(target) = target_node {
debug!("Focused validation on target node: {}", target);
}
let shapes_store = oxirs_core::rdf_store::ConcreteStore::new().map_err(|e| {
ShaclError::ValidationError(format!("Failed to create shapes store: {}", e))
})?;
for triple in shapes_triples {
shapes_store.insert_triple(triple.clone()).map_err(|e| {
ShaclError::ValidationError(format!("Failed to insert shape triple: {}", e))
})?;
}
let mut shape_parser = oxirs_shacl::ShapeParser::new();
let shapes_vec = shape_parser
.parse_shapes_from_store(&shapes_store, None) .map_err(|e| ShaclError::ValidationError(format!("Shape parsing failed: {}", e)))?;
if shapes_vec.is_empty() {
warn!("No SHACL shapes found in the shapes graph");
return Ok(true); }
let shapes: IndexMap<_, _> = shapes_vec
.into_iter()
.map(|shape| (shape.id.clone(), shape))
.collect();
info!("Parsed {} SHACL shapes", shapes.len());
let data_store = oxirs_core::rdf_store::ConcreteStore::new()
.map_err(|e| ShaclError::ValidationError(format!("Failed to create data store: {}", e)))?;
for triple in data_triples {
data_store.insert_triple(triple.clone()).map_err(|e| {
ShaclError::ValidationError(format!("Failed to insert data triple: {}", e))
})?;
}
let config = oxirs_shacl::ValidationConfig {
max_violations: 0, include_info: true,
include_warnings: true,
fail_fast: false, max_recursion_depth: 100,
timeout_ms: Some(60000), parallel: true, context: std::collections::HashMap::new(),
strategy: oxirs_shacl::ValidationStrategy::Optimized,
};
let mut engine = oxirs_shacl::ValidationEngine::new(&shapes, config);
let report = if let Some(target_iri) = target_node {
let target_term = oxirs_core::model::NamedNode::new(target_iri)
.map_err(|e| ShaclError::ValidationError(format!("Invalid target node IRI: {}", e)))?;
let target_terms = vec![oxirs_core::model::Term::NamedNode(target_term)];
let mut combined_report = oxirs_shacl::ValidationReport::new();
for shape in shapes.values() {
let shape_report = engine
.validate_nodes(&data_store, shape, &target_terms)
.map_err(|e| ShaclError::ValidationError(format!("Validation failed: {}", e)))?;
combined_report.merge_result(shape_report);
}
combined_report
} else {
engine
.validate_store(&data_store)
.map_err(|e| ShaclError::ValidationError(format!("Validation failed: {}", e)))?
};
let conforms = report.conforms;
debug!(
"Validation complete: conforms={}, violations={}",
conforms,
report.violations.len()
);
Ok(conforms)
}
fn build_validation_report(
conforms: bool,
graph: &str,
target_node: Option<&str>,
duration: std::time::Duration,
) -> ValidationSummary {
ValidationSummary {
conforms,
error_count: if conforms { 0 } else { 1 },
graph: graph.to_string(),
duration_ms: duration.as_millis() as u64,
target_node: target_node.map(|s| s.to_string()),
}
}
fn serialize_validation_report(report: &ValidationSummary) -> Result<String, ShaclError> {
let turtle = format!(
r#"@prefix sh: <http://www.w3.org/ns/shacl#> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
[]
a sh:ValidationReport ;
sh:conforms {} ;
sh:result [] .
"#,
if report.conforms { "true" } else { "false" }
);
Ok(turtle)
}
pub async fn handle_shacl_validation_server(
Query(params): Query<ShaclParams>,
State(state): State<std::sync::Arc<crate::server::AppState>>,
headers: HeaderMap,
body: Bytes,
) -> Response {
match handle_shacl_validation(
Query(params),
State(std::sync::Arc::new(state.store.clone())),
headers,
body,
)
.await
{
Ok(response) => response,
Err(err) => err.into_response(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use oxirs_core::rdf_store::ConcreteStore;
fn setup_test_store() -> Arc<ConcreteStore> {
let store = ConcreteStore::new().unwrap();
let turtle_data = r#"@prefix ex: <http://example.org/> .
ex:Alice <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> ex:Person .
ex:Alice ex:name "Alice" .
ex:Alice ex:age "30" .
"#;
let triples = oxirs_core::format::turtle::TurtleParser::new()
.parse_str(turtle_data)
.unwrap();
for triple in triples {
store.insert_triple(triple).unwrap();
}
Arc::new(store)
}
#[tokio::test]
async fn test_shacl_params_default() {
let params = ShaclParams {
graph: "default".to_string(),
target: None,
};
assert_eq!(params.graph, "default");
assert!(params.target.is_none());
}
#[test]
fn test_get_graph_triples_default() {
let store = setup_test_store();
let result = get_graph_triples("default", store.as_ref());
assert!(result.is_ok());
let triples = result.unwrap();
assert!(!triples.is_empty());
}
#[test]
fn test_validate_shapes_placeholder() {
let shapes = vec![];
let data = vec![];
let result = validate_shapes(&shapes, &data, None);
assert!(result.is_ok());
assert!(result.unwrap()); }
#[test]
fn test_build_validation_report() {
let report =
build_validation_report(true, "default", None, std::time::Duration::from_millis(100));
assert!(report.conforms);
assert_eq!(report.error_count, 0);
assert_eq!(report.graph, "default");
}
#[test]
fn test_serialize_validation_report() {
let report = ValidationSummary {
conforms: true,
error_count: 0,
graph: "default".to_string(),
duration_ms: 100,
target_node: None,
};
let turtle = serialize_validation_report(&report).unwrap();
assert!(turtle.contains("sh:ValidationReport"));
assert!(turtle.contains("sh:conforms true"));
}
}