Files
zip/address.go
zeekayandHanzo Dev 72321b16e2 A parameter's value is one segment of the address
The address is a URL path, so a value substituted into it has to be escaped as a
single segment. A trace id of t1/2 went in raw and read as two, so a service
that forwards what AddressOf answers asked its runtime for a route nobody
registered: the request arrived at the right operation and the forward went
somewhere else. A space did the same thing more quietly.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-17 15:03:43 -07:00

151 lines
5.7 KiB
Go

package zip
import (
"net/url"
"reflect"
"strconv"
"strings"
)
// A ROUTE'S ADDRESS IS A VALUE, AND ITS INPUT ALREADY CARRIES IT.
//
// [registerTyped] binds the segments the router matched onto the op's In —
// /traces/:traceId puts the matched segment in the field that names itself
// traceId (see bindURL, urlFieldName). So by the time a handler runs, the In IS
// the address's parameters, decoded and named.
//
// [Address] is the inverse of that binding, and it exists because the caller
// that has to reconstruct the concrete path — a relay that hands the call on to
// something that speaks HTTP — otherwise spells the address a SECOND time:
//
// opGet(g, "/traces/:traceId", traceSpans) // once, at the route
// relay(ctx, "GET", root+"/traces/"+in.TraceID, …) // and again, by hand
//
// Two spellings of one address drift, and the drift is invisible: the hand-built
// path is what gets requested, so a route pattern that no longer agrees with it
// is never consulted and never contradicted. Reading the parameters back out of
// the In through the SAME rule that put them there closes that by construction —
// there is one spelling, the one the route was registered with.
//
// It is deliberately the inverse of bindURL and not a new rule: same field walk
// (wireFields, so promoted embedded fields count), same naming (urlFieldName, so
// `url:` beats `json:`), same case-insensitive match. A parameter the input does
// not name renders empty rather than panicking, which is what bindURL does with
// the mirror-image miss.
//
// pattern is spelled the way the ROUTER spells it — ":name", optionally
// constrained (":project<guid>") or optional (":id?") — because the router's
// spelling is what a registration states.
func Address(pattern string, in any) string {
if !strings.Contains(pattern, ":") {
return pattern
}
values := addressValues(in)
segments := strings.Split(pattern, "/")
for i, segment := range segments {
if len(segment) < 2 || segment[0] != ':' {
continue
}
// ESCAPED, because the result is a URL PATH and a parameter's value is
// ONE segment of it. A trace id of "t1/2" substituted raw read as two
// segments, so a relay forwarding this address asked its runtime for a
// route nobody registered — the request arrived correctly and the
// forward went elsewhere.
segments[i] = url.PathEscape(values[strings.ToLower(paramName(segment))])
}
return strings.Join(segments, "/")
}
// Template is pattern in the spelling a DOCUMENT writes — ":traceId" becomes
// "{traceId}" — which is the address's public name: the OpenAPI path, the string
// a reference page prints and the key a caller matches an address on.
//
// A constraint and an optional marker are the ROUTER's business (":project<guid>",
// ":id?") and do not survive: they say how a segment is matched, not what it is
// called, and a document that published them would name a parameter no client
// ever writes.
//
// A WILDCARD IS A SEGMENT TOO, and it becomes "{wildcardN}", numbered in path
// order. The router's "*" and "+" match greedily where a document's parameter
// matches one segment, so the name is the closest thing a document can say — but
// it is not a new judgement here: [ID] already spells a wildcard "wildcardN" and
// numbers it the same way, so the two agree by construction and an operation id
// derived from the router's spelling equals one derived from the document's.
//
// Returning "*" verbatim, as this did, published a path no document template can
// mean, and left a caller that reads a route table and a caller that reads the
// registry disagreeing about the SAME address — which resolves as "no such
// operation" rather than as a difference of spelling.
func Template(pattern string) string {
if !strings.ContainsAny(pattern, ":*+") {
return pattern
}
segments := strings.Split(pattern, "/")
stars := 0
for i, segment := range segments {
switch {
case segment == "*" || segment == "+":
stars++
segments[i] = "{wildcard" + strconv.Itoa(stars) + "}"
case len(segment) >= 2 && segment[0] == ':':
segments[i] = "{" + paramName(segment) + "}"
}
}
return strings.Join(segments, "/")
}
// paramName is the name a router segment declares, without the constraint or the
// optional marker the router reads it with.
func paramName(segment string) string {
name := segment[1:]
if cut := strings.IndexAny(name, "<?"); cut >= 0 {
name = name[:cut]
}
return name
}
// addressValues renders every URL-carried field of in as the string the URL
// would carry, keyed by its lowercased URL name so the lookup folds case the way
// bindURL's match does.
func addressValues(in any) map[string]string {
v := reflect.ValueOf(in)
for v.Kind() == reflect.Pointer {
if v.IsNil() {
return nil
}
v = v.Elem()
}
if v.Kind() != reflect.Struct {
return nil
}
out := map[string]string{}
for _, f := range wireFields(v.Type()) {
name := urlFieldName(f)
if name == "" || name == "-" {
continue
}
out[strings.ToLower(name)] = scalarString(v.FieldByIndex(f.Index))
}
return out
}
// scalarString is setScalar read backwards: the wire spelling of one value. A
// kind a URL cannot carry renders empty, which is the same silence setScalar
// keeps when it is handed one.
func scalarString(fv reflect.Value) string {
switch fv.Kind() {
case reflect.String:
return fv.String()
case reflect.Bool:
return strconv.FormatBool(fv.Bool())
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return strconv.FormatInt(fv.Int(), 10)
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return strconv.FormatUint(fv.Uint(), 10)
case reflect.Float32, reflect.Float64:
return strconv.FormatFloat(fv.Float(), 'g', -1, fv.Type().Bits())
default:
return ""
}
}