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, repository: Option>, } /// 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, /// Optional: Find linked entity of this kind #[serde(skip_serializing_if = "Option::is_none")] pub lookup_entity: Option, /// Optional: Extract field from resolved entity #[serde(skip_serializing_if = "Option::is_none")] pub extract: Option, /// 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, } impl FieldMapper { /// Create a new field mapper from configuration /// Config format: { "target_field": "source.field" or complex mapping } pub fn new(config: HashMap) -> Result { 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) -> Self { self.repository = Some(repository); self } /// Parse a mapping value from config fn parse_mapping(value: Value) -> Result { // 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 { 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 { // 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 { 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 { 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 { 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 { // 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> { 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 { 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) 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")); } }