// Thin bobzhang/crescent middleware adapter (T-04) for the
// vicoproplus/moon_ua_parser library.
//
// Responsibility boundary (plan R2 — four duties only, zero inlined
// business semantics):
//
// 1. Registration adaptation — `middleware()` builds a crescent
// `Middleware` (`async (Event, MiddlewareNext) -> &Responder`, onion
// model) for `App::use_middleware`.
// 2. Request-entry helper call — the middleware reads the `User-Agent`
// request header and calls
// `@middleware_core.assemble(ua, @ua_parser.parse(ua))`. Assembly,
// degradation judgment and forensics-record construction all live in
// `vicoproplus/moon_ua_parser_middleware_core` (T-03); nothing is
// re-implemented in this file.
// 3. Context mounting — the assembled `UaInfo` is mounted on
// `event.res.headers` BEFORE `next()` (see "Mounting mechanism" in
// README.mbt.md for why res.headers beats the request_id-map bypass):
// one JSON round-trip header carrying the full `UaInfo`
// (`HEADER_UA_INFO`) plus one family header per domain
// (`HEADER_UA_BROWSER` / `HEADER_UA_OS` / `HEADER_UA_DEVICE`). Because
// crescent's `Event` is a shared mutable record and `App::dispatch`
// finalizes the response from `event.res`, downstream handlers can read
// the mount during the request (`mounted_ua_info`) and clients/tests can
// read the very same values from the response headers.
// 4. Log-channel adaptation — a degraded request's `ForensicsRecord` is
// forwarded to an injectable sink (default `console_forensics_sink`,
// one console line). bobzhang/crescent ships no logger middleware
// (verified against the walkthrough snapshot at upstream commit
// d99287ae: `middleware/pkg.generated.mbti` exports only rate_limit /
// request_id / security_headers), so the injectable sink IS the
// forensics (FO) channel adaptation.
//
// Degradation behavior (spec §3, implemented entirely by the helper): a
// parse `Err` or an all-three-families-at-fallback result mounts the EMPTY
// `UaInfo` (empty-string families), never raises into the request chain,
// and emits `{ua_summary, reason}` forensics to the sink. The response
// therefore keeps its normal (non-5xx) status with empty family headers.
///|
/// Response-header name carrying the JSON round-trip of the mounted
/// `UaInfo` (see `ua_info_to_json` for the transport schema).
pub const HEADER_UA_INFO : String = "X-Ua-Info"
///|
/// Response-header name carrying the mounted browser family.
pub const HEADER_UA_BROWSER : String = "X-Ua-Browser"
///|
/// Response-header name carrying the mounted OS family.
pub const HEADER_UA_OS : String = "X-Ua-Os"
///|
/// Response-header name carrying the mounted device family.
pub const HEADER_UA_DEVICE : String = "X-Ua-Device"
///|
/// Prefix of the default console forensics line (FO channel).
pub const FORENSICS_LOG_PREFIX : String = "[moon_ua_parser]"
///|
/// Forensics output channel type: `(@middleware_core.ForensicsRecord) -> Unit`.
/// Injectable so tests can capture records (`integration_test.mbt`) and
/// applications can forward them to any logging backend; the default is
/// `console_forensics_sink`. (Spelled inline at use sites — this toolchain
/// build does not support `typealias` declarations.)
///|
/// Default FO channel: one console line per degraded request.
///
/// Format: ` degraded user-agent parse: reason= ua_summary=""`
/// where `ua_summary` is already truncated to
/// `UA_SUMMARY_MAX_LENGTH` characters by the helper.
pub fn console_forensics_sink(rec : @middleware_core.ForensicsRecord) -> Unit {
println(
"\{FORENSICS_LOG_PREFIX} degraded user-agent parse: reason=\{rec.reason} ua_summary=\"\{rec.ua_summary}\"",
)
}
///|
/// Registration adapter: builds the UA-parser middleware for
/// `App::use_middleware`.
///
/// The middleware, per request:
/// 1. reads the `User-Agent` request header (missing header = empty
/// string; the helper's semantics decide what that mounts);
/// 2. calls `@middleware_core.assemble(ua, @ua_parser.parse(ua))`;
/// 3. mounts `outcome.ua_info` on `event.res.headers` before `next()`;
/// 4. forwards `outcome.forensics` (iff degraded) to the sink.
///
/// `forensics_sink` defaults to `console_forensics_sink` when omitted.
pub fn middleware(
forensics_sink? : (@middleware_core.ForensicsRecord) -> Unit,
) -> @crescent.Middleware {
let sink = forensics_sink.unwrap_or(console_forensics_sink)
(event, next) => {
let ua = event.req.get_header("User-Agent").unwrap_or("")
let outcome = @middleware_core.assemble(ua, @ua_parser.parse(ua))
mount(event, outcome.ua_info)
match outcome.forensics {
Some(rec) => sink(rec)
None => ()
}
next()
}
}
///|
/// Handler-side read-back: the `UaInfo` mounted for this request, parsed
/// from the middleware's JSON header on `event.res.headers`. `None` when
/// the middleware is not registered upstream or the header was removed /
/// is not valid transport JSON.
pub fn mounted_ua_info(event : @crescent.Event) -> @ua_parser.UaInfo? {
match event.res.headers.get(HEADER_UA_INFO) {
Some(value) => parse_mounted_ua_info(value)
None => None
}
}
///|
/// Client-side read-back: parse a `HEADER_UA_INFO` header value (e.g. from
/// a `TestResponse.headers` map or an HTTP client's response headers) back
/// into the mounted `UaInfo`. `None` on absent/invalid transport JSON.
pub fn parse_mounted_ua_info(header_value : String) -> @ua_parser.UaInfo? {
let json = @json.parse(header_value) catch { _ => return None }
ua_info_from_json(json)
}
///|
/// Mount `info` on the request context (duty 3). Pure header writes on the
/// shared `event.res` — before `next()`, so handlers see it and
/// `App::dispatch` finalizes the response from the same record.
fn mount(event : @crescent.Event, info : @ua_parser.UaInfo) -> Unit {
let headers = event.res.headers
headers.set(HEADER_UA_BROWSER, info.browser.family)
headers.set(HEADER_UA_OS, info.os.family)
headers.set(HEADER_UA_DEVICE, info.device.family)
headers.set(HEADER_UA_INFO, ua_info_to_json(info).stringify())
}
///|
/// Transport schema (adapter-owned, mechanical field copying — no business
/// semantics): `{"browser":D,"os":D,"device":D}` where a versioned domain
/// `D` is `{"family":String,"major":S?,"minor":S?,"patch":S?,"patch_minor":S?,"rule_index":N?}`,
/// and the device domain replaces the four version fields with
/// `{"brand":S?,"model":S?}`. `S?` is a JSON string or null, `N?` a JSON
/// number or null. All fields round-trip exactly, so
/// `parse_mounted_ua_info(ua_info_to_json(info).stringify()) == Some(info)`.
fn ua_info_to_json(info : @ua_parser.UaInfo) -> Json {
Json::object({
"browser": versioned_domain_to_json(
family=info.browser.family,
v1=info.browser.major,
v2=info.browser.minor,
v3=info.browser.patch,
v4=info.browser.patch_minor,
rule_index=info.browser.rule_index,
),
"os": versioned_domain_to_json(
family=info.os.family,
v1=info.os.major,
v2=info.os.minor,
v3=info.os.patch,
v4=info.os.patch_minor,
rule_index=info.os.rule_index,
),
"device": Json::object({
"family": Json::string(info.device.family),
"brand": optional_string_to_json(info.device.brand),
"model": optional_string_to_json(info.device.model),
"rule_index": optional_int_to_json(info.device.rule_index),
}),
})
}
///|
/// One browser/OS domain of the transport schema (labeled arguments keep
/// the mechanical copy readable).
fn versioned_domain_to_json(
family~ : String,
v1~ : String?,
v2~ : String?,
v3~ : String?,
v4~ : String?,
rule_index~ : Int?,
) -> Json {
Json::object({
"family": Json::string(family),
"major": optional_string_to_json(v1),
"minor": optional_string_to_json(v2),
"patch": optional_string_to_json(v3),
"patch_minor": optional_string_to_json(v4),
"rule_index": optional_int_to_json(rule_index),
})
}
///|
fn optional_string_to_json(value : String?) -> Json {
match value {
Some(s) => Json::string(s)
None => Json::null()
}
}
///|
fn optional_int_to_json(value : Int?) -> Json {
match value {
Some(n) => Json::number(n.to_double())
None => Json::null()
}
}
///|
/// Inverse of `ua_info_to_json`; `None` when the JSON does not match the
/// transport schema.
fn ua_info_from_json(json : Json) -> @ua_parser.UaInfo? {
match json {
Json::Object(obj) => {
let browser = match object_domain(obj, "browser") {
Some(domain) => versioned_domain_from_json(domain)
None => None
}
let os = match object_domain(obj, "os") {
Some(domain) => os_domain_from_json(domain)
None => None
}
let device = match object_domain(obj, "device") {
Some(domain) => device_domain_from_json(domain)
None => None
}
match (browser, os, device) {
(Some(browser), Some(os), Some(device)) =>
Some({ browser, os, device, })
_ => None
}
}
_ => None
}
}
///|
fn object_domain(obj : Map[String, Json], key : String) -> Map[String, Json]? {
match obj.get(key) {
Some(Json::Object(domain)) => Some(domain)
_ => None
}
}
///|
fn versioned_domain_from_json(obj : Map[String, Json]) -> @ua_parser.Browser? {
match obj.get("family") {
Some(Json::String(family)) =>
Some({
family,
major: json_string(obj, "major"),
minor: json_string(obj, "minor"),
patch: json_string(obj, "patch"),
patch_minor: json_string(obj, "patch_minor"),
rule_index: json_int(obj, "rule_index"),
})
_ => None
}
}
///|
/// OS reconstruction mirrors the browser field layout (`Browser` and `OS`
/// are structurally identical but distinct types).
fn os_domain_from_json(obj : Map[String, Json]) -> @ua_parser.OS? {
match obj.get("family") {
Some(Json::String(family)) =>
Some({
family,
major: json_string(obj, "major"),
minor: json_string(obj, "minor"),
patch: json_string(obj, "patch"),
patch_minor: json_string(obj, "patch_minor"),
rule_index: json_int(obj, "rule_index"),
})
_ => None
}
}
///|
/// Device reconstruction mirrors the browser/OS field layout
/// (family/brand/model/rule_index); returns a `Device`.
fn device_domain_from_json(obj : Map[String, Json]) -> @ua_parser.Device? {
match obj.get("family") {
Some(Json::String(family)) =>
Some({
family,
brand: json_string(obj, "brand"),
model: json_string(obj, "model"),
rule_index: json_int(obj, "rule_index"),
})
_ => None
}
}
///|
fn json_string(obj : Map[String, Json], key : String) -> String? {
match obj.get(key) {
Some(Json::String(s)) => Some(s)
_ => None
}
}
///|
fn json_int(obj : Map[String, Json], key : String) -> Int? {
match obj.get(key) {
Some(Json::Number(n, ..)) => Some(n.to_int())
_ => None
}
}