// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0
///|
/// What a hop takes out of a set of request headers: the trace it joins, if the
/// request carried one, and the baggage that came with it.
///
/// One value rather than two calls, because a hop that reads one and forgets the
/// other is a bug this shape makes hard to write: `inject` puts back exactly what
/// `extract` found, changed only where the caller changed it.
#derive(Eq, Debug)
pub(all) struct Extracted {
/// The parent context, or `None` when the request carried no context or carried
/// one this library refused. A refused context is not an error here: W3C Trace
/// Context says a hop starts a new trace rather than failing the request.
context : @trace.TraceContext?
baggage : Baggage
}
///|
pub extend Extracted with Eq::{equal, not_equal}
///|
pub extend Extracted with Debug::{to_repr}
///|
/// Read the propagation headers out of `headers`, which is the shape both an ASGI
/// scope and an outgoing request carry.
///
/// Header names are matched without regard to case; a value is taken as it is,
/// with the whitespace around it dropped.
///
/// Two `traceparent` headers refuse each other: the specification says a request
/// with more than one is invalid, and picking one of the two would be guessing
/// which proxy wrote the one that matters. Several `tracestate` or `baggage`
/// headers are combined in order, as RFC 7230 allows a field to be split across
/// lines.
///
/// A header that does not parse leaves that field empty and the rest intact: a
/// mangled `tracestate` must not cost a hop the trace, and a mangled `baggage`
/// must not cost it the trace either.
pub fn extract(headers : Array[(String, String)]) -> Extracted {
let parents = values(headers, traceparent_header)
let context = if parents.length() == 1 {
let states = values(headers, tracestate_header)
let state = if states.is_empty() { None } else { Some(join(states)) }
join_trace(parents[0][:], state)
} else {
None
}
let carried = values(headers, baggage_header)
let baggage = read_baggage(carried)
{ context, baggage, }
}
///|
/// Put `extracted` back into `headers`: the propagation headers already there are
/// removed and rewritten from the context and the bag, and everything else is left
/// exactly as it was.
///
/// Rewriting rather than appending is what makes this safe to call on a request
/// that already carried a context — the one from the sender must not survive next
/// to the one this hop decided on.
pub fn inject(
headers : Array[(String, String)],
extracted : Extracted,
) -> Array[(String, String)] {
let out = headers.filter(header => !is_propagation_header(header.0))
match extracted.context {
Some(context) if context.is_valid() => {
out.push((traceparent_header, context.to_traceparent()))
if !context.state.is_empty() {
out.push((tracestate_header, context.state.to_header()))
}
}
_ => ()
}
if !extracted.baggage.is_empty() {
out.push((baggage_header, extracted.baggage.to_header()))
}
out
}
///|
/// The context `traceparent` names, with the vendor state `tracestate` carries if
/// that header is readable.
///
/// The two are read separately because W3C Trace Context §3.3.1 says a `tracestate`
/// that fails to parse must not affect the `traceparent`: a hop that dropped the
/// trace over a mangled vendor header would break the one thing the header exists
/// to protect. A `traceparent` that fails, on the other hand, leaves no trace to
/// join, and the caller starts a new one rather than failing the request.
fn join_trace(
traceparent : StringView,
tracestate : String?,
) -> @trace.TraceContext? {
let context = parse_traceparent(traceparent) catch { _ => return None }
let state = match tracestate {
None => @trace.empty_trace_state
Some(text) =>
@trace.TraceState::parse(text[:]) catch {
_ => @trace.empty_trace_state
}
}
Some({ ..context, state, })
}
///|
/// The bag the `baggage` field lines describe, or an empty one when the header is
/// refused.
fn read_baggage(carried : Array[String]) -> Baggage {
if carried.is_empty() {
return empty_baggage
}
let baggage = Baggage::parse(join(carried)[:]) catch {
_ => return empty_baggage
}
baggage
}
///|
/// Whether `name` is one of the three headers this package owns.
fn is_propagation_header(name : String) -> Bool {
let name = name.to_lower()
name == traceparent_header ||
name == tracestate_header ||
name == baggage_header
}
///|
/// Every value of `name` in `headers`, in order, matched without regard to case.
fn values(headers : Array[(String, String)], name : String) -> Array[String] {
let out : Array[String] = []
for header in headers {
if header.0.to_lower() == name {
out.push(header.1)
}
}
out
}
///|
/// Several field lines as one value, which is what RFC 7230 §3.2.2 says a repeated
/// header means.
fn join(parts : Array[String]) -> String {
let out = StringBuilder()
for i, part in parts {
if i > 0 {
out.write_char(',')
}
out.write_string(part)
}
out.to_string()
}