mirror of
https://github.com/zap-proto/zip.git
synced 2026-10-19 15:38:57 +00:00
App, Group, Operation. A group is a prefix and what holds under it, an operation
is one typed route declared there, and the verb methods are generic methods —
which Go 1.27 allows on a concrete type and still refuses on an interface. So In
and Out come from the handler and the caller writes no type arguments:
users := app.Group("/v1/users")
users.Post("/", api.Create)
mint := users.Group("/mint").With(middleware.Mint(gate))
mint.Post("/deposit", api.Deposit)
A Group is a CLOSED HANDLE: two unexported fields, and no method that hands back
the app, the parent or the fiber underneath. A subsystem given the group at its
own prefix cannot reach above it, so confinement is a property of the type
instead of a rule something has to police — which is what a host needs in order
to hand out a bounded surface and know what comes back.
That is bought by giving up the router interface, deliberately. Router and
OpTarget are gone, and with them the ability to implement a route table outside
this package: a decorator is no longer a router, a gate is middleware on a group,
and a bound is a path. The five package-level verb functions go the same way —
a registration is a method on the thing it registers into.
Raw carries what is not a typed operation: a stream, an upgrade, a probe, a
foreign subtree. It names its method, and it is the longer spelling because
opting out of the typed model is the exception and should read like one.
Co-authored-by: Hanzo Dev <[email protected]>
189 lines
5.4 KiB
Go
189 lines
5.4 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})
|
|
a.Get("/v1/t/thing", echoText)
|
|
a.Get("/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"`
|
|
}
|
|
a.Get("/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)
|
|
}
|
|
}
|
|
}
|