Create PSForever config framework (#283)

We can now parse arbitrary INI configuration entries. This will allow
server customization and testing without recompiling the server.
This commit is contained in:
pschord 2019-10-21 14:12:26 -04:00 committed by GitHub
parent 4b71d76cb2
commit c3d19b5377
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 647 additions and 5 deletions

View file

@ -0,0 +1,183 @@
// Copyright (c) 2019 PSForever
package net.psforever.config
import org.ini4j
import scala.reflect.ClassTag
import scala.annotation.implicitNotFound
import scala.concurrent.duration._
case class ConfigValueMapper[T](name: String)(f: (String => Option[T])) {
def apply(t: String): Option[T] = f(t)
}
object ConfigValueMapper {
implicit val toInt : ConfigValueMapper[Int] = ConfigValueMapper[Int]("toInt") { e =>
try {
Some(e.toInt)
} catch {
case e: Exception => None
}
}
implicit val toBool : ConfigValueMapper[Boolean] = ConfigValueMapper[Boolean]("toBool") { e =>
if (e == "yes") {
Some(true)
} else if (e == "no") {
Some(false)
} else {
None
}
}
implicit val toFloat : ConfigValueMapper[Float] = ConfigValueMapper[Float]("toFloat") { e =>
try {
Some(e.toFloat)
} catch {
case e: Exception => None
}
}
implicit val toDuration : ConfigValueMapper[Duration] = ConfigValueMapper[Duration]("toDuration") { e =>
try {
Some(Duration(e))
} catch {
case e: Exception => None
}
}
implicit val toStr : ConfigValueMapper[String] = ConfigValueMapper[String]("toString") { e =>
Some(e)
}
}
sealed trait ConfigEntry {
type Value
val key : String
val default : Value
def getType : String
val constraints : Seq[Constraint[Value]]
def read(v: String): Option[Value]
}
final case class ConfigEntryString(key: String, default : String, constraints : Constraint[String]*) extends ConfigEntry {
type Value = String
def getType = "String"
def read(v : String) = ConfigValueMapper.toStr(v)
}
final case class ConfigEntryInt(key: String, default : Int, constraints : Constraint[Int]*) extends ConfigEntry {
type Value = Int
def getType = "Int"
def read(v : String) = ConfigValueMapper.toInt(v)
}
final case class ConfigEntryBool(key: String, default : Boolean, constraints : Constraint[Boolean]*) extends ConfigEntry {
type Value = Boolean
def getType = "Bool"
def read(v : String) = ConfigValueMapper.toBool(v)
}
final case class ConfigEntryFloat(key: String, default : Float, constraints : Constraint[Float]*) extends ConfigEntry {
type Value = Float
def getType = "Float"
def read(v : String) = ConfigValueMapper.toFloat(v)
}
final case class ConfigEntryTime(key: String, default : Duration, constraints : Constraint[Duration]*) extends ConfigEntry {
type Value = Duration
def getType = "Time"
def read(v : String) = ConfigValueMapper.toDuration(v)
}
case class ConfigSection(name: String, entries: ConfigEntry*)
@implicitNotFound("Nothing was inferred")
sealed trait ConfigTypeRequired[-T]
object ConfigTypeRequired {
implicit object cfgTypeRequired extends ConfigTypeRequired[Any]
//We do not want Nothing to be inferred, so make an ambiguous implicit
implicit object `\n The Get[T] call needs a type T matching the corresponding ConfigEntry` extends ConfigTypeRequired[Nothing]
}
trait ConfigParser {
protected var config_map : Map[String, Any]
protected val config_template : Seq[ConfigSection]
// Misuse of this function can lead to run time exceptions when the types don't match
def Get[T : ConfigTypeRequired](key : String) : T = config_map(key).asInstanceOf[T]
def Load(filename : String) : ValidationResult = {
val ini = new org.ini4j.Ini()
config_map = Map()
try {
ini.load(new java.io.File(filename))
} catch {
case e : org.ini4j.InvalidFileFormatException =>
return Invalid(e.getMessage)
case e : java.io.FileNotFoundException =>
return Invalid(e.getMessage)
}
val result : Seq[ValidationResult] = config_template.map { section =>
val sectionIni = ini.get(section.name)
if (sectionIni == null)
Seq(Invalid("section.missing", section.name))
else
section.entries.map(parseSection(sectionIni, _))
}.reduceLeft((x, y) => x ++ y)
val errors : Seq[Invalid] = result.collect { case iv : Invalid => iv }
if (errors.length > 0)
errors.reduceLeft((x, y) => x ++ y)
else
// run post-parse validation only if we successfully parsed
postParseChecks
}
def FormatErrors(invalidResult : Invalid) : Seq[String] = {
var count = 0;
invalidResult.errors.map((error) => {
var message = error.message;
if (error.args.length > 0)
message += " ("+error.args(0)+")"
count += 1;
s"Error ${count}: ${message}"
});
}
protected def postParseChecks : ValidationResult = {
Valid
}
protected def parseSection(sectionIni : org.ini4j.Profile.Section, entry : ConfigEntry) : ValidationResult = {
var rawValue = sectionIni.get(entry.key)
val full_key = sectionIni.getName + "." + entry.key
val value = if (rawValue == null) {
// warn about defaults from unset parameters?
entry.default
} else {
rawValue = rawValue.stripPrefix("\"").stripSuffix("\"")
entry.read(rawValue) match {
case Some(v) => v
case None => return Invalid(ValidationError(String.format("%s: value format error (expected: %s)", full_key, entry.getType)))
}
}
config_map += (full_key -> value)
ParameterValidator(entry.constraints, Some(value)) match {
case v @ Valid => v
case i @ Invalid(errors) => {
Invalid(errors.map(x => ValidationError(x.messages.map(full_key + ": " + _), x.args: _*)))
}
}
}
}

View file

@ -0,0 +1,290 @@
// Copyright (c) 2019 PSForever
// Lifted from https://raw.githubusercontent.com/playframework/playframework/2.7.x/core/play/src/main/scala/play/api/data/validation/Validation.scala
package net.psforever.config
/**
* A form constraint.
*
* @tparam T type of values handled by this constraint
* @param name the constraint name, to be displayed to final user
* @param args the message arguments, to format the constraint name
* @param f the validation function
*/
case class Constraint[-T](name: Option[String], args: Seq[Any])(f: (T => ValidationResult)) {
/**
* Run the constraint validation.
*
* @param t the value to validate
* @return the validation result
*/
def apply(t: T): ValidationResult = f(t)
}
/**
* This object provides helpers for creating `Constraint` values.
*
* For example:
* {{{
* val negative = Constraint[Int] {
* case i if i < 0 => Valid
* case _ => Invalid("Must be a negative number.")
* }
* }}}
*/
object Constraint {
/**
* Creates a new anonymous constraint from a validation function.
*
* @param f the validation function
* @return a constraint
*/
def apply[T](f: (T => ValidationResult)): Constraint[T] = apply(None, Nil)(f)
/**
* Creates a new named constraint from a validation function.
*
* @param name the constraint name
* @param args the constraint arguments, used to format the constraint name
* @param f the validation function
* @return a constraint
*/
def apply[T](name: String, args: Any*)(f: (T => ValidationResult)): Constraint[T] = apply(Some(name), args.toSeq)(f)
}
/**
* Defines a set of built-in constraints.
*/
object Constraints extends Constraints
/**
* Defines a set of built-in constraints.
*
* @define emailAddressDoc Defines an emailAddress constraint for `String` values which will validate email addresses.
*
* '''name'''[constraint.email]
* '''error'''[error.email]
*
* @define nonEmptyDoc Defines a required constraint for `String` values, i.e. one in which empty strings are invalid.
*
* '''name'''[constraint.required]
* '''error'''[error.required]
*/
trait Constraints {
private val emailRegex =
"""^[a-zA-Z0-9\.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$""".r
/**
* $emailAddressDoc
*/
def emailAddress(errorMessage: String = "error.email"): Constraint[String] = Constraint[String]("constraint.email") {
e =>
if (e == null) Invalid(ValidationError(errorMessage))
else if (e.trim.isEmpty) Invalid(ValidationError(errorMessage))
else
emailRegex
.findFirstMatchIn(e)
.map(_ => Valid)
.getOrElse(Invalid(ValidationError(errorMessage)))
}
/**
* $emailAddressDoc
*
*/
def emailAddress: Constraint[String] = emailAddress()
/**
* $nonEmptyDoc
*/
def nonEmpty(errorMessage: String = "error.required"): Constraint[String] =
Constraint[String]("constraint.required") { o =>
if (o == null) Invalid(ValidationError(errorMessage))
else if (o.trim.isEmpty) Invalid(ValidationError(errorMessage))
else Valid
}
/**
* $nonEmptyDoc
*
*/
def nonEmpty: Constraint[String] = nonEmpty()
/**
* Defines a minimum value for `Ordered` values, by default the value must be greater than or equal to the constraint parameter
*
* '''name'''[constraint.min(minValue)]
* '''error'''[error.min(minValue)] or [error.min.strict(minValue)]
*/
def min[T](
minValue: T,
strict: Boolean = false,
errorMessage: String = "error.min",
strictErrorMessage: String = "error.min.strict"
)(implicit ordering: scala.math.Ordering[T]): Constraint[T] = Constraint[T]("constraint.min", minValue) { o =>
(ordering.compare(o, minValue).signum, strict) match {
case (1, _) | (0, false) => Valid
case (_, false) => Invalid(ValidationError(errorMessage, minValue))
case (_, true) => Invalid(ValidationError(strictErrorMessage, minValue))
}
}
/**
* Defines a maximum value for `Ordered` values, by default the value must be less than or equal to the constraint parameter
*
* '''name'''[constraint.max(maxValue)]
* '''error'''[error.max(maxValue)] or [error.max.strict(maxValue)]
*/
def max[T](
maxValue: T,
strict: Boolean = false,
errorMessage: String = "error.max",
strictErrorMessage: String = "error.max.strict"
)(implicit ordering: scala.math.Ordering[T]): Constraint[T] = Constraint[T]("constraint.max", maxValue) { o =>
(ordering.compare(o, maxValue).signum, strict) match {
case (-1, _) | (0, false) => Valid
case (_, false) => Invalid(ValidationError(errorMessage, maxValue))
case (_, true) => Invalid(ValidationError(strictErrorMessage, maxValue))
}
}
/**
* Defines a minimum length constraint for `String` values, i.e. the strings length must be greater than or equal to the constraint parameter
*
* '''name'''[constraint.minLength(length)]
* '''error'''[error.minLength(length)]
*/
def minLength(length: Int, errorMessage: String = "error.minLength"): Constraint[String] =
Constraint[String]("constraint.minLength", length) { o =>
require(length >= 0, "string minLength must not be negative")
if (o == null) Invalid(ValidationError(errorMessage, length))
else if (o.size >= length) Valid
else Invalid(ValidationError(errorMessage, length))
}
/**
* Defines a maximum length constraint for `String` values, i.e. the strings length must be less than or equal to the constraint parameter
*
* '''name'''[constraint.maxLength(length)]
* '''error'''[error.maxLength(length)]
*/
def maxLength(length: Int, errorMessage: String = "error.maxLength"): Constraint[String] =
Constraint[String]("constraint.maxLength", length) { o =>
require(length >= 0, "string maxLength must not be negative")
if (o == null) Invalid(ValidationError(errorMessage, length))
else if (o.size <= length) Valid
else Invalid(ValidationError(errorMessage, length))
}
/**
* Defines a regular expression constraint for `String` values, i.e. the string must match the regular expression pattern
*
* '''name'''[constraint.pattern(regex)] or defined by the name parameter.
* '''error'''[error.pattern(regex)] or defined by the error parameter.
*/
def pattern(
regex: => scala.util.matching.Regex,
name: String = "constraint.pattern",
error: String = "error.pattern"
): Constraint[String] = Constraint[String](name, () => regex) { o =>
require(regex != null, "regex must not be null")
require(name != null, "name must not be null")
require(error != null, "error must not be null")
if (o == null) Invalid(ValidationError(error, regex))
else regex.unapplySeq(o).map(_ => Valid).getOrElse(Invalid(ValidationError(error, regex)))
}
}
/**
* A validation result.
*/
sealed trait ValidationResult
/**
* Validation was a success.
*/
case object Valid extends ValidationResult
/**
* Validation was a failure.
*
* @param errors the resulting errors
*/
case class Invalid(errors: Seq[ValidationError]) extends ValidationResult {
/**
* Combines these validation errors with another validation failure.
*
* @param other validation failure
* @return a new merged `Invalid`
*/
def ++(other: Invalid): Invalid = Invalid(this.errors ++ other.errors)
}
/**
* This object provides helper methods to construct `Invalid` values.
*/
object Invalid {
/**
* Creates an `Invalid` value with a single error.
*
* @param error the validation error
* @return an `Invalid` value
*/
def apply(error: ValidationError): Invalid = Invalid(Seq(error))
/**
* Creates an `Invalid` value with a single error.
*
* @param error the validation error message
* @param args the validation error message arguments
* @return an `Invalid` value
*/
def apply(error: String, args: Any*): Invalid = Invalid(Seq(ValidationError(error, args: _*)))
}
object ParameterValidator {
def apply[T](constraints: Iterable[Constraint[T]], optionalParam: Option[T]*) =
optionalParam.flatMap {
_.map { param =>
constraints.flatMap {
_(param) match {
case i: Invalid => Some(i)
case _ => None
}
}
}
}.flatten match {
case Nil => Valid
case invalids =>
invalids.reduceLeft { (a, b) =>
a ++ b
}
}
}
/**
* A validation error.
*
* @param messages the error message, if more then one message is passed it will use the last one
* @param args the error message arguments
*/
case class ValidationError(messages: Seq[String], args: Any*) {
lazy val message = messages.last
}
object ValidationError {
def apply(message: String, args: Any*) = new ValidationError(Seq(message), args: _*)
}