// file: ks-config/src/composition.rs // version: 3 //! Binary composition configuration and resolution into the shared runtime profile contract. const COMPOSITION_JSON_SCHEMA: &str = include_str!("../../config/schemas/composition.config.schema.json"); const DEFAULT_LOGGING_CONFIG_PATH: &str = "config/logging.config.json"; const DEFAULT_TRANSPORT_CONFIG_PATH: &str = "config/transport.config.json"; const DEFAULT_LISTENERS_CONFIG_PATH: &str = "config/listeners.config.json"; const DEFAULT_STORE_CONFIG_PATH: &str = "config/store.config.json"; const DEFAULT_WALLET_CONFIG_PATH: &str = "config/wallet.config.json"; const DEFAULT_EXECUTION_CONFIG_PATH: &str = "config/execution.config.json"; /// Root composition document used by a binary to select shared configuration profiles. #[derive(Clone, serde::Deserialize, Eq, PartialEq)] pub struct CompositionConfigDocument { /// Active binary composition profile name. pub active_profile: std::string::String, /// Shared configuration documents referenced by this composition. pub sources: CompositionConfigSources, /// Named binary composition profiles. pub profiles: std::vec::Vec, } /// Paths of shared configuration documents consumed by one binary composition. #[derive(Clone, serde::Deserialize, Eq, PartialEq)] pub struct CompositionConfigSources { /// Logging configuration document path relative to the workspace root unless absolute. pub logging: std::string::String, /// Transport configuration document path relative to the workspace root unless absolute. pub transport: std::string::String, /// Listener configuration document path relative to the workspace root unless absolute. pub listeners: std::string::String, /// Store configuration document path relative to the workspace root unless absolute. pub store: std::string::String, /// Wallet configuration document path relative to the workspace root unless absolute. pub wallet: std::string::String, /// Execution configuration document path relative to the workspace root unless absolute. pub execution: std::string::String, } /// Named composition profile with optional overrides of each shared document default. #[derive(Clone, serde::Deserialize, Eq, PartialEq)] pub struct CompositionProfileConfig { /// Composition profile code. pub name: std::string::String, /// Optional binary-owned application settings opaque to the shared composition layer. pub application: std::option::Option, /// Optional logging profile override. pub logging_profile: std::option::Option, /// Optional transport profile override. pub transport_profile: std::option::Option, /// Optional listener profile override. pub listeners_profile: std::option::Option, /// Optional store profile override. pub store_profile: std::option::Option, /// Optional wallet profile override. pub wallet_profile: std::option::Option, /// Optional execution profile override. pub execution_profile: std::option::Option, } /// Resolved application configuration and the source documents needed by a binary. #[derive(Clone, Eq, PartialEq)] pub struct ComposedAppConfig { /// Parsed binary composition document. pub composition: CompositionConfigDocument, /// Resolved runtime application configuration. pub app_config: crate::AppConfig, /// Resolved logging source path. pub logging_path: std::path::PathBuf, /// Resolved transport source path. pub transport_path: std::path::PathBuf, /// Resolved listener source path. pub listeners_path: std::path::PathBuf, /// Resolved store source path. pub store_path: std::path::PathBuf, /// Resolved wallet source path. pub wallet_path: std::path::PathBuf, /// Resolved execution source path. pub execution_path: std::path::PathBuf, } /// Returns the embedded binary composition JSON Schema text. pub fn composition_json_schema_text() -> &'static str { return COMPOSITION_JSON_SCHEMA; } /// Parses the embedded binary composition JSON Schema into a JSON value. pub fn composition_json_schema_value() -> ks_core::Result { let result = serde_json::from_str::(COMPOSITION_JSON_SCHEMA); return match result { std::result::Result::Ok(value) => std::result::Result::Ok(value), std::result::Result::Err(error) => std::result::Result::Err(ks_core::Error::new( "composition_config_schema_parse_failed", error.to_string(), )), }; } /// Validates raw binary composition JSON against its embedded schema. pub fn validate_composition_json_schema(raw_json: &str) -> ks_core::Result<()> { let schema = match composition_json_schema_value() { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let instance = match serde_json::from_str::(raw_json) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => { return std::result::Result::Err(ks_core::Error::new( "composition_config_json_parse_failed", error.to_string(), )); }, }; let validator = match jsonschema::validator_for(&schema) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => { return std::result::Result::Err(ks_core::Error::new( "composition_config_schema_compile_failed", error.to_string(), )); }, }; return match validator.validate(&instance) { std::result::Result::Ok(()) => std::result::Result::Ok(()), std::result::Result::Err(_) => std::result::Result::Err(ks_core::Error::new( "composition_config_schema_validation_failed", "composition configuration does not satisfy its schema", )), }; } /// Parses and validates one binary composition document. pub fn parse_composition_json(raw_json: &str) -> ks_core::Result { match validate_composition_json_schema(raw_json) { std::result::Result::Ok(()) => (), std::result::Result::Err(error) => return std::result::Result::Err(error), } let document = match serde_json::from_str::(raw_json) { std::result::Result::Ok(value) => value, std::result::Result::Err(_) => { return std::result::Result::Err(ks_core::Error::new( "composition_config_json_decode_failed", "composition configuration could not be decoded", )); }, }; return match validate_composition_document(&document) { std::result::Result::Ok(()) => std::result::Result::Ok(document), std::result::Result::Err(error) => std::result::Result::Err(error), }; } /// Reads and parses one binary composition document from a filesystem path. pub fn read_composition_json_file( path: &std::path::Path, ) -> ks_core::Result { let raw_json = match std::fs::read_to_string(path) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => { return std::result::Result::Err(ks_core::Error::new( "composition_config_file_read_failed", error.to_string(), )); }, }; return parse_composition_json(&raw_json); } /// Loads workspace environment values, resolves placeholders and parses one binary composition document. pub fn read_composition_json_file_with_environment( path: &std::path::Path, workspace_root: &std::path::Path, ) -> ks_core::Result { match crate::load_workspace_environment(workspace_root) { std::result::Result::Ok(_) => (), std::result::Result::Err(error) => return std::result::Result::Err(error), } let raw_json = match std::fs::read_to_string(path) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => { return std::result::Result::Err(ks_core::Error::new( "composition_config_file_read_failed", error.to_string(), )); }, }; return parse_composition_json(&crate::resolve_environment_placeholders(&raw_json)); } /// Returns one named binary composition profile. pub fn composition_profile<'a>( document: &'a CompositionConfigDocument, profile_name: &str, ) -> ks_core::Result<&'a CompositionProfileConfig> { for profile in &document.profiles { if profile.name == profile_name { return std::result::Result::Ok(profile); } } return std::result::Result::Err(ks_core::Error::new( "composition_profile_not_found", profile_name.to_string(), )); } /// Returns the active binary composition profile. pub fn active_composition_profile( document: &CompositionConfigDocument, ) -> ks_core::Result<&CompositionProfileConfig> { return composition_profile(document, document.active_profile.as_str()); } /// Resolves a source path relative to the workspace root unless already absolute. pub fn resolve_composition_source_path( workspace_root: &std::path::Path, configured_path: &str, ) -> std::path::PathBuf { let path = std::path::PathBuf::from(configured_path); if path.is_absolute() { return path; } return workspace_root.join(path); } /// Resolves one optional composition override against a shared document default profile. pub fn resolve_profile_selection<'a>( override_name: std::option::Option<&'a str>, default_profile: &'a str, ) -> &'a str { return match override_name { std::option::Option::Some(value) if !value.trim().is_empty() => value, _ => default_profile, }; } /// Resolves all shared source selections into the runtime application contract. pub fn compose_app_config( document: &CompositionConfigDocument, transport: &crate::TransportConfigDocument, listeners: &crate::ListenersConfigDocument, store: &crate::StoreConfigDocument, wallet: &crate::WalletConfigDocument, execution: &crate::ExecutionConfigDocument, ) -> ks_core::Result { let mut profiles = std::vec::Vec::::new(); for profile in &document.profiles { let transport_name = resolve_profile_selection( profile.transport_profile.as_deref(), transport.default_profile.as_str(), ); let listeners_name = resolve_profile_selection( profile.listeners_profile.as_deref(), listeners.default_profile.as_str(), ); let store_name = resolve_profile_selection( profile.store_profile.as_deref(), store.default_profile.as_str(), ); let wallet_name = resolve_profile_selection( profile.wallet_profile.as_deref(), wallet.default_profile.as_str(), ); let execution_name = resolve_profile_selection( profile.execution_profile.as_deref(), execution.default_profile.as_str(), ); let transport_profile = match crate::transport_profile(transport, transport_name) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let listeners_profile = match crate::listeners_profile(listeners, listeners_name) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let store_profile = match crate::store_profile(store, store_name) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let wallet_profile = match crate::resolved_wallet_profile(wallet, wallet_name) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let execution_profile = match crate::execution_profile(execution, execution_name) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; profiles.push(crate::ProfileConfig { name: profile.name.clone(), database: store_profile.database.clone(), solana: crate::SolanaConfig { http_endpoints: transport_profile.http_endpoints, ws_endpoints: transport_profile.ws_endpoints, listeners: crate::resolved_listener_config(listeners_profile), }, wallet: wallet_profile, execution: execution_profile.config.clone(), }); } let config = crate::AppConfig { active_profile: document.active_profile.clone(), profiles, }; return match crate::validate_config(&config) { std::result::Result::Ok(()) => std::result::Result::Ok(config), std::result::Result::Err(error) => std::result::Result::Err(error), }; } /// Reads a binary composition and all referenced shared documents into the runtime contract. pub fn read_composed_app_config_with_environment( composition_path: &std::path::Path, workspace_root: &std::path::Path, ) -> ks_core::Result { let composition = match read_composition_json_file_with_environment(composition_path, workspace_root) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let logging_path = resolve_composition_source_path(workspace_root, composition.sources.logging.as_str()); let transport_path = resolve_composition_source_path(workspace_root, composition.sources.transport.as_str()); let listeners_path = resolve_composition_source_path(workspace_root, composition.sources.listeners.as_str()); let store_path = resolve_composition_source_path(workspace_root, composition.sources.store.as_str()); let wallet_path = resolve_composition_source_path(workspace_root, composition.sources.wallet.as_str()); let execution_path = resolve_composition_source_path(workspace_root, composition.sources.execution.as_str()); let transport = match crate::read_transport_json_file_with_environment(&transport_path, workspace_root) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let listeners = match crate::read_listeners_json_file_with_environment(&listeners_path, workspace_root) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let store = match crate::read_store_json_file_with_environment(&store_path, workspace_root) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let wallet = match crate::read_wallet_json_file_with_environment(&wallet_path, workspace_root) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let execution = match crate::read_execution_json_file_with_environment(&execution_path, workspace_root) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; match validate_logging_references_with_environment(&logging_path, workspace_root, &composition) { std::result::Result::Ok(()) => (), std::result::Result::Err(error) => return std::result::Result::Err(error), } let app_config = match compose_app_config(&composition, &transport, &listeners, &store, &wallet, &execution) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; return std::result::Result::Ok(ComposedAppConfig { composition, app_config, logging_path, transport_path, listeners_path, store_path, wallet_path, execution_path, }); } /// Loads canonical shared documents and composes their independent default profiles without an application composition file. pub fn read_default_shared_app_config_with_environment( workspace_root: &std::path::Path, ) -> ks_core::Result { let transport_path = workspace_root.join(DEFAULT_TRANSPORT_CONFIG_PATH); let listeners_path = workspace_root.join(DEFAULT_LISTENERS_CONFIG_PATH); let store_path = workspace_root.join(DEFAULT_STORE_CONFIG_PATH); let wallet_path = workspace_root.join(DEFAULT_WALLET_CONFIG_PATH); let execution_path = workspace_root.join(DEFAULT_EXECUTION_CONFIG_PATH); let transport = match crate::read_transport_json_file_with_environment(&transport_path, workspace_root) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let listeners = match crate::read_listeners_json_file_with_environment(&listeners_path, workspace_root) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let store = match crate::read_store_json_file_with_environment(&store_path, workspace_root) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let wallet = match crate::read_wallet_json_file_with_environment(&wallet_path, workspace_root) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let execution = match crate::read_execution_json_file_with_environment(&execution_path, workspace_root) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; let defaults = [ transport.default_profile.as_str(), listeners.default_profile.as_str(), store.default_profile.as_str(), wallet.default_profile.as_str(), execution.default_profile.as_str(), ]; let profile_name = if defaults.iter().all(|value| return *value == defaults[0]) { defaults[0].to_string() } else { "shared_default".to_string() }; let composition = CompositionConfigDocument { active_profile: profile_name.clone(), sources: CompositionConfigSources { logging: DEFAULT_LOGGING_CONFIG_PATH.to_string(), transport: DEFAULT_TRANSPORT_CONFIG_PATH.to_string(), listeners: DEFAULT_LISTENERS_CONFIG_PATH.to_string(), store: DEFAULT_STORE_CONFIG_PATH.to_string(), wallet: DEFAULT_WALLET_CONFIG_PATH.to_string(), execution: DEFAULT_EXECUTION_CONFIG_PATH.to_string(), }, profiles: vec![CompositionProfileConfig { name: profile_name.clone(), application: std::option::Option::None, logging_profile: std::option::Option::None, transport_profile: std::option::Option::None, listeners_profile: std::option::Option::None, store_profile: std::option::Option::None, wallet_profile: std::option::Option::None, execution_profile: std::option::Option::None, }], }; return compose_app_config(&composition, &transport, &listeners, &store, &wallet, &execution); } /// Validates a typed binary composition document before referenced documents are loaded. pub fn validate_composition_document(document: &CompositionConfigDocument) -> ks_core::Result<()> { match crate::require_non_empty(&document.active_profile, "composition.active_profile") { std::result::Result::Ok(()) => (), std::result::Result::Err(error) => return std::result::Result::Err(error), } for source in [ &document.sources.logging, &document.sources.transport, &document.sources.listeners, &document.sources.store, &document.sources.wallet, &document.sources.execution, ] { match crate::require_non_empty(source, "composition.sources") { std::result::Result::Ok(()) => (), std::result::Result::Err(error) => return std::result::Result::Err(error), } } if document.profiles.is_empty() { return std::result::Result::Err(ks_core::Error::new( "composition_profiles_empty", "at least one composition profile is required", )); } let mut names = std::collections::BTreeSet::::new(); for profile in &document.profiles { match crate::require_non_empty(&profile.name, "composition.profile.name") { std::result::Result::Ok(()) => (), std::result::Result::Err(error) => return std::result::Result::Err(error), } for reference in [ profile.logging_profile.as_ref(), profile.transport_profile.as_ref(), profile.listeners_profile.as_ref(), profile.store_profile.as_ref(), profile.wallet_profile.as_ref(), profile.execution_profile.as_ref(), ] { if let std::option::Option::Some(value) = reference && let std::result::Result::Err(error) = crate::require_non_empty(value, "composition.profile.reference") { return std::result::Result::Err(error); } } if !names.insert(profile.name.clone()) { return std::result::Result::Err(ks_core::Error::new( "composition_profile_name_duplicate", profile.name.clone(), )); } } return match active_composition_profile(document) { std::result::Result::Ok(_) => std::result::Result::Ok(()), std::result::Result::Err(error) => std::result::Result::Err(error), }; } fn validate_logging_references_with_environment( path: &std::path::Path, workspace_root: &std::path::Path, composition: &CompositionConfigDocument, ) -> ks_core::Result<()> { match crate::load_workspace_environment(workspace_root) { std::result::Result::Ok(_) => (), std::result::Result::Err(error) => return std::result::Result::Err(error), } let raw_json = match std::fs::read_to_string(path) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => { return std::result::Result::Err(ks_core::Error::new( "composition_logging_file_read_failed", error.to_string(), )); }, }; let resolved = crate::resolve_environment_placeholders(&raw_json); let value = match serde_json::from_str::(&resolved) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => { return std::result::Result::Err(ks_core::Error::new( "composition_logging_json_parse_failed", error.to_string(), )); }, }; let default_profile = match value.get("default_profile").and_then(serde_json::Value::as_str) { std::option::Option::Some(value) if !value.trim().is_empty() => value, _ => { return std::result::Result::Err(ks_core::Error::new( "composition_logging_default_profile_missing", path.display().to_string(), )); }, }; let profiles = match value.get("profiles").and_then(serde_json::Value::as_array) { std::option::Option::Some(value) => value, std::option::Option::None => { return std::result::Result::Err(ks_core::Error::new( "composition_logging_profiles_missing", path.display().to_string(), )); }, }; let mut names = std::collections::BTreeSet::::new(); for profile in profiles { if let std::option::Option::Some(name) = profile.get("name").and_then(serde_json::Value::as_str) { names.insert(name.to_string()); } } if !names.contains(default_profile) { return std::result::Result::Err(ks_core::Error::new( "composition_logging_default_profile_not_found", default_profile.to_string(), )); } for profile in &composition.profiles { let selected = resolve_profile_selection(profile.logging_profile.as_deref(), default_profile); if !names.contains(selected) { return std::result::Result::Err(ks_core::Error::new( "composition_logging_profile_not_found", selected.to_string(), )); } } return std::result::Result::Ok(()); } #[cfg(test)] mod tests { const DEFAULT_COMPOSITION: &str = include_str!("../../config/kb-app-demo-desktop.default.config.json"); const EXAMPLE_COMPOSITION: &str = include_str!("../../config/exemples/example.kb-app-demo-desktop.default.config.json"); #[test] fn default_and_example_compositions_validate() { assert!(super::parse_composition_json(DEFAULT_COMPOSITION).is_ok()); assert!(super::parse_composition_json(EXAMPLE_COMPOSITION).is_ok()); } #[test] fn local_devnet_composition_uses_shared_defaults() { let composition = match super::parse_composition_json(DEFAULT_COMPOSITION) { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => panic!("default composition must parse: {error}"), }; let profile = match super::composition_profile(&composition, "local_devnet") { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => { panic!("local Devnet composition must resolve: {error}") }, }; assert!(profile.application.is_some()); assert!(profile.logging_profile.is_none()); assert!(profile.transport_profile.is_none()); assert!(profile.store_profile.is_none()); } }