///|
/// A declared expectation of a configuration consumer. Missing values may be
/// supplied by the consumer's default before type and allowed-value checks.
pub struct ConfigConsumerRule {
path : ConfigPath
required : Bool
expected_kind : ConfigValueKind?
allowed_values : Array[ConfigValue]?
default_value : ConfigValue?
minimum : Double?
maximum : Double?
integer : Bool
when : (ConfigPath, ConfigValue)?
} derive(Eq, Debug)
///|
pub extend ConfigConsumerRule with Eq::{not_equal, equal}
///|
pub extend ConfigConsumerRule with Debug::{to_repr}
///|
pub struct ConfigConsumerContract {
rules : Array[ConfigConsumerRule]
} derive(Eq, Debug)
///|
pub extend ConfigConsumerContract with Eq::{not_equal, equal}
///|
pub extend ConfigConsumerContract with Debug::{to_repr}
///|
pub(all) enum ConfigConsumerIssueKind {
MissingRequired
TypeMismatch
DisallowedValue
BelowMinimum
AboveMaximum
NotInteger
NonFiniteNumber
} derive(Eq, Debug)
///|
pub extend ConfigConsumerIssueKind with Eq::{not_equal, equal}
///|
pub extend ConfigConsumerIssueKind with Debug::{to_repr}
///|
pub struct ConfigConsumerIssue {
path : ConfigPath
kind : ConfigConsumerIssueKind
} derive(Eq, Debug)
///|
pub extend ConfigConsumerIssue with Eq::{not_equal, equal}
///|
pub extend ConfigConsumerIssue with Debug::{to_repr}
///|
pub fn ConfigConsumerIssue::path(self : ConfigConsumerIssue) -> ConfigPath {
self.path
}
///|
pub fn ConfigConsumerIssue::kind(
self : ConfigConsumerIssue,
) -> ConfigConsumerIssueKind {
self.kind
}
///|
pub fn ConfigConsumerIssueKind::to_string(
self : ConfigConsumerIssueKind,
) -> String {
match self {
MissingRequired => "missing_required"
TypeMismatch => "type_mismatch"
DisallowedValue => "disallowed_value"
BelowMinimum => "below_minimum"
AboveMaximum => "above_maximum"
NotInteger => "not_integer"
NonFiniteNumber => "non_finite_number"
}
}
///|
/// Parse a consumer declaration: {"rules":[{"path":"server.port",
/// "required":true,"type":"number","allowed_values":[8080],"default":8080}]}.
/// Only declared constraints are checked; unknown rule fields are rejected so
/// misspellings cannot silently weaken the release gate.
pub fn ConfigConsumerContract::from_value(
source : ConfigValue,
) -> Result[ConfigConsumerContract, String] {
if source.kind() != Object {
return Err("consumer contract must be an object")
}
let rules_value = match source.field("rules") {
Some(value) if value.kind() == Array => value
_ => return Err("consumer contract requires an array 'rules'")
}
if rules_value.length().unwrap() == 0 {
return Err("consumer contract 'rules' must not be empty")
}
let root_fields = match source.raw {
Object(fields) => fields
_ => abort("unreachable")
}
for key in root_fields.keys() {
if key != "rules" {
return Err("unknown consumer contract field '" + key + "'")
}
}
let rules : Array[ConfigConsumerRule] = []
let seen : Map[String, Bool] = Map([])
for index in 0..
match path.as_string() {
Some(text) => text
None => return Err("consumer rule 'path' must be a string")
}
None => return Err("consumer rule is missing 'path'")
}
let path = match parse_path(path_text) {
Ok(path) => path
Err(_) => return Err("invalid consumer rule path '" + path_text + "'")
}
if seen.contains(path_text) {
return Err("duplicate consumer rule path '" + path_text + "'")
}
seen[path_text] = true
let required = match value.field("required") {
None => false
Some(flag) =>
match flag.as_boolean() {
Some(flag) => flag
None => return Err("consumer rule 'required' must be boolean")
}
}
let expected_kind = match value.field("type") {
None => None
Some(kind) =>
match kind.as_string() {
Some("null") => Some(Null)
Some("boolean") => Some(Boolean)
Some("number") => Some(Number)
Some("string") => Some(String)
Some("array") => Some(Array)
Some("object") => Some(Object)
_ => return Err("consumer rule 'type' is invalid")
}
}
let allowed_values = match value.field("allowed_values") {
None => None
Some(values) => {
if values.kind() != Array || values.length().unwrap() == 0 {
return Err("consumer rule 'allowed_values' must be a nonempty array")
}
let items : Array[ConfigValue] = []
for item in 0..
return Err(
"consumer allowed value has a different type at '" +
path_text +
"'",
)
_ => ()
}
items.push(item_value)
}
Some(items)
}
}
let default_value = value.field("default")
let when = match parse_consumer_condition(value) {
Ok(condition) => condition
Err(error) => return Err(error)
}
let minimum = match parse_consumer_bound(value, "minimum", path_text) {
Ok(bound) => bound
Err(error) => return Err(error)
}
let maximum = match parse_consumer_bound(value, "maximum", path_text) {
Ok(bound) => bound
Err(error) => return Err(error)
}
let integer = match value.field("integer") {
None => false
Some(flag) =>
match flag.as_boolean() {
Some(flag) => flag
None => return Err("consumer rule 'integer' must be boolean")
}
}
if (
minimum is Some(_) ||
maximum is Some(_) ||
value.contains_field("integer")
) &&
expected_kind != Some(Number) {
return Err(
"numeric consumer constraints require type 'number' at '" +
path_text +
"'",
)
}
match (minimum, maximum) {
(Some(lower), Some(upper)) => {
if lower > upper {
return Err("consumer minimum exceeds maximum at '" + path_text + "'")
}
if integer && lower.ceil() > upper.floor() {
return Err(
"consumer numeric interval contains no integer at '" +
path_text +
"'",
)
}
}
_ => ()
}
if !required && expected_kind is None && allowed_values is None {
return Err("consumer rule at '" + path_text + "' has no constraint")
}
let fields = match value.raw {
Object(fields) => fields
_ => abort("unreachable")
}
for key in fields.keys() {
if key != "path" &&
key != "required" &&
key != "type" &&
key != "allowed_values" &&
key != "default" &&
key != "minimum" &&
key != "maximum" &&
key != "integer" &&
key != "when" {
return Err("unknown consumer rule field '" + key + "'")
}
}
let rule = {
path,
required,
expected_kind,
allowed_values,
default_value,
minimum,
maximum,
integer,
when,
}
match allowed_values {
Some(values) =>
for allowed in values {
if consumer_numeric_violation(rule, allowed) is Some(_) {
return Err(
"consumer allowed value violates numeric constraints at '" +
path_text +
"'",
)
}
}
None => ()
}
match default_value {
Some(default) =>
if consumer_violation(rule, default) is Some(_) {
return Err(
"consumer rule default violates constraints at '" + path_text + "'",
)
}
None => ()
}
rules.push(rule)
}
match validate_consumer_conditions(rules) {
Ok(_) => Ok({ rules, })
Err(error) => Err(error)
}
}
///|
fn consumer_violation(
rule : ConfigConsumerRule,
value : ConfigValue,
) -> ConfigConsumerIssueKind? {
match rule.expected_kind {
Some(kind) if value.kind() != kind => return Some(TypeMismatch)
_ => ()
}
match consumer_numeric_violation(rule, value) {
Some(issue) => return Some(issue)
None => ()
}
match rule.allowed_values {
Some(values) => {
for allowed in values {
if allowed == value {
return None
}
}
Some(DisallowedValue)
}
None => None
}
}
///|
/// A default cannot hide a non-object ancestor of the requested path.
fn consumer_parent_is_invalid(
candidate : ConfigValue,
path : ConfigPath,
) -> Bool {
let mut current = candidate
for index in 0..<(path.length() - 1) {
if current.kind() != Object {
return true
}
match current.field(path.segment(index).unwrap()) {
Some(next) => current = next
None => return false
}
}
current.kind() != Object
}
///|
/// Return violations of the *declared* consumer expectations. A pass does
/// not prove compatibility with undeclared application behavior.
pub fn ConfigConsumerContract::check(
self : ConfigConsumerContract,
candidate : ConfigValue,
) -> Array[ConfigConsumerIssue] {
let issues : Array[ConfigConsumerIssue] = []
let inputs = consumer_unconditional_rules(self.rules)
for rule in self.rules {
if !consumer_condition_matches(rule, candidate, inputs) {
continue
}
if consumer_parent_is_invalid(candidate, rule.path) {
issues.push({ path: rule.path, kind: TypeMismatch, })
continue
}
let selected = match candidate.get(rule.path) {
Some(value) => Some(value)
None => rule.default_value
}
match selected {
None =>
if rule.required {
issues.push({ path: rule.path, kind: MissingRequired, })
}
Some(value) =>
match consumer_violation(rule, value) {
Some(kind) => issues.push({ path: rule.path, kind, })
None => ()
}
}
}
issues.sort_by((left, right) => {
left.path.to_string().lexical_compare(right.path.to_string())
})
issues
}