///|
/// Decode a `Bytes` request field to text, replacing any malformed UTF-8 rather
/// than raising — inbound bytes are attacker-controlled, so extraction stays
/// total.
fn decode_field(raw : Bytes) -> String {
@utf8.decode_lossy(raw[:])
}
///|
/// Split a query string into decoded `key`/`value` pairs. Splitting happens on the
/// raw bytes so that a percent-escaped `&` or `=` inside a value cannot be mistaken
/// for a separator; each half is percent/plus-decoded afterwards. A pair with no
/// `=` is a bare key with an empty value.
fn query_pairs(raw : Bytes) -> Array[(String, String)] {
let out : Array[(String, String)] = []
let n = raw.length()
let mut start = 0
for i = 0; i <= n; i = i + 1 {
if i == n || raw[i] == b'&' {
if i > start {
let mut eq = -1
for j = start; j < i; j = j + 1 {
if raw[j] == b'=' {
eq = j
break
}
}
if eq < 0 {
out.push((decode_component(raw, start, i), ""))
} else {
out.push(
(decode_component(raw, start, eq), decode_component(raw, eq + 1, i)),
)
}
}
start = i + 1
}
}
out
}
///|
/// Look up a query-string parameter by name, e.g. `?limit=10&q=cat%20dog`. Keys and
/// values are percent/plus-decoded, so `q` above reads back as `cat dog`. When a key
/// repeats, the first occurrence wins; use `query_all` to read every one.
pub fn Context::query(self : Context, name : String) -> String? {
for kv in query_pairs(self.request.query_string) {
if kv.0 == name {
return Some(kv.1)
}
}
None
}
///|
/// Every value given for `name`, in the order they appear — `?tag=a&tag=b` reads
/// back as `["a", "b"]`. Empty when the key is absent.
pub fn Context::query_all(self : Context, name : String) -> Array[String] {
let out : Array[String] = []
for kv in query_pairs(self.request.query_string) {
if kv.0 == name {
out.push(kv.1)
}
}
out
}
///|
/// Parse the request body as JSON, returning `None` for an empty body or one
/// that does not parse — the total counterpart of FastAPI reading a JSON body.
pub fn Context::body_json(self : Context) -> Json? {
let s = decode_field(self.request.body)
if s == "" {
return None
}
Some(@json.parse(s)) catch {
_ => None
}
}
///|
/// Pull a single field out of a JSON object body by name, `None` if the body is
/// absent, not an object, or lacks the field.
pub fn Context::json_field(self : Context, name : String) -> Json? {
match self.body_json() {
Some(Object(m)) => m.get(name)
_ => None
}
}
///|
/// Look up a cookie by name from the request `Cookie` header, which is a
/// `; `-separated list of `key=value` pairs. Surrounding spaces are trimmed;
/// `None` if there is no `Cookie` header or the name is absent.
pub fn Context::cookie(self : Context, name : String) -> String? {
match self.request.header("cookie") {
None => None
Some(raw) => {
let n = raw.length()
let mut start = 0
for i = 0; i <= n; i = i + 1 {
if i == n || raw[i] == ';' {
if i > start {
let pair = trim_spaces(raw[start:i].to_owned())
let m = pair.length()
let mut eq = -1
for j = 0; j < m; j = j + 1 {
if pair[j] == '=' {
eq = j
break
}
}
if eq >= 0 && pair[0:eq].to_owned() == name {
return Some(pair[eq + 1:m].to_owned())
}
}
start = i + 1
}
}
None
}
}
}
///|
/// Trim ASCII spaces and tabs from both ends of `s` (core has no `trim`).
fn trim_spaces(s : String) -> String {
let n = s.length()
let mut a = 0
let mut b = n
while a < b && (s[a].to_int() == 0x20 || s[a].to_int() == 0x09) {
a = a + 1
}
while b > a && (s[b - 1].to_int() == 0x20 || s[b - 1].to_int() == 0x09) {
b = b - 1
}
s[a:b].to_owned()
}
///|
/// Parse a base-10 integer, `None` if `s` is not a well-formed integer literal
/// (optional leading `+`/`-`, then one or more ASCII digits, nothing else).
fn parse_int(s : String) -> Int? {
let n = s.length()
if n == 0 {
return None
}
let mut i = 0
let mut neg = false
let first = s[0].to_int()
if first == 0x2D {
neg = true
i = 1
} else if first == 0x2B {
i = 1
}
if i >= n {
return None
}
let mut acc = 0
while i < n {
let c = s[i].to_int()
if c < 0x30 || c > 0x39 {
return None
}
acc = acc * 10 + (c - 0x30)
i = i + 1
}
Some(if neg { -acc } else { acc })
}
///|
/// One entry in a `422` response's `detail` array, mirroring FastAPI /
/// pydantic v2: where the error is (`loc`, e.g. `["query", "q"]`), a human
/// `msg`, and a machine `kind` (serialised as the JSON key `type`).
pub(all) struct ValidationError {
loc : Array[String]
msg : String
kind : String
}
///|
/// The canonical "a required parameter was not supplied" error located at
/// `loc`, matching FastAPI's `{"type": "missing", "msg": "Field required"}`.
pub fn ValidationError::missing(loc : Array[String]) -> ValidationError {
{ loc, msg: "Field required", kind: "missing", }
}
///|
/// A type/parse error located at `loc`, e.g. `kind = "int_parsing"` with the
/// matching pydantic message — the shape FastAPI reports for a value of the
/// wrong type.
pub fn ValidationError::type_error(
loc : Array[String],
kind : String,
msg : String,
) -> ValidationError {
{ loc, msg, kind, }
}
///|
/// `loc` with `seg` appended, as a fresh array so sibling error paths never
/// share and mutate one another.
fn loc_push(loc : Array[String], seg : String) -> Array[String] {
let out : Array[String] = []
for x in loc {
out.push(x)
}
out.push(seg)
out
}
///|
/// Render one validation error as its JSON object.
fn ValidationError::to_json(self : ValidationError) -> Json {
let m : Map[String, Json] = Map([
("type", self.kind.to_json()),
("loc", self.loc.to_json()),
("msg", self.msg.to_json()),
])
m.to_json()
}
///|
/// The `{"detail": [ ... ]}` body FastAPI returns when request validation
/// fails, built from a list of `ValidationError`s.
pub fn validation_error_body(errors : Array[ValidationError]) -> Json {
let detail : Array[Json] = []
for e in errors {
detail.push(e.to_json())
}
let doc : Map[String, Json] = Map([("detail", detail.to_json())])
doc.to_json()
}
///|
/// A `422 Unprocessable Entity` response whose `application/json` body lists the
/// validation `errors`, exactly as FastAPI reports a failed request.
pub fn unprocessable(errors : Array[ValidationError]) -> @moonasgi.Response {
json(422, validation_error_body(errors))
}
///|
/// Whether a `Double` holds an exact integer value (no fractional part).
fn is_integral(n : Double) -> Bool {
n == n.to_int().to_double()
}
///|
/// Validate a JSON `value` against the descriptor `schema`, appending
/// FastAPI-shaped errors to `errs` (located at `loc`). This is what "the
/// descriptor drives validation" means: the very tree that emits the OpenAPI
/// body schema also decides whether an inbound body conforms — one source of
/// truth, exactly as pydantic derives both from one model. A named object is
/// validated against its inline fields, so no `$ref` resolution is needed here.
pub fn validate_schema(
schema : Schema,
value : Json,
loc : Array[String],
errs : Array[ValidationError],
) -> Unit {
match (schema, value) {
(SStr, String(_)) => ()
(SStr, _) =>
errs.push(
ValidationError::type_error(
loc, "string_type", "Input should be a valid string",
),
)
(SInt, Number(n, ..)) =>
if !is_integral(n) {
errs.push(
ValidationError::type_error(
loc, "int_from_float", "Input should be a valid integer, got a number with a fractional part",
),
)
}
(SInt, _) =>
errs.push(
ValidationError::type_error(
loc, "int_type", "Input should be a valid integer",
),
)
(SFloat, Number(_, ..)) => ()
(SFloat, _) =>
errs.push(
ValidationError::type_error(
loc, "float_type", "Input should be a valid number",
),
)
(SBool, True) => ()
(SBool, False) => ()
(SBool, _) =>
errs.push(
ValidationError::type_error(
loc, "bool_type", "Input should be a valid boolean",
),
)
(SNull, Null) => ()
(SNull, _) =>
errs.push(
ValidationError::type_error(loc, "null_type", "Input should be null"),
)
(SArray(item), Array(a)) =>
for i, v in a {
validate_schema(item, v, loc_push(loc, i.to_string()), errs)
}
(SArray(_), _) =>
errs.push(
ValidationError::type_error(
loc, "list_type", "Input should be a valid list",
),
)
(SEnum(_, values), v) =>
if !enum_member(values, v) {
errs.push(
ValidationError::type_error(
loc,
"enum",
"Input should be " + enum_expected(values),
),
)
}
(SObject(os), Object(m)) =>
for f in os.fields {
match m.get(f.name) {
None =>
if f.required {
errs.push(ValidationError::missing(loc_push(loc, f.name)))
}
Some(v) => {
validate_schema(f.schema, v, loc_push(loc, f.name), errs)
check_constraints(v, f.constraints, loc_push(loc, f.name), errs)
}
}
}
(SObject(_), _) =>
errs.push(
ValidationError::type_error(
loc, "model_type", "Input should be a valid object",
),
)
}
}
///|
/// Validate a raw string parameter `v` (query/path/header/cookie values always
/// arrive as text) against the declared scalar `schema`, appending a parse error
/// to `errs` when it cannot represent that scalar. Strings always pass; integers
/// and booleans are checked; floats are accepted (no false negatives here).
fn validate_scalar_str(
schema : Schema,
v : String,
loc : Array[String],
errs : Array[ValidationError],
) -> Unit {
match schema {
SInt =>
match parse_int(v) {
Some(_) => ()
None =>
errs.push(
ValidationError::type_error(
loc, "int_parsing", "Input should be a valid integer, unable to parse string as an integer",
),
)
}
SBool =>
if !(v == "true" ||
v == "false" ||
v == "1" ||
v == "0" ||
v == "True" ||
v == "False") {
errs.push(
ValidationError::type_error(
loc, "bool_parsing", "Input should be a valid boolean, unable to interpret input",
),
)
}
SEnum(base, values) =>
if !enum_member(values, scalar_json(base, v)) {
errs.push(
ValidationError::type_error(
loc,
"enum",
"Input should be " + enum_expected(values),
),
)
}
_ => ()
}
}
///|
/// Coerce a raw string parameter to the `Json` shape its declared scalar `base`
/// carries, so an enum-member comparison sees like-typed values (a `1` path
/// segment against an integer-valued member). Unparseable input stays a string
/// and simply fails membership, as any non-member does.
fn scalar_json(base : Schema, v : String) -> Json {
match base {
SInt =>
match parse_int(v) {
Some(n) => n.to_json()
None => v.to_json()
}
SBool =>
match v {
"true" | "True" | "1" => true.to_json()
"false" | "False" | "0" => false.to_json()
_ => v.to_json()
}
_ => v.to_json()
}
}
///|
/// Whether `v` is one of the allowed enum `values`.
fn enum_member(values : Array[Json], v : Json) -> Bool {
for allowed in values {
if allowed == v {
return true
}
}
false
}
///|
/// The Pydantic-style "expected one of" fragment for an `enum` error: string
/// members quoted, comma-separated, with a final `or` before the last.
fn enum_expected(values : Array[Json]) -> String {
let parts : Array[String] = []
for v in values {
parts.push(
match v {
String(s) => "'" + s + "'"
_ => v.stringify()
},
)
}
let n = parts.length()
let sb = StringBuilder()
for i = 0; i < n; i = i + 1 {
if i == n - 1 && n > 1 {
sb.write_string(" or ")
} else if i > 0 {
sb.write_string(", ")
}
sb.write_string(parts[i])
}
sb.to_string()
}
///|
/// Validate an inbound request `ctx` against this endpoint descriptor: every
/// declared parameter (path / query / header / cookie) plus the JSON request
/// body, all off the same descriptor tree that emits the OpenAPI operation.
/// Returns the accumulated errors — an empty array means the request conforms,
/// otherwise pass them to `unprocessable` for a FastAPI-shaped `422`.
pub fn Endpoint::validate(
self : Endpoint,
ctx : Context,
) -> Array[ValidationError] {
let errs : Array[ValidationError] = []
for p in self.params {
let raw = match p.loc {
InPath => ctx.param(p.name)
InQuery => ctx.query(p.name)
InHeader => ctx.request.header(p.name)
InCookie => ctx.cookie(p.name)
}
match raw {
None =>
if p.required {
errs.push(ValidationError::missing([loc_str(p.loc), p.name]))
}
Some(v) =>
validate_scalar_str(p.schema, v, [loc_str(p.loc), p.name], errs)
}
}
match self.request_body {
Some(body) =>
match ctx.body_json() {
None =>
if self.request_required {
errs.push(ValidationError::missing(["body"]))
}
Some(j) => validate_schema(body, j, ["body"], errs)
}
None => ()
}
errs
}