001/* 002 * jPOS Project [http://jpos.org] 003 * Copyright (C) 2000-2026 jPOS Software SRL 004 * 005 * This program is free software: you can redistribute it and/or modify 006 * it under the terms of the GNU Affero General Public License as 007 * published by the Free Software Foundation, either version 3 of the 008 * License, or (at your option) any later version. 009 * 010 * This program is distributed in the hope that it will be useful, 011 * but WITHOUT ANY WARRANTY; without even the implied warranty of 012 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 013 * GNU Affero General Public License for more details. 014 * 015 * You should have received a copy of the GNU Affero General Public License 016 * along with this program. If not, see <http://www.gnu.org/licenses/>. 017 */ 018 019package org.jpos.core; 020 021import java.util.ArrayList; 022import java.util.List; 023import java.util.function.Predicate; 024import java.util.regex.Pattern; 025 026/** 027 * Declarative configuration validation framework. 028 * 029 * <p>Provides a fluent API for defining validation rules for jPOS configuration properties. 030 * 031 * <p>Example usage: 032 * <pre> 033 * ConfigValidator validator = new ConfigValidator() 034 * .required("bootstrap-servers") 035 * .required("app-id") 036 * .range("startup-timeout-seconds", 1, 3600) 037 * .range("max-retries", 0, 10) 038 * .nonEmpty("topic") 039 * .pattern("group-id", "^[a-zA-Z0-9._-]+$") 040 * .custom("processing-guarantee", 041 * v -> v.equals("at_least_once") || v.equals("exactly_once"), 042 * "must be 'at_least_once' or 'exactly_once'"); 043 * 044 * validator.validate(config); 045 * </pre> 046 * 047 * @author apr 048 * @since 3.0.2 049 */ 050public class ConfigValidator { 051 /** Default constructor; no instance state to initialise. */ 052 public ConfigValidator() {} 053 private final List<ValidationRule> rules = new ArrayList<>(); 054 055 /** 056 * Validate that a property is present and non-empty. 057 * @param key the property key 058 * @return this validator for chaining 059 */ 060 public ConfigValidator required(String key) { 061 rules.add(new ValidationRule(key, config -> { 062 String value = config.get(key, null); 063 if (value == null || value.trim().isEmpty()) { 064 throw new ConfigurationException("Required property '" + key + "' is missing or empty"); 065 } 066 })); 067 return this; 068 } 069 070 /** 071 * Validate that a property, if present, is non-empty. 072 * @param key the property key 073 * @return this validator for chaining 074 */ 075 public ConfigValidator nonEmpty(String key) { 076 rules.add(new ValidationRule(key, config -> { 077 String value = config.get(key, null); 078 if (value != null && value.trim().isEmpty()) { 079 throw new ConfigurationException("Property '" + key + "' must not be empty"); 080 } 081 })); 082 return this; 083 } 084 085 /** 086 * Validate that an integer property is within a specified range. 087 * Only validates if the property is present. 088 * @param key the property key 089 * @param min minimum value (inclusive) 090 * @param max maximum value (inclusive) 091 * @return this validator for chaining 092 */ 093 public ConfigValidator range(String key, int min, int max) { 094 rules.add(new ValidationRule(key, config -> { 095 String strValue = config.get(key, null); 096 if (strValue != null && !strValue.trim().isEmpty()) { 097 try { 098 int value = Integer.parseInt(strValue.trim()); 099 if (value < min || value > max) { 100 throw new ConfigurationException( 101 "Property '" + key + "' must be between " + min + " and " + max + ": " + value 102 ); 103 } 104 } catch (NumberFormatException e) { 105 throw new ConfigurationException("Property '" + key + "' must be an integer: " + strValue); 106 } 107 } 108 })); 109 return this; 110 } 111 112 /** 113 * Validate that a long property is within a specified range. 114 * Only validates if the property is present. 115 * @param key the property key 116 * @param min minimum value (inclusive) 117 * @param max maximum value (inclusive) 118 * @return this validator for chaining 119 */ 120 public ConfigValidator rangeLong(String key, long min, long max) { 121 rules.add(new ValidationRule(key, config -> { 122 String strValue = config.get(key, null); 123 if (strValue != null && !strValue.trim().isEmpty()) { 124 try { 125 long value = Long.parseLong(strValue.trim()); 126 if (value < min || value > max) { 127 throw new ConfigurationException( 128 "Property '" + key + "' must be between " + min + " and " + max + ": " + value 129 ); 130 } 131 } catch (NumberFormatException e) { 132 throw new ConfigurationException("Property '" + key + "' must be a long: " + strValue); 133 } 134 } 135 })); 136 return this; 137 } 138 139 /** 140 * Validate that a double property is within a specified range. 141 * Only validates if the property is present. 142 * @param key the property key 143 * @param min minimum value (inclusive) 144 * @param max maximum value (inclusive) 145 * @return this validator for chaining 146 */ 147 public ConfigValidator rangeDouble(String key, double min, double max) { 148 rules.add(new ValidationRule(key, config -> { 149 String strValue = config.get(key, null); 150 if (strValue != null && !strValue.trim().isEmpty()) { 151 try { 152 double value = Double.parseDouble(strValue.trim()); 153 if (value < min || value > max) { 154 throw new ConfigurationException( 155 "Property '" + key + "' must be between " + min + " and " + max + ": " + value 156 ); 157 } 158 } catch (NumberFormatException e) { 159 throw new ConfigurationException("Property '" + key + "' must be a double: " + strValue); 160 } 161 } 162 })); 163 return this; 164 } 165 166 /** 167 * Validate that a property matches a regex pattern. 168 * Only validates if the property is present. 169 * @param key the property key 170 * @param regex the regular expression pattern 171 * @return this validator for chaining 172 */ 173 public ConfigValidator pattern(String key, String regex) { 174 Pattern compiled = Pattern.compile(regex); 175 rules.add(new ValidationRule(key, config -> { 176 String value = config.get(key, null); 177 if (value != null && !value.trim().isEmpty()) { 178 if (!compiled.matcher(value.trim()).matches()) { 179 throw new ConfigurationException( 180 "Property '" + key + "' does not match pattern '" + regex + "': " + value 181 ); 182 } 183 } 184 })); 185 return this; 186 } 187 188 /** 189 * Validate a property using a custom predicate. 190 * Only validates if the property is present. 191 * @param key the property key 192 * @param predicate the validation predicate (returns true if valid) 193 * @param errorMessage error message if validation fails 194 * @return this validator for chaining 195 */ 196 public ConfigValidator custom(String key, Predicate<String> predicate, String errorMessage) { 197 rules.add(new ValidationRule(key, config -> { 198 String value = config.get(key, null); 199 if (value != null && !value.trim().isEmpty()) { 200 if (!predicate.test(value.trim())) { 201 throw new ConfigurationException("Property '" + key + "' " + errorMessage + ": " + value); 202 } 203 } 204 })); 205 return this; 206 } 207 208 /** 209 * Validate that at least one of the specified properties is present. 210 * @param keys the property keys 211 * @return this validator for chaining 212 */ 213 public ConfigValidator requireAtLeastOne(String... keys) { 214 rules.add(new ValidationRule("requireAtLeastOne", config -> { 215 for (String key : keys) { 216 String value = config.get(key, null); 217 if (value != null && !value.trim().isEmpty()) { 218 return; 219 } 220 } 221 throw new ConfigurationException( 222 "At least one of these properties must be present: " + String.join(", ", keys) 223 ); 224 })); 225 return this; 226 } 227 228 /** 229 * Validate that exactly one of the specified properties is present. 230 * @param keys the property keys 231 * @return this validator for chaining 232 */ 233 public ConfigValidator requireExactlyOne(String... keys) { 234 rules.add(new ValidationRule("requireExactlyOne", config -> { 235 int count = 0; 236 for (String key : keys) { 237 String value = config.get(key, null); 238 if (value != null && !value.trim().isEmpty()) { 239 count++; 240 } 241 } 242 if (count == 0) { 243 throw new ConfigurationException( 244 "Exactly one of these properties must be present: " + String.join(", ", keys) 245 ); 246 } else if (count > 1) { 247 throw new ConfigurationException( 248 "Only one of these properties can be present: " + String.join(", ", keys) 249 ); 250 } 251 })); 252 return this; 253 } 254 255 /** 256 * Validate that a boolean property has a valid value. 257 * Accepted values: true, false, yes, no, 1, 0 (case-insensitive). 258 * Only validates if the property is present. 259 * @param key the property key 260 * @return this validator for chaining 261 */ 262 public ConfigValidator validBoolean(String key) { 263 rules.add(new ValidationRule(key, config -> { 264 String value = config.get(key, null); 265 if (value != null && !value.trim().isEmpty()) { 266 String v = value.trim().toLowerCase(); 267 if (!v.equals("true") && !v.equals("false") && 268 !v.equals("yes") && !v.equals("no") && 269 !v.equals("1") && !v.equals("0")) { 270 throw new ConfigurationException( 271 "Property '" + key + "' must be a boolean (true/false/yes/no/1/0): " + value 272 ); 273 } 274 } 275 })); 276 return this; 277 } 278 279 /** 280 * Validate the configuration. 281 * @param config the configuration to validate 282 * @throws ConfigurationException if validation fails 283 */ 284 public void validate(Configuration config) throws ConfigurationException { 285 for (ValidationRule rule : rules) { 286 rule.validate(config); 287 } 288 } 289 290 /** 291 * Internal validation rule. 292 */ 293 private static class ValidationRule { 294 private final String key; 295 private final ValidationFunction validator; 296 297 ValidationRule(String key, ValidationFunction validator) { 298 this.key = key; 299 this.validator = validator; 300 } 301 302 void validate(Configuration config) throws ConfigurationException { 303 validator.validate(config); 304 } 305 } 306 307 /** 308 * Validation function interface. 309 */ 310 @FunctionalInterface 311 private interface ValidationFunction { 312 void validate(Configuration config) throws ConfigurationException; 313 } 314}