Files
charybdis/src/plugins/field_mapper.rs
T
2026-05-12 17:06:43 +02:00

728 lines
27 KiB
Rust

use crate::charybdis::entities::Entity;
use crate::database::EntityRepository;
use anyhow::{Result, anyhow};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::sync::Arc;
use tracing::warn;
/// Field mapper handles mapping between entity fields and external tool fields
pub struct FieldMapper {
mappings: HashMap<String, FieldMapping>,
repository: Option<Arc<EntityRepository>>,
}
/// Field mapping types
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum FieldMapping {
/// Complex mapping with entity resolution (must come first to match objects with "from" key)
Complex(ComplexMapping),
/// Static value wrapper (object with "value" key)
/// Example: {"value": "Web Application"}, {"value": true}, {"value": 123}
StaticWrapper(StaticValue),
/// Direct field access using dot notation (plain string)
/// Example: "metadata.name" accesses entity.metadata.name
Direct(String),
}
/// Wrapper for static values to distinguish from field paths
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StaticValue {
pub value: Value,
}
/// Complex field mapping configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComplexMapping {
/// Source field path
pub from: String,
/// Optional: Resolve entity reference
#[serde(skip_serializing_if = "Option::is_none")]
pub resolve_entity: Option<String>,
/// Optional: Find linked entity of this kind
#[serde(skip_serializing_if = "Option::is_none")]
pub lookup_entity: Option<String>,
/// Optional: Extract field from resolved entity
#[serde(skip_serializing_if = "Option::is_none")]
pub extract: Option<String>,
/// Optional: Resolve array of entity IDs
#[serde(default)]
pub resolve_array: bool,
/// Optional: Transform function
#[serde(skip_serializing_if = "Option::is_none")]
pub transform: Option<String>,
}
impl FieldMapper {
/// Create a new field mapper from configuration
/// Config format: { "target_field": "source.field" or complex mapping }
pub fn new(config: HashMap<String, Value>) -> Result<Self> {
let mut mappings = HashMap::new();
for (field_name, mapping_value) in config {
let mapping = Self::parse_mapping(mapping_value)?;
mappings.insert(field_name, mapping);
}
Ok(Self {
mappings,
repository: None,
})
}
/// Set the entity repository (required for entity resolution)
pub fn with_repository(mut self, repository: Arc<EntityRepository>) -> Self {
self.repository = Some(repository);
self
}
/// Parse a mapping value from config
fn parse_mapping(value: Value) -> Result<FieldMapping> {
// Use serde untagged deserialization
// Order matters: Complex (with "from"), StaticWrapper (with "value"), Direct (string)
serde_json::from_value(value).map_err(|e| anyhow!("Failed to parse field mapping: {}", e))
}
/// Map a single field from source entity (async for entity resolution)
pub async fn map(&self, field_name: &str, entity: &Entity) -> Result<Value> {
let mapping = self
.mappings
.get(field_name)
.ok_or_else(|| anyhow!("No mapping configured for field: {}", field_name))?;
match mapping {
FieldMapping::Direct(path) => self.get_field_by_path(entity, path),
FieldMapping::StaticWrapper(static_val) => Ok(static_val.value.clone()),
FieldMapping::Complex(complex) => self.map_complex(complex, entity).await,
}
}
/// Handle complex field mapping with entity resolution
async fn map_complex(&self, mapping: &ComplexMapping, entity: &Entity) -> Result<Value> {
// Get source field value
let source_value = self.get_field_by_path(entity, &mapping.from)?;
// If no entity resolution needed, just return (possibly transformed) value
if mapping.resolve_entity.is_none() && !mapping.resolve_array {
return Ok(source_value);
}
// Entity resolution required - need repository
let repository = self
.repository
.as_ref()
.ok_or_else(|| anyhow!("Entity resolution requires repository"))?;
// Handle array resolution
if mapping.resolve_array {
return self.resolve_array(source_value, mapping, repository).await;
}
// Handle single entity resolution
if let Some(resolve_kind) = &mapping.resolve_entity {
return self
.resolve_entity(source_value, resolve_kind, mapping, repository)
.await;
}
Ok(source_value)
}
/// Resolve a single entity reference
async fn resolve_entity(
&self,
entity_id: Value,
expected_kind: &str,
mapping: &ComplexMapping,
repository: &EntityRepository,
) -> Result<Value> {
let id_str = entity_id
.as_str()
.ok_or_else(|| anyhow!("Entity ID must be a string"))?;
// Resolve the entity
let resolved = repository
.get_by_id(id_str)
.await?
.ok_or_else(|| anyhow!("Entity not found: {}", id_str))?;
// Validate kind if specified
if resolved.kind != expected_kind {
return Err(anyhow!(
"Expected entity kind {}, got {}",
expected_kind,
resolved.kind
));
}
// If we need to lookup a linked entity
if let Some(lookup_kind) = &mapping.lookup_entity {
let linked = self
.find_linked_entity(&resolved.id, lookup_kind, repository)
.await?;
// Extract field from linked entity
if let Some(extract_path) = &mapping.extract {
return self.get_field_by_path(&linked, extract_path);
}
return entity_to_json(&linked);
}
// Extract field from resolved entity
if let Some(extract_path) = &mapping.extract {
return self.get_field_by_path(&resolved, extract_path);
}
// Return entire resolved entity
entity_to_json(&resolved)
}
/// Resolve an array of entity references
async fn resolve_array(
&self,
entity_ids: Value,
mapping: &ComplexMapping,
repository: &EntityRepository,
) -> Result<Value> {
let ids_array = entity_ids
.as_array()
.ok_or_else(|| anyhow!("Expected array of entity IDs"))?;
let mut results = Vec::new();
for id_value in ids_array {
let resolve_kind = mapping
.resolve_entity
.as_ref()
.ok_or_else(|| anyhow!("resolve_entity required for array resolution"))?;
match self
.resolve_entity(id_value.clone(), resolve_kind, mapping, repository)
.await
{
Ok(value) => results.push(value),
Err(e) => {
warn!("Failed to resolve entity in array: {}", e);
// Continue with other entities
}
}
}
Ok(Value::Array(results))
}
/// Find an entity linked to a source entity
async fn find_linked_entity(
&self,
source_id: &str,
target_kind: &str,
repository: &EntityRepository,
) -> Result<Entity> {
let entities = repository.list_by_kind(target_kind).await?;
// Find entity with linked_entity_id matching source_id
for entity in entities {
let linked_id = self
.get_field_by_path(&entity, "metadata.linked_entity_id")
.ok()
.and_then(|v| v.as_str().map(String::from));
if linked_id.as_deref() == Some(source_id) {
return Ok(entity);
}
}
Err(anyhow!(
"No {} entity found linked to {}",
target_kind,
source_id
))
}
/// Get field value from entity using dot notation path
/// Example: "metadata.name" → entity.metadata.name
fn get_field_by_path(&self, entity: &Entity, path: &str) -> Result<Value> {
// Convert entity to JSON for easy path traversal
let entity_json = entity_to_json(entity)?;
// Split path and traverse JSON
let parts: Vec<&str> = path.split('.').collect();
let mut current = &entity_json;
for part in parts {
current = current
.get(part)
.ok_or_else(|| anyhow!("Field not found in entity: {}", path))?;
}
Ok(current.clone())
}
/// Map all configured fields from entity to target format
/// Returns HashMap of field_name → mapped_value
pub async fn map_all(&self, entity: &Entity) -> Result<HashMap<String, Value>> {
let mut result = HashMap::new();
for (field_name, _) in &self.mappings {
match self.map(field_name, entity).await {
Ok(value) => {
result.insert(field_name.clone(), value);
}
Err(e) => {
// Log warning but don't fail entire mapping
warn!("Failed to map field {}: {}", field_name, e);
}
}
}
Ok(result)
}
/// Check if a field is mapped
pub fn has_mapping(&self, field_name: &str) -> bool {
self.mappings.contains_key(field_name)
}
}
/// Helper function to convert Entity to JSON Value
/// Works around prost_types::Timestamp not implementing serde traits
fn entity_to_json(entity: &Entity) -> Result<Value> {
use crate::charybdis::entities::entity;
use serde_json::json;
// Manual conversion to avoid prost_types::Timestamp serde issues
let mut entity_obj = serde_json::Map::new();
entity_obj.insert("id".to_string(), json!(&entity.id));
entity_obj.insert("kind".to_string(), json!(&entity.kind));
// Add metadata if present - extract fields based on metadata type
if let Some(ref metadata) = entity.metadata {
let mut metadata_obj = serde_json::Map::new();
match metadata {
entity::Metadata::ServiceMetadata(m) => {
metadata_obj.insert("name".to_string(), json!(&m.name));
if !m.namespace.is_empty() {
metadata_obj.insert("namespace".to_string(), json!(&m.namespace));
}
if !m.description.is_empty() {
metadata_obj.insert("description".to_string(), json!(&m.description));
}
// ServiceMetadata labels is repeated string (Vec<String>)
if !m.labels.is_empty() {
metadata_obj.insert("labels".to_string(), json!(&m.labels));
}
if !m.tags.is_empty() {
metadata_obj.insert("tags".to_string(), json!(&m.tags));
}
}
entity::Metadata::SystemMetadata(m) => {
metadata_obj.insert("name".to_string(), json!(&m.name));
if !m.namespace.is_empty() {
metadata_obj.insert("namespace".to_string(), json!(&m.namespace));
}
if !m.description.is_empty() {
metadata_obj.insert("description".to_string(), json!(&m.description));
}
if !m.labels.is_empty() {
metadata_obj.insert("labels".to_string(), json!(&m.labels));
}
if !m.tags.is_empty() {
metadata_obj.insert("tags".to_string(), json!(&m.tags));
}
}
entity::Metadata::ComponentMetadata(m) => {
metadata_obj.insert("name".to_string(), json!(&m.name));
if !m.namespace.is_empty() {
metadata_obj.insert("namespace".to_string(), json!(&m.namespace));
}
if !m.description.is_empty() {
metadata_obj.insert("description".to_string(), json!(&m.description));
}
if !m.labels.is_empty() {
metadata_obj.insert("labels".to_string(), json!(&m.labels));
}
if !m.tags.is_empty() {
metadata_obj.insert("tags".to_string(), json!(&m.tags));
}
}
entity::Metadata::ApiMetadata(m) => {
metadata_obj.insert("name".to_string(), json!(&m.name));
if !m.namespace.is_empty() {
metadata_obj.insert("namespace".to_string(), json!(&m.namespace));
}
if !m.description.is_empty() {
metadata_obj.insert("description".to_string(), json!(&m.description));
}
if !m.labels.is_empty() {
metadata_obj.insert("labels".to_string(), json!(&m.labels));
}
if !m.tags.is_empty() {
metadata_obj.insert("tags".to_string(), json!(&m.tags));
}
}
entity::Metadata::UserMetadata(m) => {
metadata_obj.insert("name".to_string(), json!(&m.name));
if !m.namespace.is_empty() {
metadata_obj.insert("namespace".to_string(), json!(&m.namespace));
}
if !m.description.is_empty() {
metadata_obj.insert("description".to_string(), json!(&m.description));
}
if !m.labels.is_empty() {
metadata_obj.insert("labels".to_string(), json!(&m.labels));
}
if !m.tags.is_empty() {
metadata_obj.insert("tags".to_string(), json!(&m.tags));
}
}
entity::Metadata::GroupMetadata(m) => {
metadata_obj.insert("name".to_string(), json!(&m.name));
if !m.namespace.is_empty() {
metadata_obj.insert("namespace".to_string(), json!(&m.namespace));
}
if !m.description.is_empty() {
metadata_obj.insert("description".to_string(), json!(&m.description));
}
if !m.labels.is_empty() {
metadata_obj.insert("labels".to_string(), json!(&m.labels));
}
if !m.tags.is_empty() {
metadata_obj.insert("tags".to_string(), json!(&m.tags));
}
}
entity::Metadata::DomainMetadata(m) => {
metadata_obj.insert("name".to_string(), json!(&m.name));
if !m.namespace.is_empty() {
metadata_obj.insert("namespace".to_string(), json!(&m.namespace));
}
if !m.description.is_empty() {
metadata_obj.insert("description".to_string(), json!(&m.description));
}
if !m.labels.is_empty() {
metadata_obj.insert("labels".to_string(), json!(&m.labels));
}
if !m.tags.is_empty() {
metadata_obj.insert("tags".to_string(), json!(&m.tags));
}
}
entity::Metadata::ResourceMetadata(m) => {
metadata_obj.insert("name".to_string(), json!(&m.name));
if !m.namespace.is_empty() {
metadata_obj.insert("namespace".to_string(), json!(&m.namespace));
}
if !m.description.is_empty() {
metadata_obj.insert("description".to_string(), json!(&m.description));
}
if !m.labels.is_empty() {
metadata_obj.insert("labels".to_string(), json!(&m.labels));
}
if !m.tags.is_empty() {
metadata_obj.insert("tags".to_string(), json!(&m.tags));
}
}
entity::Metadata::DefectdojoMetadata(_) => {
// Plugin type — skip for now
}
entity::Metadata::KeycloakMetadata(_) => {
// Plugin metadata type — skip
}
entity::Metadata::DependencytrackMetadata(_) => {
// Plugin metadata type — skip
}
entity::Metadata::FindingMetadata(m) => {
metadata_obj.insert("name".to_string(), json!(&m.title));
if !m.description.is_empty() {
metadata_obj.insert("description".to_string(), json!(&m.description));
}
}
}
entity_obj.insert("metadata".to_string(), Value::Object(metadata_obj));
}
// Add spec if present - extract fields based on spec type
if let Some(ref spec) = entity.spec {
let mut spec_obj = serde_json::Map::new();
match spec {
entity::Spec::ServiceSpec(s) => {
if !s.r#type.is_empty() {
spec_obj.insert("type".to_string(), json!(&s.r#type));
}
if !s.lifecycle.is_empty() {
spec_obj.insert("lifecycle".to_string(), json!(&s.lifecycle));
}
if !s.owner.is_empty() {
spec_obj.insert("owner".to_string(), json!(&s.owner));
}
if !s.system.is_empty() {
spec_obj.insert("system".to_string(), json!(&s.system));
}
if !s.subcomponent_of.is_empty() {
spec_obj.insert("subcomponent_of".to_string(), json!(&s.subcomponent_of));
}
if !s.depends_on.is_empty() {
spec_obj.insert("depends_on".to_string(), json!(&s.depends_on));
}
if !s.consumes_apis.is_empty() {
spec_obj.insert("consumes_apis".to_string(), json!(&s.consumes_apis));
}
if !s.provides_apis.is_empty() {
spec_obj.insert("provides_apis".to_string(), json!(&s.provides_apis));
}
}
entity::Spec::SystemSpec(s) => {
if !s.owner.is_empty() {
spec_obj.insert("owner".to_string(), json!(&s.owner));
}
if !s.domain.is_empty() {
spec_obj.insert("domain".to_string(), json!(&s.domain));
}
}
entity::Spec::ComponentSpec(s) => {
if !s.r#type.is_empty() {
spec_obj.insert("type".to_string(), json!(&s.r#type));
}
if !s.lifecycle.is_empty() {
spec_obj.insert("lifecycle".to_string(), json!(&s.lifecycle));
}
if !s.owner.is_empty() {
spec_obj.insert("owner".to_string(), json!(&s.owner));
}
if !s.system.is_empty() {
spec_obj.insert("system".to_string(), json!(&s.system));
}
if !s.subcomponent_of.is_empty() {
spec_obj.insert("subcomponent_of".to_string(), json!(&s.subcomponent_of));
}
if !s.depends_on.is_empty() {
spec_obj.insert("depends_on".to_string(), json!(&s.depends_on));
}
if !s.provides_apis.is_empty() {
spec_obj.insert("provides_apis".to_string(), json!(&s.provides_apis));
}
if !s.consumes_apis.is_empty() {
spec_obj.insert("consumes_apis".to_string(), json!(&s.consumes_apis));
}
}
entity::Spec::ApiSpec(s) => {
if !s.r#type.is_empty() {
spec_obj.insert("type".to_string(), json!(&s.r#type));
}
if !s.lifecycle.is_empty() {
spec_obj.insert("lifecycle".to_string(), json!(&s.lifecycle));
}
if !s.owner.is_empty() {
spec_obj.insert("owner".to_string(), json!(&s.owner));
}
if !s.system.is_empty() {
spec_obj.insert("system".to_string(), json!(&s.system));
}
if !s.definition.is_empty() {
spec_obj.insert("definition".to_string(), json!(&s.definition));
}
}
entity::Spec::UserSpec(s) => {
if let Some(profile) = &s.profile {
let mut profile_obj = serde_json::Map::new();
if !profile.display_name.is_empty() {
profile_obj.insert("display_name".to_string(), json!(&profile.display_name));
}
if !profile.email.is_empty() {
profile_obj.insert("email".to_string(), json!(&profile.email));
}
if !profile.picture.is_empty() {
profile_obj.insert("picture".to_string(), json!(&profile.picture));
}
if !profile_obj.is_empty() {
spec_obj.insert("profile".to_string(), Value::Object(profile_obj));
}
}
if !s.member_of.is_empty() {
spec_obj.insert("member_of".to_string(), json!(&s.member_of));
}
}
entity::Spec::GroupSpec(s) => {
if !s.r#type.is_empty() {
spec_obj.insert("type".to_string(), json!(&s.r#type));
}
if let Some(profile) = &s.profile {
let mut profile_obj = serde_json::Map::new();
if !profile.display_name.is_empty() {
profile_obj.insert("display_name".to_string(), json!(&profile.display_name));
}
if !profile.email.is_empty() {
profile_obj.insert("email".to_string(), json!(&profile.email));
}
if !profile.picture.is_empty() {
profile_obj.insert("picture".to_string(), json!(&profile.picture));
}
if !profile_obj.is_empty() {
spec_obj.insert("profile".to_string(), Value::Object(profile_obj));
}
}
if !s.parent.is_empty() {
spec_obj.insert("parent".to_string(), json!(&s.parent));
}
if !s.children.is_empty() {
spec_obj.insert("children".to_string(), json!(&s.children));
}
if !s.members.is_empty() {
spec_obj.insert("members".to_string(), json!(&s.members));
}
}
entity::Spec::DomainSpec(s) => {
if !s.owner.is_empty() {
spec_obj.insert("owner".to_string(), json!(&s.owner));
}
}
entity::Spec::ResourceSpec(s) => {
if !s.r#type.is_empty() {
spec_obj.insert("type".to_string(), json!(&s.r#type));
}
if !s.owner.is_empty() {
spec_obj.insert("owner".to_string(), json!(&s.owner));
}
if !s.system.is_empty() {
spec_obj.insert("system".to_string(), json!(&s.system));
}
if !s.depends_on.is_empty() {
spec_obj.insert("depends_on".to_string(), json!(&s.depends_on));
}
}
entity::Spec::DefectdojoSpec(_) => {
// Plugin type — skip for now
}
entity::Spec::KeycloakSpec(_) => {
// Plugin spec type — skip
}
entity::Spec::DependencytrackSpec(_) => {
// Plugin spec type — skip
}
entity::Spec::FindingSpec(s) => {
spec_obj.insert("component_ref".to_string(), json!(&s.component_ref));
spec_obj.insert("lifecycle".to_string(), json!(&s.lifecycle));
spec_obj.insert("scanner".to_string(), json!(&s.scanner));
spec_obj.insert("rule_id".to_string(), json!(&s.rule_id));
}
}
entity_obj.insert("spec".to_string(), Value::Object(spec_obj));
}
// Add annotations if present
if !entity.annotations.is_empty() {
entity_obj.insert("annotations".to_string(), json!(&entity.annotations));
}
Ok(Value::Object(entity_obj))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::charybdis::core::ComponentMetadata;
use crate::charybdis::entities::entity;
use serde_json::json;
fn create_test_entity(id: &str, kind: &str) -> Entity {
Entity {
id: id.to_string(),
kind: kind.to_string(),
metadata: Some(entity::Metadata::ComponentMetadata(ComponentMetadata {
name: "test-component".to_string(),
description: "Test description".to_string(),
..Default::default()
})),
spec: None,
annotations: Default::default(),
created_at: None,
updated_at: None,
}
}
#[tokio::test]
async fn test_direct_field_mapping() {
let config = HashMap::from([
("name".to_string(), json!("metadata.name")),
("description".to_string(), json!("metadata.description")),
]);
let mapper = FieldMapper::new(config).unwrap();
let entity = create_test_entity("test-123", "Component");
let name = mapper.map("name", &entity).await.unwrap();
assert_eq!(name, json!("test-component"));
let desc = mapper.map("description", &entity).await.unwrap();
assert_eq!(desc, json!("Test description"));
}
#[tokio::test]
async fn test_static_field_mapping() {
let config = HashMap::from([
(
"product_type".to_string(),
json!({"value": "Web Application"}),
),
("is_active".to_string(), json!({"value": true})),
("priority".to_string(), json!({"value": 100})),
]);
let mapper = FieldMapper::new(config).unwrap();
let entity = create_test_entity("test-123", "Component");
assert_eq!(
mapper.map("product_type", &entity).await.unwrap(),
json!("Web Application")
);
assert_eq!(mapper.map("is_active", &entity).await.unwrap(), json!(true));
assert_eq!(mapper.map("priority", &entity).await.unwrap(), json!(100));
}
#[tokio::test]
async fn test_map_all() {
let config = HashMap::from([
("name".to_string(), json!("metadata.name")),
("type".to_string(), json!({"value": "Web Application"})),
]);
let mapper = FieldMapper::new(config).unwrap();
let entity = create_test_entity("test-123", "Component");
let mapped = mapper.map_all(&entity).await.unwrap();
assert_eq!(mapped.get("name").unwrap(), &json!("test-component"));
assert_eq!(mapped.get("type").unwrap(), &json!("Web Application"));
}
#[tokio::test]
async fn test_complex_mapping_parse() {
let config = HashMap::from([(
"members".to_string(),
json!({
"from": "spec.owner",
"resolve_entity": "Group",
"extract": "spec.members"
}),
)]);
let mapper = FieldMapper::new(config).unwrap();
assert!(mapper.has_mapping("members"));
}
}