-
Notifications
You must be signed in to change notification settings - Fork 0
Async Validation
Some checks need to wait: a name registry, a remote allow-list. The Async module mirrors the sync API behind an explicit await boundary. Async.mustAsync takes an async predicate; Async.mustAsyncWithContext also receives immutable context data. Async.fromSync lifts an ordinary rule. Nothing async runs until Async.validateAsync is awaited:
module Ex07Async
import Std.Io as Io
import PuduLangValidator.Async as Async
import PuduLangValidator.Rule as Rule
import PuduLangValidator.Validator as Validator
type Signup = { name: Str, email: Str }
async fn nameFree(_root: Signup, name: Str) -> Bool { name != "taken" }
async fn emailKnown(_root: Signup, email: Str, _data: Rule.ContextData) -> Bool {
email == "ada@example.com"
}
fn syncEmail() -> Rule.Rule[Signup] {
Rule.build(Rule.must(Rule.ruleFor("email", |signup: Signup| signup.email),
|email: Str| email != ""))
}
export async fn main() -> Int {
let validator = Async.add(Async.add(Async.add(Async.create(),
Async.mustAsync("name", |signup: Signup| signup.name, nameFree,
"Name is taken.", "Taken")),
Async.mustAsyncWithContext("email", |signup: Signup| signup.email, emailKnown,
"Email is unknown.", "Unknown")),
Async.fromSync(syncEmail()))
let bad = Async.validateAsync(&validator, Signup{name: "taken", email: "ghost@example.com"}).await
let chosen = Async.validateAsyncWith(&validator, Signup{name: "taken", email: "ghost@example.com"},
Validator.includeProperties(Validator.defaults(), ["name"])).await
let line = "bad=" + show(bad.errors.length()) + " chosen=" + show(chosen.errors.length())
match Io.writeLine(line) {
case Ok(_) => if bad.errors.length() == 2 && chosen.errors.length() == 1 { 0 } else { 2 }
case Err(_) => 1
}
}
Output:
bad=2 chosen=1
Async validators keep the sync habits: named rule sets, property selection, child validators (setValidatorAsync, setOptionalValidatorAsync, forEachValidatorAsync), per-element collection checks, when/whenAsync conditions, severity and state overrides, and a prevalidation hook with sync (withPreValidation) and awaited (withPreValidationAsync) forms. Failures arrive in declaration order either way, so sync and async results read the same.
Async.forEachAsync checks every array element; Async.forEachWhereAsync checks only the ones
its filter keeps. Async.whenAsync awaits a context-aware condition before running its rule, and
Async.customAsync lets an awaited callback report its own structured failures. Async.stopOnFirst
ends the validator after its first failing rule, and Async.mustAsyncWithContext reads the same
immutable data the sync validateWithData entry points supply:
module Ex14AsyncMore
import Std.Io as Io
import Std.Json as Json
import Std.Map as Map
import PuduLangValidator.Async as Async
import PuduLangValidator.Result as Validation
import PuduLangValidator.Rule as Rule
import PuduLangValidator.Validator as Validator
type Item = { key: Str, tags: Array[Str] }
async fn rejectSkipAndShort(_root: Item, tag: Str) -> Bool { tag != "skip" && tag.length() >= 3 }
async fn included(tag: Str) -> Bool { tag != "skip" }
async fn keyExists(_root: Item, key: Str) -> Bool { key == "present" }
export async fn main() -> Int {
let every = Async.forEachAsync("tags", |item: Item| item.tags, rejectSkipAndShort,
"Tag is short.", "TagLength")
let kept = Async.forEachWhereAsync("tags", |item: Item| item.tags, included, rejectSkipAndShort,
"Tag is short.", "TagLength")
let stocked = Item{key: "present", tags: ["skip", "x", "longer"]}
let all = Async.validateAsync(&Async.add(Async.create(), every), stocked).await
let some = Async.validateAsync(&Async.add(Async.create(), kept), stocked).await
let conditional = Async.whenAsync(
Async.mustAsync("key", |item: Item| item.key, keyExists, "The key was not found.", "MissingKey"),
async fn(root: Item, _data: Rule.ContextData) -> Bool { !root.tags.isEmpty() })
let skipped = Async.validateAsync(&Async.add(Async.create(), conditional),
Item{key: "absent", tags: []}).await
let ran = Async.validateAsync(&Async.add(Async.create(), conditional),
Item{key: "absent", tags: ["x"]}).await
let custom = Async.customAsync("root",
async fn(root: Item, _data: Rule.ContextData) -> Array[Validation.Failure] {
if root.key == "present" { [] } else { [Validation.Failure{
propertyName: "root", errorMessage: "Root failed.", errorCode: "Root",
severity: Validation.Error, customState: "" }] }
})
let customBad = Async.validateAsync(&Async.add(Async.create(), custom),
Item{key: "absent", tags: []}).await
let first = Async.mustAsync("key", |item: Item| item.key, keyExists, "Missing key.", "MissingKey")
let second = Async.mustAsync("other", |item: Item| item.key, keyExists, "Missing other.", "MissingOther")
let stopped = Async.validateAsync(&Async.stopOnFirst(Async.add(Async.add(Async.create(), first), second)),
Item{key: "absent", tags: []}).await
let flowing = Async.validateAsync(&Async.add(Async.add(Async.create(), first), second),
Item{key: "absent", tags: []}).await
let invited = Async.mustAsyncWithContext("key", |item: Item| item.key,
async fn(_root: Item, key: Str, data: Rule.ContextData) -> Bool {
match Map.get(&data, "expected") {
case Some(Json.Text(wanted)) => key == wanted
case _ => false
}
}, "Key is not invited.", "Invited")
let strangers = Async.validateAsyncWithData(&Async.add(Async.create(), invited),
Item{key: "ghost", tags: []}, Validator.defaults(),
mapOf([("expected", Json.Text("present"))])).await
let line = "every=" + show(all.errors.length()) + " kept=" + show(some.errors.length())
+ " skipped=" + show(skipped.errors.length()) + " ran=" + show(ran.errors.length())
+ " custom=" + show(customBad.errors[0].errorCode)
+ " stopped=" + show(stopped.errors.length()) + " flowing=" + show(flowing.errors.length())
+ " strangers=" + show(strangers.errors.length())
match Io.writeLine(line) {
case Ok(_) => if all.errors.length() == 2 && some.errors.length() == 1
&& skipped.errors.length() == 0 && ran.errors.length() == 1
&& customBad.errors[0].errorCode == "Root"
&& stopped.errors.length() == 1 && flowing.errors.length() == 2
&& strangers.errors.length() == 1 { 0 } else { 2 }
case Err(_) => 1
}
}
Output:
every=2 kept=1 skipped=0 ran=1 custom="Root" stopped=1 flowing=2 strangers=1
Both "skip" and "x" fail the unfiltered check; the filter keeps only "x". The gated key check
is skipped for the empty tag list and runs for the stocked one. The custom check names its own
code, the stopped validator keeps one failure where the flowing one keeps two, and the stranger
fails the invitation check from context data.
Async.forEachWhereAsyncWithContext passes the same immutable data to both its filter and its
element predicate. Async.withSeverityFrom and Async.withStateFrom derive a failure's
classification from the root source, and the static withSeverity and withState fix them:
module Ex21AsyncDecor
import Std.Io as Io
import Std.Json as Json
import Std.Map as Map
import PuduLangValidator.Async as Async
import PuduLangValidator.Result as Validation
import PuduLangValidator.Rule as Rule
import PuduLangValidator.Validator as Validator
type Item = { key: Str, tags: Array[Str] }
export async fn main() -> Int {
let tagged = Async.forEachWhereAsyncWithContext("tags", |item: Item| item.tags,
async fn(tag: Str, data: Rule.ContextData) -> Bool {
match Map.get(&data, "ignored") {
case Some(Json.Text(skipped)) => tag != skipped
case _ => true
}
},
async fn(_root: Item, tag: Str, data: Rule.ContextData) -> Bool {
match Map.get(&data, "expected") {
case Some(Json.Text(wanted)) => tag == wanted
case _ => false
}
}, "Tag differs from context.", "TagContext")
let data: Rule.ContextData = mapOf([("expected", Json.Text("long")), ("ignored", Json.Text("skip"))])
let stocked = Item{key: "present", tags: ["skip", "x", "long"]}
let contextual = Async.validateAsyncWithData(&Async.add(Async.create(), tagged),
stocked, Validator.defaults(), data).await
let decorated = Async.withState(Async.withSeverityFrom(
Async.mustAsync("key", |item: Item| item.key,
async fn(_root: Item, key: Str) -> Bool { key == "present" },
"The key was not found.", "MissingKey"),
|root: Item| if root.key == "present" { Validation.Warning } else { Validation.Error }), "checked")
let announced = Async.validateAsync(&Async.add(Async.create(), decorated),
Item{key: "absent", tags: []}).await
let failure = announced.errors[0]
let line = "contextual=" + show(contextual.errors.map(|error: Validation.Failure| error.propertyName))
+ " severity=" + show(failure.severity) + " state=" + failure.customState
match Io.writeLine(line) {
case Ok(_) => if contextual.errors.length() == 1
&& contextual.errors[0].propertyName == "tags[1]"
&& failure.severity == Validation.Error && failure.customState == "checked" { 0 } else { 2 }
case Err(_) => 1
}
}
Output:
contextual=["tags[1]"] severity=Error state=checked
The filter drops "skip" through context data and the predicate keeps only "long", so the
single failure keeps its source index. The absent key fails with the computed Error severity
and the fixed "checked" state.
Async.setValidatorAsync awaits one nested validator, Async.setOptionalValidatorAsync skips an
absent one, and Async.forEachValidatorAsync awaits one per array element, each keeping its source
index. A child built only from named rules stays silent in the default run; Async.inRuleSet
files an async rule under a name and the static withSeverity fixes its classification:
module Ex24AsyncChild
import Std.Io as Io
import PuduLangValidator.Async as Async
import PuduLangValidator.Result as Validation
import PuduLangValidator.Validator as Validator
type Child = { code: Str }
type Root = { tag: Str, child: Child, items: Array[Child], note: Option[Child] }
async fn codeMatches(_root: Child, code: Str) -> Bool { code == "ok" }
fn childValidator() -> Async.AsyncValidator[Child] {
Async.add(Async.create(), Async.mustAsync("code", |child: Child| child.code,
codeMatches, "Code differs.", "Code"))
}
fn parentValidator() -> Async.AsyncValidator[Root] {
let child = childValidator()
let direct = Async.setValidatorAsync("child", |root: Root| root.child, child)
let each = Async.forEachValidatorAsync("items", |root: Root| root.items, childValidator())
let maybe = Async.setOptionalValidatorAsync("note", |root: Root| root.note, childValidator())
let named = Async.inRuleSet(Async.withSeverity(
Async.mustAsync("tag", |root: Root| root.tag,
async fn(_root: Root, tag: Str) -> Bool { tag == "x" }, "Tag unknown.", "Tag"),
Validation.Warning), "priority")
Async.add(Async.add(Async.add(Async.add(Async.create(), direct), each), maybe), named)
}
export async fn main() -> Int {
let validator = parentValidator()
let root = Root{tag: "y", child: Child{code: "bad"},
items: [Child{code: "ok"}, Child{code: "bad"}], note: None}
let nested = Async.validateAsync(&validator, root).await
let chosen = Async.validateAsyncWith(&validator, root, Validator.onlyRuleSets(["priority"])).await
let line = "nested=" + show(nested.errors.map(|failure: Validation.Failure| failure.propertyName))
+ " priority=" + show(chosen.errors[0].severity)
match Io.writeLine(line) {
case Ok(_) => if nested.errors.length() == 2
&& nested.errors[0].propertyName == "child.code"
&& nested.errors[1].propertyName == "items[1].code"
&& chosen.errors.length() == 1 && chosen.errors[0].severity == Validation.Warning { 0 } else { 2 }
case Err(_) => 1
}
}
Output:
nested=["child.code", "items[1].code"] priority=Warning
The direct child and the second item fail with prefixed paths while the absent note stays silent
and the named tag rule waits. Under the priority set only the tag rule runs, failing with its
fixed Warning severity.
Async.withPreValidation installs a synchronous root callback and Async.withPreValidationAsync
an awaited one that also reads context data; either answers None to continue or Some(failures)
to return immediately. Async.when runs its rule only while its sync condition holds:
module Ex25AsyncPreflight
import Std.Io as Io
import Std.Json as Json
import Std.Map as Map
import PuduLangValidator.Async as Async
import PuduLangValidator.Result as Validation
import PuduLangValidator.Rule as Rule
import PuduLangValidator.Validator as Validator
type Item = { key: Str, enabled: Bool }
async fn keyExists(_root: Item, key: Str) -> Bool { key == "present" }
fn blocked() -> Array[Validation.Failure] {
[Validation.Failure{propertyName: "", errorMessage: "Blocked.",
errorCode: "Blocked", severity: Validation.Error, customState: ""}]
}
export async fn main() -> Int {
let check = Async.mustAsync("key", |item: Item| item.key, keyExists,
"The key was not found.", "MissingKey")
let syncGate = Async.withPreValidation(Async.add(Async.create(), check),
fn(item: Item) -> Option[Array[Validation.Failure]] {
if item.key == "Blocked" { Some(blocked()) } else { None }
})
let syncBlocked = Async.validateAsync(&syncGate, Item{key: "Blocked", enabled: true}).await
let syncPlain = Async.validateAsync(&syncGate, Item{key: "absent", enabled: true}).await
let asyncGate = Async.withPreValidationAsync(Async.add(Async.create(), check),
async fn(item: Item, data: Rule.ContextData) -> Option[Array[Validation.Failure]] {
match Map.get(&data, "expected") {
case Some(Json.Text(wanted)) => if item.key == wanted { None } else {
Some([Validation.Failure{propertyName: "key", errorMessage: "Unexpected key.",
errorCode: "Unexpected", severity: Validation.Error, customState: ""}]) }
case _ => None
}
})
let data: Rule.ContextData = mapOf([("expected", Json.Text("present"))])
let asyncEarly = Async.validateAsyncWithData(&asyncGate,
Item{key: "ghost", enabled: true}, Validator.defaults(), data).await
let asyncPass = Async.validateAsyncWithData(&asyncGate,
Item{key: "present", enabled: true}, Validator.defaults(), data).await
let gated = Async.when(check, |item: Item| item.enabled)
let gatedSkip = Async.validateAsync(&Async.add(Async.create(), gated),
Item{key: "absent", enabled: false}).await
let gatedRun = Async.validateAsync(&Async.add(Async.create(), gated),
Item{key: "absent", enabled: true}).await
let line = "sync=" + show(syncBlocked.errors[0].errorCode) + "/" + show(syncPlain.errors[0].errorCode)
+ " async=" + show(asyncEarly.errors[0].errorCode) + "/" + show(asyncPass.isValid)
+ " gated=" + show(gatedSkip.isValid) + "/" + show(gatedRun.isValid)
match Io.writeLine(line) {
case Ok(_) => if syncBlocked.errors[0].errorCode == "Blocked"
&& syncPlain.errors[0].errorCode == "MissingKey"
&& asyncEarly.errors[0].errorCode == "Unexpected" && asyncPass.isValid
&& gatedSkip.isValid && !gatedRun.isValid { 0 } else { 2 }
case Err(_) => 1
}
}
Output:
sync="Blocked"/"MissingKey" async="Unexpected"/true gated=true/false
The blocked key is refused before any rule runs while the plain miss falls through to the key check. The awaited gate refuses the stranger from context data and passes the invited key on to its rule. The gated check is skipped while disabled and fails while enabled.