mirror of
https://github.com/2revoemag/PSF-BotServer.git
synced 2026-02-12 19:31:04 +00:00
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:
parent
4b71d76cb2
commit
c3d19b5377
8 changed files with 647 additions and 5 deletions
183
common/src/main/scala/net/psforever/config/ConfigParser.scala
Normal file
183
common/src/main/scala/net/psforever/config/ConfigParser.scala
Normal 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: _*)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 string’s 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 string’s 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: _*)
|
||||
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue