Files
zip/urltext_test.go
T
zeekay 9d277517b9 The type decides how a URL value is read, before its kind does
A written form is usually carried by a NUMERIC kind, and deciding by kind first
put the rule out of reach of exactly the values that need it. A P-Chain height
is a u64 whose reserved value is spelled "proposed"; an encoding is a u8 spelled
"json". Both met ParseUint, both failed, and both left the field at zero — so a
request for the proposed height was served as height 0 and a request for JSON
was served as hex, each with a 200 on the answer.

Asking the type first is also encoding/json own precedence: a named string with
an UnmarshalText is decoded through it and not by assignment. Matching that is
what lets a URL and a JSON body on the same struct read one word one way, rather
than a field meaning two things depending on which side it arrived from.

So a type that states how to read itself from text gets that reading, and what
is left is decided by kind. The default branch goes with it: nothing reaches the
switch that has a written form to ask about.
2026-08-29 22:52:19 -07:00

189 lines
5.5 KiB
Go

package zip_test
// A URL carries characters, and some values are written as characters without
// being a Go string. An id is the case that forced this: a 32-byte array is not
// a kind the binder could convert into, so every route addressing a resource by
// id published a parameter that bound nothing and ran the handler on a zero id —
// a wrong answer with a 200 on it. The type already says how to read itself from
// text; these pin that the binder asks.
import (
"context"
"encoding/hex"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"github.com/zap-proto/zip"
)
// hash is the shape of every id in the fleet: an array, written as text.
type hash [4]byte
func (h hash) MarshalText() ([]byte, error) { return []byte(hex.EncodeToString(h[:])), nil }
func (h *hash) UnmarshalText(text []byte) error {
raw, err := hex.DecodeString(string(text))
if err != nil {
return err
}
if len(raw) != len(h) {
return errors.New("hash is 4 bytes")
}
copy(h[:], raw)
return nil
}
// tone is a named string that ALSO reads text, and reads it differently. The
// type decides, so it gets the reading it declared.
type tone string
func (t *tone) UnmarshalText([]byte) error { *t = "read-as-text"; return nil }
type textIn struct {
ID hash `json:"id"`
Prior *hash `json:"prior"`
Tone tone `json:"tone"`
}
type textOut struct {
ID hash `json:"id"`
Prior *hash `json:"prior"`
Tone tone `json:"tone"`
}
func echoText(_ context.Context, in *textIn) (*textOut, error) {
return &textOut{ID: in.ID, Prior: in.Prior, Tone: in.Tone}, nil
}
func rawText(t *testing.T, path string) string {
t.Helper()
a := zip.New(zip.Config{AppName: "t", DisableStartupMessage: true})
zip.Get(a, "/v1/t/thing", echoText)
zip.Get(a, "/v1/t/thing/:id", echoText)
res, err := a.Fiber().Test(httpGet(t, path))
if err != nil {
t.Fatal(err)
}
body, err := io.ReadAll(res.Body)
if err != nil {
t.Fatal(err)
}
if res.StatusCode != http.StatusOK {
t.Fatalf("GET %s: %d %s", path, res.StatusCode, body)
}
return string(body)
}
func askText(t *testing.T, path string) textOut {
t.Helper()
body := rawText(t, path)
var out textOut
if err := json.Unmarshal([]byte(body), &out); err != nil {
t.Fatalf("GET %s: %v over %s", path, err, body)
}
return out
}
func httpGet(t *testing.T, path string) *http.Request {
t.Helper()
req, err := http.NewRequest(http.MethodGet, path, nil)
if err != nil {
t.Fatal(err)
}
return req
}
func TestURLBindsAValueThatReadsItselfFromText(t *testing.T) {
got := askText(t, "/v1/t/thing?id=deadbeef&prior=0badcafe")
if want := (hash{0xde, 0xad, 0xbe, 0xef}); got.ID != want {
t.Errorf("query id: got %x, want %x", got.ID, want)
}
if got.Prior == nil {
t.Fatal("query prior: nil, want a value")
}
if want := (hash{0x0b, 0xad, 0xca, 0xfe}); *got.Prior != want {
t.Errorf("query prior: got %x, want %x", *got.Prior, want)
}
if got := askText(t, "/v1/t/thing/deadbeef"); got.ID != (hash{0xde, 0xad, 0xbe, 0xef}) {
t.Errorf("path id: got %x, want deadbeef", got.ID)
}
}
func TestURLLeavesTheZeroWhenTextCannotBeRead(t *testing.T) {
got := askText(t, "/v1/t/thing?id=notahash")
if got.ID != (hash{}) {
t.Errorf("got %x, want the zero id: a value that cannot be read is not half-read", got.ID)
}
if got.Prior != nil {
t.Errorf("got %v, want nil: an absent value is not an allocated one", got.Prior)
}
}
// A type that declares how to read itself from text gets that reading, even
// where its KIND could have carried the string on its own.
//
// This is encoding/json's own precedence — a named string with an UnmarshalText
// is decoded through it, not by assignment — so a URL and a JSON body on the
// same struct read one word one way. Deciding by kind first also put the rule
// out of reach of the values that need it most: a written form is usually
// carried by a NUMERIC kind, and a height spelled "proposed" or an encoding
// spelled "json" met ParseUint, failed, and bound zero.
//
// The answer is read as raw text, not unmarshalled: tone reads text on the way
// IN too, so decoding the reply would apply the very rule under test to it.
func TestTheTypeDecidesBeforeItsKind(t *testing.T) {
got := rawText(t, "/v1/t/thing?tone=plain")
if !strings.Contains(got, `"tone":"read-as-text"`) {
t.Errorf("got %s, want the reading tone declared", got)
}
}
// count is the case that forced the ordering: a written form carried by a
// numeric kind, whose word is not a number.
type count uint64
func (c *count) UnmarshalText(text []byte) error {
if string(text) == "all" {
*c = 1 << 20
return nil
}
n, err := strconv.ParseUint(string(text), 10, 64)
*c = count(n)
return err
}
func TestAWrittenFormOnANumericKindIsRead(t *testing.T) {
a := zip.New(zip.Config{AppName: "t", DisableStartupMessage: true})
type countIn struct {
N count `json:"n"`
}
zip.Get(a, "/v1/t/count", func(_ context.Context, in *countIn) (*countIn, error) { return in, nil })
for word, want := range map[string]count{"all": 1 << 20, "7": 7} {
resp, err := a.Test(httptest.NewRequest("GET", "/v1/t/count?n="+word, nil))
if err != nil {
t.Fatal(err)
}
raw, _ := io.ReadAll(resp.Body)
// Read as a plain number: count reads TEXT on the way in, so decoding
// the reply into it would apply the rule under test to the answer.
var got struct {
N uint64 `json:"n"`
}
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatal(err)
}
if got.N != uint64(want) {
t.Errorf("n=%s bound %d, want %d", word, got.N, want)
}
}
}