A Base's records are a typed GraphQL schema, answered through its own router

graph.New(app, router) renders the Base's collections as GraphQL and
answers every field with a request on the Base's own router, made with
the caller's headers, so the list and view rules, tenancy, rate limits
and expand's view checks are the REST path's and there is no second
authorization. zip's engine executes it: a host mounts g.Schema and
g.Resolve with zip.MountGraphFor, so zip is bumped to v1.37.43.

- A type per base and view collection, named from the collection
  (people: BasePerson, person, people, peopleGroupBy, createPerson,
  updatePerson, upsertPerson, deletePerson), with its fields, relations
  as objects, the records pointing at it as connections
  (opportunities_via_company) and its history.
- A filter input per collection whose operators per field type are the
  where clause's: each is one leaf of core.CompileWhere, so and, or,
  not, @me, relative dates, back-relations and inSegment mean what they
  mean there, every value bound.
- orderBy, cursor connections paged through Base's pages, a totalCount
  asked only when selected, and aggregate and GroupBy from the
  aggregate route.
- Bounds: 100 a page, four relations deep refused before any request,
  50 requests a root field, and zip's 10,000 resolutions.

graph_test drives it through zip against a CRM Base: every filter type,
paging both ways, the figures, the writes as REST reads them back, the
refusals in REST's own words (a rule, a superuser-only collection,
another org's token), the budget, and type names on every object.
tests.IAM signs IAM tokens for any test, and apis' helper uses it.
This commit is contained in:
2026-10-19 09:31:00 -07:00
parent a9d2f1ff46
commit ab9da76ea8
14 changed files with 3428 additions and 43 deletions
+39
View File
@@ -27,6 +27,7 @@ its own Base at `<data dir>/orgs/<org>/data.db`.
| `plugins/zap` | the ZAP listener, which serves the HTTP handler |
| `plugins/extruntime`, `plugins/gojavm` | the runtime interface and the goja runtime behind `/v1/functions` |
| `plugins/{migratecmd,ghupdate,waitlist,bootnode,calendar,cloudsql,commerce}` | what their names say |
| `graph/` | a Base's records as typed GraphQL: the schema rendered from its collections, every field answered by a request on its own router as the caller (see "The graph") |
| `modules/` | named collection sets an org installs into its Base (`POST /v1/modules/{name}`, `core.GrowCollections`); `modules/crm` is the CRM (`crm.json`), blank-imported by the binary |
| `plugins/{ha,replicate,tasks,scheduler,vault,pyvm,v8vm,wasmvm,starkvm,extbench}` | plugins the binary does not link |
| `network/` | quasar replication (`BASE_NETWORK=quasar`) and its `s3://` archive |
@@ -201,6 +202,44 @@ prefix. Outbound calls follow the same rule: the Tasks client posts to
`/v1/tasks/namespaces/default`. `route_prefix_test.go` fails on a `/api/` string
literal anywhere in the module or a quoted `/api` path in `ui-react` or `sdk`.
## The graph
`graph.New(app, router)` is the Base's records as GraphQL, executed by zip's
engine: a host mounts `g.Schema` and `g.Resolve` with `zip.MountGraphFor` (cloud
serves it at `/v1/base/graphql` and composes it into `/v1/graphql` as `base`).
The package has no transport of its own and no second authorization.
- **The schema is the Base's own**, rendered from its collections and rebuilt
when one changes (keyed by each collection's id and `updated`). Base and view
collections are types; a collection Base keeps for itself (`_*`, system) and an
auth collection are not, and a relation to one reads as the id it holds.
Naming: `people` → `BasePerson`, roots `person(id)`, `people(...)`,
`peopleGroupBy(field, filter)`, `createPerson`, `updatePerson`,
`upsertPerson(data, on)`, `deletePerson`; a name that would collide is left out
(`model.skipped`). Field names are Base's, verbatim. Every type starts with
`Base`, so a host composes it without renaming.
- **Every field is a request on the Base's router**, made with the caller's
headers (`zip.Headers`): the records list (`?where=`, `sort`, `expand`), view,
create, update, delete, `/aggregate` and `/history`. Rules, tenancy, rate limits
and expand's view checks are therefore the REST path's; `graph_test` holds the
graph's refusals to REST's own messages.
- **A filter is a where clause.** `BasePersonFilter` is `and`/`or`/`not`,
`inSegment`/`notInSegment`, each field's own filter and each back-relation
(`list_members_via_person`), and every operator becomes one leaf of
`core.CompileWhere`; `@me`, relative dates, `tz` and `weekStart` mean what they
mean there. `orderBy` is a list of one-field objects (a composite part, or one
relation deep), with the id appended so the order is total.
- **A cursor is a position.** Base pages by page and perPage, so a window of n
from an offset is read in pages of n+1, one page or the two it spans; inserts
before a cursor shift what follows it. `totalCount` is counted only when
selected, and `aggregate` (on any connection) and `GroupBy` are the aggregate
route, over the filter and never the page.
- **Bounds.** A page holds at most 100; a selection reaches at most four
relations, back-relations or histories deep, refused before any request; one
root field makes at most 50 requests; zip refuses a request past 10,000
resolutions whole.
- **No subscriptions.** Realtime is the SSE stream, under the same rules.
## Admin UI
`ui-react`: React 19, TanStack Router, `@hanzo/ui` components on `@hanzo/gui`,
+2 -41
View File
@@ -1,16 +1,9 @@
package apis_test
import (
"crypto/rand"
"crypto/rsa"
"encoding/base64"
"encoding/json"
"math/big"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/hanzoai/base/apis"
@@ -783,40 +776,8 @@ func TestExternalAuthOnlyHasNoFallback(t *testing.T) {
func mintIAMToken(t testing.TB, claims jwt.MapClaims) (token, jwksURL string) {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
const kid = "iam-test"
jwks := map[string]any{"keys": []map[string]any{{
"kty": "RSA",
"kid": kid,
"alg": "RS256",
"use": "sig",
"n": base64.RawURLEncoding.EncodeToString(key.PublicKey.N.Bytes()),
"e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(key.PublicKey.E)).Bytes()),
}}}
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(jwks)
}))
t.Cleanup(ts.Close)
claims["iat"] = time.Now().Unix()
claims["exp"] = time.Now().Add(time.Hour).Unix()
jt := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
jt.Header["kid"] = kid
signed, err := jt.SignedString(key)
if err != nil {
t.Fatal(err)
}
return signed, ts.URL
iam := tests.NewIAM(t)
return iam.Token(t, claims), iam.URL
}
// TestTheUsernameIsNotADisplayName pins which claim the account's USERNAME is
+4 -2
View File
@@ -42,8 +42,8 @@ require (
github.com/spf13/cast v1.10.0
github.com/spf13/cobra v1.10.2
github.com/valyala/fasthttp v1.74.0
github.com/zap-proto/http v0.3.9
github.com/zap-proto/zip v1.37.3
github.com/zap-proto/http v0.3.12
github.com/zap-proto/zip v1.37.43
go.starlark.net v0.0.0-20260908191801-89a6a09411d5
golang.org/x/crypto v0.57.0
golang.org/x/image v0.46.0
@@ -72,6 +72,7 @@ require (
github.com/dop251/base64dec v0.0.0-20231022112746-c6c9f9a96217 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/evanw/esbuild v0.28.2 // indirect
github.com/fasthttp/websocket v1.5.12 // indirect
github.com/fxamacker/cbor/v2 v2.9.1 // indirect
github.com/go-ini/ini v1.67.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
@@ -148,6 +149,7 @@ require (
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38 // indirect
github.com/spaolacci/murmur3 v1.1.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/stretchr/testify v1.11.1 // indirect
+4
View File
@@ -451,10 +451,14 @@ github.com/zap-proto/go v1.8.3 h1:GWhb8y7ylgVcaVOR8XN/It56Xovm5Mkv+nQqiblnnoA=
github.com/zap-proto/go v1.8.3/go.mod h1:fbNeWcssRRbTkQEDCrmUmzqJA1gKcAlgi1z4QrtEFZg=
github.com/zap-proto/http v0.3.9 h1:fD/Zx8FAglMokLGg23dq5Ca3744Hm7bqt3bv8gUk4zI=
github.com/zap-proto/http v0.3.9/go.mod h1:UYfGhDDCetgxs65XSev8Lpf65COg5vKQK+cWwZGh4zQ=
github.com/zap-proto/http v0.3.12 h1:74EvTeTZxikxctOLKno3UvGWVv3hiaE35UleAU5Nrc0=
github.com/zap-proto/http v0.3.12/go.mod h1:Gw4Kn/PpF1P+CHJyBQQ3aoFDI3P4p0wsjURFwjPnHzE=
github.com/zap-proto/mcp v1.0.5 h1:1Hi0KNi4YlWW2zkQUMDK9n3jP3wZaRJ1rseMW9p9F0c=
github.com/zap-proto/mcp v1.0.5/go.mod h1:cYOsq+33NyW4X8nUVtIs/qtezVovYazw1s0V7mrQBYA=
github.com/zap-proto/zip v1.37.3 h1:QdSB0xqr4c7frwR8c+bVTQSWIpiavPYk3AnMXOpj/LA=
github.com/zap-proto/zip v1.37.3/go.mod h1:mYE5BMXclZ14Wz4tw/3ir+QBI7A7DPpi3ROXgwnXpfU=
github.com/zap-proto/zip v1.37.43 h1:bm8LnTL+odj9r6BVaROU9Fw6nW7dvyshKtz18RjsO5Y=
github.com/zap-proto/zip v1.37.43/go.mod h1:HbZOq92/BIj7YfnCO2u8s4HdiXXv8wLYy4R+Ina4tlo=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI=
+82
View File
@@ -0,0 +1,82 @@
package graph
import "github.com/hanzoai/base/core"
// composite is the shape of a composite field's value: the object it answers,
// the parts a filter, a sort and an input name, in the order its field names
// them (graph_test holds the two to each other).
type composite struct {
typ string // FullName: BaseFullName, BaseFullNameFilter, …
parts []part
write bool // takes a value in an Input; an actor is Base's to stamp
text bool // contains reads its text parts (the field is a core.TextParts)
}
// part is one named part of a composite value.
type part struct {
name string
kind kind // kText or kNumber; a list part is kJSON (only contains)
list bool // a list of values: answered whole, filtered by contains
elem *object // the object a list part holds, nil for a list of strings
}
// object is an object inside a composite part: a phone, a link.
type object struct {
typ string
fields []string // every one a String
}
var (
phone = &object{typ: "Phone", fields: []string{"number", "countryCode", "callingCode"}}
link = &object{typ: "Link", fields: []string{"url", "label"}}
)
func text(name string) part { return part{name: name, kind: kText} }
func number(name string) part { return part{name: name, kind: kNumber} }
// composites is every composite field type, by the field type it shapes.
var composites = map[string]*composite{
core.FieldTypeCurrency: {typ: "Currency", write: true, parts: []part{
number("amountMicros"), text("currencyCode"),
}},
core.FieldTypeEmails: {typ: "Emails", write: true, text: true, parts: []part{
text("primaryEmail"), {name: "additionalEmails", kind: kJSON, list: true},
}},
core.FieldTypePhones: {typ: "Phones", write: true, text: true, parts: []part{
text("primaryPhoneNumber"), text("primaryPhoneCountryCode"), text("primaryPhoneCallingCode"),
{name: "additionalPhones", kind: kJSON, list: true, elem: phone},
}},
core.FieldTypeLinks: {typ: "Links", write: true, text: true, parts: []part{
text("primaryLinkUrl"), text("primaryLinkLabel"),
{name: "secondaryLinks", kind: kJSON, list: true, elem: link},
}},
core.FieldTypeAddress: {typ: "Address", write: true, text: true, parts: []part{
text("addressStreet1"), text("addressStreet2"), text("addressCity"),
text("addressState"), text("addressPostcode"), text("addressCountry"),
number("addressLat"), number("addressLng"),
}},
core.FieldTypeFullName: {typ: "FullName", write: true, text: true, parts: []part{
text("firstName"), text("lastName"),
}},
core.FieldTypeActor: {typ: "Actor", text: true, parts: []part{
text("source"), text("id"), text("name"),
}},
}
func (c *composite) part(name string) *part {
for i := range c.parts {
if c.parts[i].name == name {
return &c.parts[i]
}
}
return nil
}
func (c *composite) sortable() bool {
for _, p := range c.parts {
if !p.list {
return true
}
}
return false
}
+37
View File
@@ -0,0 +1,37 @@
package graph
import (
"slices"
"testing"
"github.com/hanzoai/base/core"
)
// Every composite field type's parts are the graph's, in the field's order, and
// contains reads text where the field says it has text to read.
func TestCompositesAreTheFieldsOwn(t *testing.T) {
t.Parallel()
for ft, c := range composites {
f := core.Fields[ft]()
field, ok := f.(core.CompositeField)
if !ok {
t.Fatalf("%s is not a composite", ft)
}
var names []string
for _, p := range c.parts {
names = append(names, p.name)
}
if !slices.Equal(names, field.Parts()) {
t.Errorf("%s: the graph names %v, the field %v", ft, names, field.Parts())
}
if _, text := f.(core.TextParts); text != c.text {
t.Errorf("%s: contains reads text in the graph %v, in the field %v", ft, c.text, text)
}
}
for ft, factory := range core.Fields {
if core.IsComposite(factory()) && composites[ft] == nil {
t.Errorf("the graph has no shape for the composite %s", ft)
}
}
}
+305
View File
@@ -0,0 +1,305 @@
package graph
import (
"fmt"
"slices"
"sort"
"strings"
"github.com/zap-proto/zip"
)
// where turns a filter argument into a where clause over c (core.CompileWhere's
// language), every value kept as the data it arrived as, so it is compiled,
// bound and checked against the caller's rules by the same code a REST `where`
// is. at is the path the filter is reached by: "" at the collection, or a
// relation's path ending in a dot.
func (m *model) where(c *coll, v any, at string) (map[string]any, error) {
obj, ok := v.(map[string]any)
if !ok {
return nil, bad("%sFilter takes an object", c.typ)
}
var all []any
for _, k := range keys(obj) {
val := obj[k]
if val == nil {
continue
}
switch k {
case "and", "or":
items, ok := val.([]any)
if !ok {
items = []any{val}
}
kids := make([]any, 0, len(items))
for _, item := range items {
kid, err := m.where(c, item, at)
if err != nil {
return nil, err
}
kids = append(kids, kid)
}
all = append(all, map[string]any{k: kids})
case "not":
kid, err := m.where(c, val, at)
if err != nil {
return nil, err
}
all = append(all, map[string]any{"not": kid})
case "inSegment", "notInSegment":
if !m.segments {
return nil, bad("%sFilter has no field %q", c.typ, k)
}
if at != "" {
return nil, bad("%s selects %s records, not records reached through %s", k, c.c.Name, strings.TrimSuffix(at, "."))
}
all = append(all, map[string]any{"op": k, "value": val})
default:
if f := c.byName[k]; f != nil && f.filterable() {
leaves, err := m.leaves(f.filterOf(), f, nil, val, at+k)
if err != nil {
return nil, err
}
all = append(all, leaves...)
continue
}
if b := c.byBack[k]; b != nil {
kid, err := m.where(b.from, val, at+k+".")
if err != nil {
return nil, err
}
all = append(all, kid)
continue
}
return nil, bad("%sFilter has no field %q", c.typ, k)
}
}
switch len(all) {
case 0:
return map[string]any{}, nil
case 1:
return all[0].(map[string]any), nil
}
return map[string]any{"and": all}, nil
}
// leaves turns one field's operators into where leaves on path. f is the field
// when the filter is a field's own, p the part when it is a composite part's.
func (m *model) leaves(typ string, f *field, p *part, v any, path string) ([]any, error) {
ops, ok := v.(map[string]any)
if !ok {
return nil, bad("%s on %q takes an object of operators", typ, path)
}
allowed := m.opsOf(typ, f)
// a date's time zone and week start belong to every leaf it makes
mods := map[string]any{}
for _, k := range []string{"tz", "weekStart"} {
if x, ok := ops[k]; ok && x != nil && typ == "BaseDateFilter" {
mods[k] = x
}
}
leaf := func(op string, value any) map[string]any {
l := map[string]any{"field": path, "op": op}
if value != nil {
l["value"] = value
}
for k, x := range mods {
l[k] = x
}
return l
}
var out []any
for _, k := range keys(ops) {
val := ops[k]
if val == nil {
continue
}
if !slices.Contains(allowed, k) {
return nil, bad("%s on %q has no operator %q", typ, path, k)
}
switch k {
case "tz", "weekStart":
// read above, with every leaf
case "empty":
b, ok := val.(bool)
if !ok {
return nil, bad("empty on %q is true or false", path)
}
if b {
out = append(out, leaf("empty", nil))
} else {
out = append(out, leaf("notEmpty", nil))
}
case "today", "past", "future":
b, ok := val.(bool)
if !ok {
return nil, bad("%s on %q is true or false", k, path)
}
if b {
out = append(out, leaf(k, nil))
} else {
out = append(out, map[string]any{"not": leaf(k, nil)})
}
case "where":
kid, err := m.where(f.target, val, path+".")
if err != nil {
return nil, err
}
out = append(out, kid)
default:
if f != nil && f.kind == kComposite && p == nil {
if part := f.comp.part(k); part != nil {
sub, err := m.leaves(partFilter(part), nil, part, val, path+"."+k)
if err != nil {
return nil, err
}
out = append(out, sub...)
continue
}
}
out = append(out, leaf(k, val))
}
}
return out, nil
}
// opsOf is every key a filter input takes.
func (m *model) opsOf(typ string, f *field) []string {
if ops, ok := sharedOps[typ]; ok {
out := make([]string, len(ops))
for i, o := range ops {
out[i] = o.name
}
return out
}
if f != nil && f.kind == kRelation && f.target != nil {
return []string{"is", "isNot", "empty", "where"}
}
if f != nil && f.kind == kComposite {
var out []string
if f.comp.text {
out = append(out, "contains", "notContains")
}
out = append(out, "empty")
for _, p := range f.comp.parts {
out = append(out, p.name)
}
return out
}
return nil
}
// partFilter is the filter a composite part takes.
func partFilter(p *part) string {
switch {
case p.list:
return "BaseContainsFilter"
case p.kind == kNumber:
return "BaseNumberFilter"
}
return "BaseTextFilter"
}
// sortOf turns an orderBy argument into Base's sort, each term a field path
// with its direction, and the record id last so the order is total.
func (m *model) sortOf(c *coll, v any) (string, error) {
if v == nil {
return "", nil
}
items, ok := v.([]any)
if !ok {
items = []any{v}
}
var terms []string
byID := false
for _, item := range items {
path, dir, err := m.sortTerm(c, item, "", 0)
if err != nil {
return "", err
}
if path == "id" {
byID = true
}
terms = append(terms, dir+path)
}
if len(terms) > 0 && !byID {
terms = append(terms, "id")
}
return strings.Join(terms, ","), nil
}
// sortTerm reads one orderBy entry: one field, and one of its parts or a
// related record's field below it.
func (m *model) sortTerm(c *coll, v any, at string, hops int) (string, string, error) {
obj, ok := v.(map[string]any)
if !ok || len(obj) != 1 {
return "", "", bad("each %sOrderBy names one field, so the order is the list's", c.typ)
}
for k, val := range obj {
f := c.byName[k]
if f == nil || !f.sortable() {
return "", "", bad("%sOrderBy has no field %q", c.typ, k)
}
switch f.kind {
case kComposite:
sub, ok := val.(map[string]any)
if !ok || len(sub) != 1 {
return "", "", bad("%s orders by one of its parts", k)
}
for pk, pv := range sub {
p := f.comp.part(pk)
if p == nil || p.list {
return "", "", bad("%s has no part %q to order by", k, pk)
}
dir, err := direction(pv)
return at + k + "." + pk, dir, err
}
case kRelation:
if s, ok := val.(string); ok {
dir, err := direction(s)
return at + k, dir, err
}
if f.target == nil {
return "", "", bad("%s orders by its id: Asc or Desc", k)
}
if hops >= 1 {
return "", "", bad("an order reaches one relation deep")
}
return m.sortTerm(f.target, val, at+k+".", hops+1)
default:
dir, err := direction(val)
return at + k, dir, err
}
}
return "", "", bad("each %sOrderBy names one field", c.typ)
}
func direction(v any) (string, error) {
switch v {
case "Asc":
return "", nil
case "Desc":
return "-", nil
}
return "", bad("an order is Asc or Desc, not %v", v)
}
func keys(m map[string]any) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
// bad is a refusal the caller reads.
func bad(format string, args ...any) error {
return zip.ErrBadRequest(fmt.Sprintf(format, args...))
}
+101
View File
@@ -0,0 +1,101 @@
// Package graph is a Base's records as a typed GraphQL schema: one object type
// per collection, with its fields, its relations and the records that point at
// it; a filter input per collection whose operators per field type are the
// where clause's (core.CompileWhere); an orderBy; cursor connections with a
// total; aggregates; create, update, upsert and delete; and each record's
// history.
//
// It has no transport and no second authorization. The schema is rendered from
// the Base's own collections, so it is per Base and changes with them, and every
// field is answered by a request on the Base's own router made as the caller:
// the record routes, the aggregate route and the history route, with their list
// and view rules, their tenancy and their rate limits, are the REST path's.
// zip's engine parses, binds variables and fragments, applies the bounds and the
// request's resolution budget, and projects each answer through its selection
// (zip.MountGraphFor). A host mounts it with the router it serves the Base with:
//
// g := graph.New(app, router)
// zipApp.MountGraphFor("/v1/base/graphql", g.Schema, g.Resolve)
//
// There are no subscriptions: realtime is Base's own SSE stream, under the same
// rules, and a client refetches what changed by id.
package graph
import (
"context"
"crypto/sha256"
"encoding/hex"
"net/http"
"sync"
"github.com/hanzoai/base/core"
)
// prefix begins every type name, so a host composing this schema into a larger
// one (api.hanzo.ai's /v1/graphql, under `base`) adds it without renaming.
const prefix = "Base"
// Graph is one Base's records as GraphQL.
type Graph struct {
app core.App
router http.Handler
mu sync.Mutex
key string
m *model
sdl string
}
// New serves app's records, answering every field through router, the
// handler that serves app's REST routes.
func New(app core.App, router http.Handler) *Graph {
return &Graph{app: app, router: router}
}
// Schema is the SDL of the Base's records as they stand.
func (g *Graph) Schema(context.Context) (string, error) {
_, sdl, err := g.current()
return sdl, err
}
// current is the model and schema of the Base's collections as they stand,
// built again only when a collection has changed since the last one.
func (g *Graph) current() (*model, string, error) {
collections, _ := g.app.Store().Get(core.StoreKeyCachedCollections).([]*core.Collection)
if collections == nil {
var err error
if collections, err = g.app.FindAllCollections(); err != nil {
return nil, "", err
}
}
h := sha256.New()
for _, c := range collections {
h.Write([]byte(c.Id))
h.Write([]byte{0})
h.Write([]byte(c.Updated.String()))
h.Write([]byte{0})
}
key := hex.EncodeToString(h.Sum(nil))
g.mu.Lock()
defer g.mu.Unlock()
if g.m != nil && g.key == key {
return g.m, g.sdl, nil
}
m := build(collections)
for _, c := range m.colls {
for _, f := range c.fields {
if f.kind == kComposite {
m.used[f.f.Type()] = true
}
}
}
if m.history {
m.used[core.FieldTypeActor] = true
}
g.m, g.sdl, g.key = m, m.render(), key
return g.m, g.sdl, nil
}
+808
View File
@@ -0,0 +1,808 @@
package graph_test
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"slices"
"sort"
"strings"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/hanzoai/base/apis"
"github.com/hanzoai/base/core"
"github.com/hanzoai/base/graph"
"github.com/hanzoai/base/modules"
"github.com/hanzoai/base/modules/crm"
"github.com/hanzoai/base/tests"
"github.com/hanzoai/base/tools/types"
"github.com/zap-proto/zip"
)
// fx is one org's Base with the CRM installed and served the way a host serves
// it: the REST router, and the graph mounted on zip's engine beside it.
type fx struct {
app *tests.TestApp
mux http.Handler
zip *zip.App
iam *tests.IAM
ann string // a member of acme
bob string // an admin of acme
zed string // the owner of zoo, another org
ids map[string]string
}
func setup(t *testing.T) *fx {
t.Helper()
app, err := tests.NewTestApp()
if err != nil {
t.Fatal(err)
}
t.Cleanup(app.Cleanup)
if _, err := modules.Install(app, crm.Name); err != nil {
t.Fatal(err)
}
iam := tests.NewIAM(t)
app.Store().Set(apis.StoreKeyExternalAuthOnly, true)
app.Store().Set(apis.StoreKeyJWKSURL, iam.URL)
app.Store().Set(apis.StoreKeyTenant, "acme")
member := func(sub, name, org, role string) string {
return iam.Token(t, jwt.MapClaims{"sub": sub, "email": name + "@" + org + ".test", "name": name,
"orgs": []any{map[string]any{"org": org, "role": role}}})
}
f := &fx{
app: app, iam: iam, ids: map[string]string{},
ann: member("acme/ann", "Ann", "acme", "member"),
bob: member("acme/bob", "Bob", "acme", "admin"),
zed: member("zoo/zed", "Zed", "zoo", "owner"),
}
f.mux = serve(t, app)
g := graph.New(app, f.mux)
f.zip = zip.New(zip.Config{DisableStartupMessage: true})
f.zip.MountGraphFor("/v1/graphql", g.Schema, g.Resolve)
return f
}
// serve compiles app's router the way a host does: the router, then OnServe so
// plugins bind their routes, then the mux.
func serve(t *testing.T, app core.App) http.Handler {
t.Helper()
router, err := apis.NewRouter(app)
if err != nil {
t.Fatal(err)
}
var mux http.Handler
e := &core.ServeEvent{App: app, Router: router, Server: &http.Server{}}
err = app.OnServe().Trigger(e, func(e *core.ServeEvent) error {
m, err := e.Router.BuildMux()
mux = m
return err
})
if err != nil {
t.Fatal(err)
}
return mux
}
type answer struct {
Data map[string]any `json:"data"`
Errors []zip.GraphError `json:"errors"`
status int
}
func (a answer) messages() string {
var out []string
for _, e := range a.Errors {
out = append(out, e.Message)
}
return strings.Join(out, " | ")
}
// gql posts one GraphQL request as the bearer of token ("" for nobody).
func (f *fx) gql(t *testing.T, token, query string, vars map[string]any) answer {
t.Helper()
body, _ := json.Marshal(map[string]any{"query": query, "variables": vars})
req := httptest.NewRequest(http.MethodPost, "/v1/graphql", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
res, err := f.zip.Test(req, zip.TestConfig{Timeout: time.Minute})
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
raw, _ := io.ReadAll(res.Body)
var a answer
if err := json.Unmarshal(raw, &a); err != nil {
t.Fatalf("not a GraphQL answer (%d): %s", res.StatusCode, raw)
}
a.status = res.StatusCode
return a
}
// ok runs a request that must answer without an error.
func (f *fx) ok(t *testing.T, token, query string, vars map[string]any) map[string]any {
t.Helper()
a := f.gql(t, token, query, vars)
if len(a.Errors) > 0 || a.Data == nil {
t.Fatalf("errors: %s\n%s", a.messages(), query)
}
return a.Data
}
// rest makes one REST request of the same Base, as the bearer of token.
func (f *fx) rest(t *testing.T, token, method, path, body string) (int, map[string]any) {
t.Helper()
var rd io.Reader
if body != "" {
rd = strings.NewReader(body)
}
req := httptest.NewRequest(method, path, rd)
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
w := httptest.NewRecorder()
f.mux.ServeHTTP(w, req)
var out map[string]any
_ = json.Unmarshal(w.Body.Bytes(), &out)
return w.Code, out
}
// save writes a record through the Go API.
func (f *fx) save(t *testing.T, coll string, data map[string]any) string {
t.Helper()
c, err := f.app.FindCollectionByNameOrId(coll)
if err != nil {
t.Fatal(err)
}
r := core.NewRecord(c)
for k, v := range data {
r.Set(k, v)
}
if err := f.app.Save(r); err != nil {
t.Fatalf("save %s: %v", coll, err)
}
return r.Id
}
// people is the fixture's records: two companies, three people, a list with
// Ada on it, a segment of the leads and the qualified, an opportunity and a
// note.
func (f *fx) people(t *testing.T) map[string]string {
t.Helper()
ids := f.ids
ids["acme"] = f.save(t, "companies", map[string]any{
"name": "Acme", "employees": 50, "icp": true,
"annual_revenue": types.Currency{AmountMicros: 5_000_000_000_000, CurrencyCode: "USD"},
"address": types.Address{AddressCity: "London", AddressCountry: "GB"},
})
ids["globex"] = f.save(t, "companies", map[string]any{
"name": "Globex", "employees": 500,
"annual_revenue": types.Currency{AmountMicros: 2_000_000_000_000, CurrencyCode: "EUR"},
})
ids["ada"] = f.save(t, "people", map[string]any{
"name": types.FullName{FirstName: "Ada", LastName: "Lovelace"}, "job_title": "Engineer",
"emails": types.Emails{PrimaryEmail: "[email protected]"}, "phones": types.Phones{PrimaryPhoneNumber: "+1 (415) 555-0100"},
"company": ids["acme"], "lifecycle": "lead", "consent": map[string]any{"email": true},
})
ids["grace"] = f.save(t, "people", map[string]any{
"name": types.FullName{FirstName: "Grace", LastName: "Hopper"}, "job_title": "Admiral",
"emails": types.Emails{PrimaryEmail: "[email protected]", AdditionalEmails: []string{"[email protected]"}},
"company": ids["globex"], "lifecycle": "customer",
})
ids["alan"] = f.save(t, "people", map[string]any{
"name": types.FullName{FirstName: "Alan", LastName: "Turing"}, "lifecycle": "qualified",
})
ids["list"] = f.save(t, "lists", map[string]any{"name": "Launch", "object": "people"})
f.save(t, "list_members", map[string]any{"list": ids["list"], "person": ids["ada"], "source": "manual"})
ids["segment"] = f.save(t, "segments", map[string]any{
"name": "Warm", "object": "people",
"where": map[string]any{"field": "lifecycle", "op": "is", "value": []any{"lead", "qualified"}},
})
ids["deal"] = f.save(t, "opportunities", map[string]any{
"name": "Acme expansion", "stage": "proposal", "close_date": time.Now().UTC().Format("2006-01-02") + " 12:00:00.000Z",
"amount": types.Currency{AmountMicros: 120_000_000_000, CurrencyCode: "USD"}, "company": ids["acme"], "contact": ids["ada"],
})
f.save(t, "opportunities", map[string]any{
"name": "Globex pilot", "stage": "new",
"amount": types.Currency{AmountMicros: 30_000_000_000, CurrencyCode: "EUR"}, "company": ids["globex"],
})
ids["note"] = f.save(t, "notes", map[string]any{"title": "Met Ada", "people": []string{ids["ada"]}})
return ids
}
// names reads the first names off a people connection.
func names(t *testing.T, conn any) []string {
t.Helper()
var out []string
edges, _ := conn.(map[string]any)["edges"].([]any)
for _, e := range edges {
node := e.(map[string]any)["node"].(map[string]any)
name, _ := node["name"].(map[string]any)
out = append(out, fmt.Sprint(name["firstName"]))
}
return out
}
func sorted(s []string) []string {
s = slices.Clone(s)
sort.Strings(s)
return s
}
// ---------------------------------------------------------------------------
// The schema is the Base's own: a Base with the CRM types its people, one
// without does not, a field added to a collection is in the next schema, and
// each is a schema a client can build against.
func TestTheSchemaIsEachBasesOwn(t *testing.T) {
t.Parallel()
f := setup(t)
plain, err := tests.NewTestApp()
if err != nil {
t.Fatal(err)
}
defer plain.Cleanup()
other := graph.New(plain, serve(t, plain))
crmSDL, err := graph.New(f.app, f.mux).Schema(context.Background())
if err != nil {
t.Fatal(err)
}
plainSDL, err := other.Schema(context.Background())
if err != nil {
t.Fatal(err)
}
for _, sdl := range []string{crmSDL, plainSDL} {
if err := zip.CheckSchema(sdl); err != nil {
t.Fatalf("not a schema a client can build against: %v\n%s", err, sdl)
}
}
for _, want := range []string{
"type BasePerson {", "people(filter: BasePersonFilter", "person(id: ID!): BasePerson",
"createPerson(data: BasePersonInput!): BasePerson", "name: BaseFullName",
"company: BaseCompany", "list_members_via_person: BaseListMemberFilter",
"opportunities_via_contact(filter: BaseOpportunityFilter", "history(first: Int, after: String): BaseHistoryConnection!",
"inSegment: ID", "input BaseCompanyRelationFilter {", "peopleGroupBy(field: String!, filter: BasePersonFilter): [BaseGroup!]!",
} {
if !strings.Contains(crmSDL, want) {
t.Errorf("the CRM schema lacks %q", want)
}
}
if strings.Contains(plainSDL, "BasePerson") || strings.Contains(plainSDL, "inSegment") {
t.Fatal("a Base without the CRM has its types")
}
if !strings.Contains(plainSDL, "type BaseDemo1 {") {
t.Fatal("a Base's own collections are not in its schema")
}
// no type a client should not see
for _, never := range []string{"_superusers", "BaseUser ", "_history", "BaseUsers"} {
if strings.Contains(crmSDL, never) {
t.Errorf("the schema names %q", never)
}
}
people, err := f.app.FindCollectionByNameOrId("people")
if err != nil {
t.Fatal(err)
}
people.Fields.Add(&core.TextField{Name: "nickname"})
if err := f.app.Save(people); err != nil {
t.Fatal(err)
}
a := f.ok(t, f.ann, `{ people { totalCount edges { node { nickname } } } }`, nil)
if a["people"].(map[string]any)["totalCount"] != float64(0) {
t.Fatalf("a field added to a collection is not in the graph: %v", a)
}
}
// Each field type's operators select what the where clause they compile to
// selects.
func TestFiltersPerFieldType(t *testing.T) {
t.Parallel()
f := setup(t)
ids := f.people(t)
for _, s := range []struct {
name string
filter string
want []string
}{
{"text is", `{job_title: {is: ["Engineer"]}}`, []string{"Ada"}},
{"text contains ignores case", `{job_title: {contains: ["MIR"]}}`, []string{"Grace"}},
{"text does not contain, empty included", `{job_title: {notContains: ["mir"]}}`, []string{"Ada", "Alan"}},
{"text empty", `{job_title: {empty: true}}`, []string{"Alan"}},
{"text not empty", `{job_title: {empty: false}}`, []string{"Ada", "Grace"}},
{"select any of", `{lifecycle: {is: ["lead", "customer"]}}`, []string{"Ada", "Grace"}},
{"select none of", `{lifecycle: {isNot: ["lead"]}}`, []string{"Alan", "Grace"}},
{"a single value where a list is taken", `{lifecycle: {is: "lead"}}`, []string{"Ada"}},
{"composite contains", `{emails: {contains: ["globex"]}}`, []string{"Grace"}},
{"composite does not contain", `{emails: {notContains: ["acme"]}}`, []string{"Alan", "Grace"}},
{"phone digits", `{phones: {contains: ["415 555"]}}`, []string{"Ada"}},
{"composite part", `{emails: {primaryEmail: {is: ["[email protected]"]}}}`, []string{"Ada"}},
{"composite list part", `{emails: {additionalEmails: {contains: ["navy"]}}}`, []string{"Grace"}},
{"full name part", `{name: {lastName: {is: ["Turing"]}}}`, []string{"Alan"}},
{"composite empty", `{address: {empty: true}}`, []string{"Ada", "Alan", "Grace"}},
{"json contains", `{consent: {contains: ["email"]}}`, []string{"Ada"}},
{"relation is", `{company: {is: ["` + ids["acme"] + `"]}}`, []string{"Ada"}},
{"relation empty", `{company: {empty: true}}`, []string{"Alan"}},
{"through a relation", `{company: {where: {name: {is: ["Globex"]}}}}`, []string{"Grace"}},
{"through a relation's composite", `{company: {where: {annual_revenue: {currencyCode: {is: ["USD"]}}}}}`, []string{"Ada"}},
{"in a list", `{list_members_via_person: {list: {is: ["` + ids["list"] + `"]}}}`, []string{"Ada"}},
{"not in a list", `{not: {list_members_via_person: {list: {is: ["` + ids["list"] + `"]}}}}`, []string{"Alan", "Grace"}},
{"in a segment", `{inSegment: "` + ids["segment"] + `"}`, []string{"Ada", "Alan"}},
{"not in a segment", `{notInSegment: "` + ids["segment"] + `"}`, []string{"Grace"}},
{"id is", `{id: {is: ["` + ids["ada"] + `"]}}`, []string{"Ada"}},
{"id is not", `{id: {isNot: ["` + ids["ada"] + `"]}}`, []string{"Alan", "Grace"}},
{"created today", `{created: {today: true}}`, []string{"Ada", "Alan", "Grace"}},
{"created not today", `{created: {today: false}}`, nil},
{"created this week, weeks from Sunday", `{created: {relative: {direction: "this", unit: "week"}, weekStart: 0}}`, []string{"Ada", "Alan", "Grace"}},
{"created in the past", `{created: {past: true}}`, []string{"Ada", "Alan", "Grace"}},
{"created after a day", `{created: {gt: "2000-01-01"}}`, []string{"Ada", "Alan", "Grace"}},
{"created before a day", `{created: {lt: "2000-01-01"}}`, nil},
{"an or of ands", `{or: [{and: [{lifecycle: {is: ["lead"]}}, {company: {is: ["` + ids["acme"] + `"]}}]}, {name: {lastName: {is: ["Turing"]}}}]}`, []string{"Ada", "Alan"}},
{"keys of one object hold together", `{lifecycle: {is: ["lead", "customer"]}, job_title: {is: ["Admiral"]}}`, []string{"Grace"}},
{"not of an or", `{not: {or: [{lifecycle: {is: ["lead"]}}, {lifecycle: {is: ["customer"]}}]}}`, []string{"Alan"}},
{"an empty filter selects all", `{}`, []string{"Ada", "Alan", "Grace"}},
} {
t.Run(s.name, func(t *testing.T) {
a := f.ok(t, f.ann, `{ people(filter: `+s.filter+`) { edges { node { name { firstName } } } } }`, nil)
if got := sorted(names(t, a["people"])); strings.Join(got, ",") != strings.Join(s.want, ",") {
t.Fatalf("got %v, want %v", got, s.want)
}
})
}
for _, s := range []struct {
name string
coll string
filter string
want int
}{
{"number above", "companies", `{employees: {gt: 100}}`, 1},
{"number at most", "companies", `{employees: {lte: 50}}`, 1},
{"number one of", "companies", `{employees: {is: [50, 500]}}`, 2},
{"bool", "companies", `{icp: {is: true}}`, 1},
{"currency amount", "companies", `{annual_revenue: {amountMicros: {gte: 3000000000000}}}`, 1},
{"currency code", "companies", `{annual_revenue: {currencyCode: {is: ["EUR"]}}}`, 1},
{"address part", "companies", `{address: {addressCity: {is: ["London"]}}}`, 1},
{"address contains", "companies", `{address: {contains: ["london"]}}`, 1},
{"a date on a day", "opportunities", `{close_date: {is: "` + time.Now().UTC().Format("2006-01-02") + `"}}`, 1},
{"a multi-relation holds one", "notes", `{people: {is: ["` + ids["ada"] + `"]}}`, 1},
{"a back-relation's field", "companies", `{opportunities_via_company: {stage: {is: ["proposal"]}}}`, 1},
} {
t.Run(s.name, func(t *testing.T) {
a := f.ok(t, f.ann, `{ c: `+s.coll+`(filter: `+s.filter+`) { totalCount } }`, nil)
if got := a["c"].(map[string]any)["totalCount"]; got != float64(s.want) {
t.Fatalf("got %v, want %d", got, s.want)
}
})
}
for _, s := range []struct{ name, filter, says string }{
{"a field the collection has not", `{nope: {is: ["x"]}}`, `BasePersonFilter has no field "nope"`},
{"an operator the type has not", `{lifecycle: {gt: "a"}}`, `has no operator "gt"`},
{"a segment through a relation", `{company: {where: {inSegment: "x"}}}`, "not records reached through company"},
{"a regex is not offered", `{job_title: {regex: "^A"}}`, `has no operator "regex"`},
} {
t.Run(s.name, func(t *testing.T) {
a := f.gql(t, f.ann, `{ people(filter: `+s.filter+`) { totalCount } }`, nil)
if !strings.Contains(a.messages(), s.says) {
t.Fatalf("errors %q, want one saying %q", a.messages(), s.says)
}
})
}
t.Run("@me is the caller", func(t *testing.T) {
created := f.ok(t, f.ann, `mutation { createOpportunity(data: {name: "Mine", stage: "new"}) { id created_by { id } } }`, nil)
me := created["createOpportunity"].(map[string]any)["created_by"].(map[string]any)["id"]
id := created["createOpportunity"].(map[string]any)["id"]
f.ok(t, f.ann, `mutation($id: ID!, $me: String) { updateOpportunity(id: $id, data: {owner: $me}) { id } }`, map[string]any{"id": id, "me": me})
a := f.ok(t, f.ann, `{ opportunities(filter: {owner: {is: ["@me"]}}) { edges { node { name } } } }`, nil)
edges := a["opportunities"].(map[string]any)["edges"].([]any)
if len(edges) != 1 || edges[0].(map[string]any)["node"].(map[string]any)["name"] != "Mine" {
t.Fatalf("@me selected %v", edges)
}
b := f.ok(t, f.bob, `{ opportunities(filter: {owner: {is: ["@me"]}}) { totalCount } }`, nil)
if b["opportunities"].(map[string]any)["totalCount"] != float64(0) {
t.Fatal("@me selected another caller's record")
}
})
}
// A connection pages in the order asked for, a cursor is a position in it, and
// a total is counted only when asked for.
func TestOrderAndPages(t *testing.T) {
t.Parallel()
f := setup(t)
f.people(t)
order := func(by string) []string {
a := f.ok(t, f.ann, `{ people(orderBy: `+by+`) { edges { node { name { firstName } } } } }`, nil)
return names(t, a["people"])
}
if got := order(`[{name: {firstName: Asc}}]`); strings.Join(got, ",") != "Ada,Alan,Grace" {
t.Fatalf("ascending: %v", got)
}
if got := order(`[{name: {firstName: Desc}}]`); strings.Join(got, ",") != "Grace,Alan,Ada" {
t.Fatalf("descending: %v", got)
}
if got := order(`[{lifecycle: Asc}, {name: {lastName: Desc}}]`); strings.Join(got, ",") != "Grace,Ada,Alan" {
t.Fatalf("two keys: %v", got)
}
if got := order(`[{company: {name: Desc}}]`); strings.Join(got[:2], ",") != "Grace,Ada" {
t.Fatalf("through a relation: %v", got)
}
for by, says := range map[string]string{
`[{name: {firstName: Asc}, lifecycle: Asc}]`: "names one field",
`[{emails: {additionalEmails: Asc}}]`: `no part "additionalEmails"`,
`[{consent: Asc}]`: `no field "consent"`,
} {
if a := f.gql(t, f.ann, `{ people(orderBy: `+by+`) { totalCount } }`, nil); !strings.Contains(a.messages(), says) {
t.Errorf("%s: errors %q, want %q", by, a.messages(), says)
}
}
// seven activities, paged three at a time
for i := range 7 {
f.save(t, "activities", map[string]any{"kind": "note", "subject": fmt.Sprintf("a%d", i), "source_id": fmt.Sprintf("s%d", i)})
}
const page = `query($after: String) { activities(first: 3, after: $after, orderBy: [{subject: Asc}]) {
totalCount edges { cursor node { subject } } pageInfo { hasNextPage hasPreviousPage startCursor endCursor } } }`
var seen []string
var after any
for range 4 {
a := f.ok(t, f.ann, page, map[string]any{"after": after})
conn := a["activities"].(map[string]any)
if conn["totalCount"] != float64(7) {
t.Fatalf("totalCount %v", conn["totalCount"])
}
for _, e := range conn["edges"].([]any) {
seen = append(seen, e.(map[string]any)["node"].(map[string]any)["subject"].(string))
}
info := conn["pageInfo"].(map[string]any)
if info["hasPreviousPage"] != (after != nil) {
t.Fatalf("hasPreviousPage %v after %v", info["hasPreviousPage"], after)
}
if info["hasNextPage"] != true {
break
}
after = info["endCursor"]
}
if strings.Join(seen, ",") != "a0,a1,a2,a3,a4,a5,a6" {
t.Fatalf("paging read %v", seen)
}
a := f.ok(t, f.ann, `{ activities(last: 2, orderBy: [{subject: Asc}]) { edges { cursor node { subject } } pageInfo { hasNextPage hasPreviousPage } } }`, nil)
conn := a["activities"].(map[string]any)
edges := conn["edges"].([]any)
if len(edges) != 2 || edges[0].(map[string]any)["node"].(map[string]any)["subject"] != "a5" {
t.Fatalf("last 2: %v", edges)
}
before := edges[0].(map[string]any)["cursor"]
b := f.ok(t, f.ann, `query($b: String) { activities(last: 2, before: $b, orderBy: [{subject: Asc}]) { edges { node { subject } } } }`, map[string]any{"b": before})
got := []string{}
for _, e := range b["activities"].(map[string]any)["edges"].([]any) {
got = append(got, e.(map[string]any)["node"].(map[string]any)["subject"].(string))
}
if strings.Join(got, ",") != "a3,a4" {
t.Fatalf("last 2 before a5: %v", got)
}
if a := f.gql(t, f.ann, `{ activities(after: "nope") { totalCount } }`, nil); !strings.Contains(a.messages(), "not a cursor") {
t.Fatalf("a forged cursor: %q", a.messages())
}
if a := f.gql(t, f.ann, `{ activities(first: 101) { totalCount } }`, nil); !strings.Contains(a.messages(), "from 0 to 100") {
t.Fatalf("a page too large: %q", a.messages())
}
}
// Figures are the aggregate route's, over what the filter selects, and a group
// by is one request for every figure its groups read.
func TestAggregates(t *testing.T) {
t.Parallel()
f := setup(t)
f.people(t)
a := f.ok(t, f.ann, `{ companies { aggregate {
count total: sum(field: "employees") avg(field: "employees") min(field: "employees") max(field: "employees")
countEmpty(field: "address") percentEmpty(field: "address") countTrue(field: "icp")
revenue: sum(field: "annual_revenue.amountMicros")
} } }`, nil)
agg := a["companies"].(map[string]any)["aggregate"].(map[string]any)
for k, want := range map[string]float64{
"count": 2, "total": 550, "avg": 275, "min": 50, "max": 500, "countEmpty": 1, "percentEmpty": 50, "countTrue": 1, "revenue": 7_000_000_000_000,
} {
if agg[k] != want {
t.Errorf("%s = %v, want %v", k, agg[k], want)
}
}
b := f.ok(t, f.ann, `{ companies(filter: {icp: {is: true}}, first: 1) { totalCount aggregate { count } } }`, nil)
if b["companies"].(map[string]any)["aggregate"].(map[string]any)["count"] != float64(1) {
t.Fatalf("an aggregate ignores the filter: %v", b)
}
g := f.ok(t, f.ann, `{ opportunitiesGroupBy(field: "stage") { value aggregate { count sum(field: "amount.amountMicros") } } }`, nil)
lanes := map[string]any{}
for _, x := range g["opportunitiesGroupBy"].([]any) {
m := x.(map[string]any)
lanes[fmt.Sprint(m["value"])] = m["aggregate"]
}
if lanes["proposal"].(map[string]any)["sum"] != float64(120_000_000_000) || lanes["new"].(map[string]any)["count"] != float64(1) {
t.Fatalf("lanes %v", lanes)
}
h := f.ok(t, f.ann, `{ peopleGroupBy(field: "lifecycle", filter: {company: {empty: false}}) { value aggregate { count } } }`, nil)
if n := len(h["peopleGroupBy"].([]any)); n != 2 {
t.Fatalf("a group by ignores its filter: %v", h)
}
if a := f.gql(t, f.ann, `{ people { aggregate { sum(field: "company.employees") } } }`, nil); !strings.Contains(a.messages(), "reads a field of") {
t.Fatalf("an aggregate through a relation: %q", a.messages())
}
}
// Writes are the record routes': what REST would store, as the caller,
// stamped with them, under the collection's rules.
func TestMutations(t *testing.T) {
t.Parallel()
f := setup(t)
ids := f.people(t)
created := f.ok(t, f.ann, `mutation($c: ID) { createPerson(data: {
name: {firstName: "Linus", lastName: "Torvalds"}, emails: {primaryEmail: "[email protected]"}, company: $c, lifecycle: "lead"
}) { id name { firstName } emails { primaryEmail } company { name } created_by { name source } } }`, map[string]any{"c": ids["acme"]})
p := created["createPerson"].(map[string]any)
id := p["id"].(string)
if p["company"].(map[string]any)["name"] != "Acme" || p["created_by"].(map[string]any)["name"] != "Ann" ||
p["emails"].(map[string]any)["primaryEmail"] != "[email protected]" {
t.Fatalf("created %v", p)
}
// what REST reads back is what was written
code, rec := f.rest(t, f.ann, http.MethodGet, "/v1/collections/people/records/"+id, "")
if code != 200 || rec["lifecycle"] != "lead" || rec["name"].(map[string]any)["lastName"] != "Torvalds" {
t.Fatalf("REST reads %d %v", code, rec)
}
updated := f.ok(t, f.ann, `mutation($id: ID!) { updatePerson(id: $id, data: {lifecycle: "customer"}) { lifecycle } }`, map[string]any{"id": id})
if updated["updatePerson"].(map[string]any)["lifecycle"] != "customer" {
t.Fatalf("updated %v", updated)
}
up := f.ok(t, f.ann, `mutation { upsertPerson(on: "emails.primaryEmail", data: {emails: {primaryEmail: "[email protected]"}, job_title: "Kernel"}) { id job_title } }`, nil)
if u := up["upsertPerson"].(map[string]any); u["id"] != id || u["job_title"] != "Kernel" {
t.Fatalf("an upsert on a held value made %v", u)
}
fresh := f.ok(t, f.ann, `mutation { upsertPerson(on: "emails.primaryEmail", data: {emails: {primaryEmail: "[email protected]"}}) { id } }`, nil)
if fresh["upsertPerson"].(map[string]any)["id"] == id {
t.Fatal("an upsert on a new value updated another record")
}
byID := `mutation { upsertPerson(data: {id: "upsertedperson1", job_title: "First"}) { id job_title } }`
f.ok(t, f.ann, byID, nil)
again := f.ok(t, f.ann, strings.Replace(byID, "First", "Second", 1), nil)
if again["upsertPerson"].(map[string]any)["job_title"] != "Second" {
t.Fatalf("an upsert by id did not update: %v", again)
}
hist := f.ok(t, f.ann, `query($id: ID!) { person(id: $id) { history { totalCount edges { node { action actor { name } changes } } } } }`, map[string]any{"id": id})
h := hist["person"].(map[string]any)["history"].(map[string]any)
edges := h["edges"].([]any)
if len(edges) == 0 || edges[0].(map[string]any)["node"].(map[string]any)["actor"].(map[string]any)["name"] != "Ann" {
t.Fatalf("history %v", h)
}
if !strings.Contains(fmt.Sprint(edges[0].(map[string]any)["node"].(map[string]any)["changes"]), "job_title") {
t.Fatalf("the newest entry is not the last write: %v", edges[0])
}
deleted := f.ok(t, f.ann, `mutation($id: ID!) { deletePerson(id: $id) }`, map[string]any{"id": id})
if deleted["deletePerson"] != id {
t.Fatalf("deleted %v", deleted)
}
gone := f.ok(t, f.ann, `query($id: ID!) { person(id: $id) { id } }`, map[string]any{"id": id})
if gone["person"] != nil {
t.Fatalf("a deleted person reads %v", gone)
}
bad := f.gql(t, f.ann, `mutation { createPerson(data: {emails: {primaryEmail: "not an email"}}) { id } }`, nil)
_, restBad := f.rest(t, f.ann, http.MethodPost, "/v1/collections/people/records", `{"emails":{"primaryEmail":"not an email"}}`)
if !strings.Contains(bad.messages(), strings.TrimSuffix(fmt.Sprint(restBad["message"]), ".")) || !strings.Contains(bad.messages(), "emails") {
t.Fatalf("a refused write says %q; REST said %v", bad.messages(), restBad)
}
for q, says := range map[string]string{
`mutation { createPerson(data: {nope: "x"}) { id } }`: `BasePersonInput has no field "nope"`,
`mutation { createPerson(data: {name: {middle: "x"}}) { id } }`: `BaseFullNameInput has no field "middle"`,
`mutation { createPerson(data: {created_by: {name: "forged"}}) { id } }`: `has no field "created_by"`,
`{ createPerson(data: {job_title: "x"}) { id } }`: "is a mutation field; this operation is a query",
`mutation { people { totalCount } }`: "is a query field; this operation is a mutation",
`mutation { upsertPerson(on: "emails.primaryEmail", data: {}) { id } }`: "needs data to give it a value",
} {
if a := f.gql(t, f.ann, q, nil); !strings.Contains(a.messages(), says) {
t.Errorf("%s: errors %q, want %q", q, a.messages(), says)
}
}
}
// The graph refuses what REST refuses, in REST's words: a member a rule keeps
// out, a collection only a superuser may list, and a token of another org.
func TestTheGraphRefusesWhatRESTRefuses(t *testing.T) {
t.Parallel()
f := setup(t)
f.people(t)
admins := types.Pointer(`@request.auth.admin = true`)
secrets := core.NewBaseCollection("secrets")
secrets.ListRule, secrets.ViewRule, secrets.CreateRule = admins, admins, admins
secrets.Fields.Add(&core.TextField{Name: "title"})
vault := core.NewBaseCollection("vault")
vault.Fields.Add(&core.TextField{Name: "title"})
for _, c := range []*core.Collection{secrets, vault} {
if err := f.app.Save(c); err != nil {
t.Fatal(err)
}
}
f.save(t, "secrets", map[string]any{"title": "the plan"})
count := func(token string) any {
return f.ok(t, token, `{ secrets { totalCount } }`, nil)["secrets"].(map[string]any)["totalCount"]
}
if count(f.ann) != float64(0) || count(f.bob) != float64(1) {
t.Fatalf("a member reads %v and an admin %v of what only admins list", count(f.ann), count(f.bob))
}
if _, rest := f.rest(t, f.ann, http.MethodGet, "/v1/collections/secrets/records", ""); rest["totalItems"] != float64(0) {
t.Fatalf("REST shows a member %v", rest)
}
for _, s := range []struct {
name, token, query, method, path, body string
}{
{"a member's create a rule refuses", f.ann, `mutation { createSecret(data: {title: "x"}) { id } }`, http.MethodPost, "/v1/collections/secrets/records", `{"title":"x"}`},
{"a collection only a superuser lists", f.bob, `{ vault { edges { node { id } } } }`, http.MethodGet, "/v1/collections/vault/records", ""},
{"a token of another org", f.zed, `{ people { edges { node { id } } } }`, http.MethodGet, "/v1/collections/people/records", ""},
} {
t.Run(s.name, func(t *testing.T) {
code, rest := f.rest(t, s.token, s.method, s.path, s.body)
if code < 400 {
t.Fatalf("REST answered %d", code)
}
a := f.gql(t, s.token, s.query, nil)
msg := strings.TrimSuffix(fmt.Sprint(rest["message"]), ".")
if len(a.Errors) == 0 || !strings.Contains(a.messages(), msg) {
t.Fatalf("the graph says %q where REST said %d %q", a.messages(), code, msg)
}
for _, v := range a.Data {
if v != nil {
t.Fatalf("a refusal answered %v", a.Data)
}
}
})
}
t.Run("nobody signed in", func(t *testing.T) {
a := f.ok(t, "", `{ people { totalCount } }`, nil)
if a["people"].(map[string]any)["totalCount"] != float64(0) {
t.Fatalf("an anonymous caller reads %v", a)
}
})
t.Run("a record the caller may not view is not expanded", func(t *testing.T) {
secret := f.save(t, "secrets", map[string]any{"title": "linked"})
notes, err := f.app.FindCollectionByNameOrId("notes")
if err != nil {
t.Fatal(err)
}
secretsColl, _ := f.app.FindCollectionByNameOrId("secrets")
notes.Fields.Add(&core.RelationField{Name: "secret", CollectionId: secretsColl.Id, MaxSelect: 1})
if err := f.app.Save(notes); err != nil {
t.Fatal(err)
}
f.save(t, "notes", map[string]any{"title": "has a secret", "secret": secret})
q := `{ notes(filter: {title: {is: ["has a secret"]}}) { edges { node { secret { id title } } } } }`
member := f.ok(t, f.ann, q, nil)["notes"].(map[string]any)["edges"].([]any)[0].(map[string]any)["node"].(map[string]any)
admin := f.ok(t, f.bob, q, nil)["notes"].(map[string]any)["edges"].([]any)[0].(map[string]any)["node"].(map[string]any)
if member["secret"] != nil || admin["secret"] == nil {
t.Fatalf("a member reads %v and an admin %v of a related secret", member["secret"], admin["secret"])
}
})
}
// A selection that reaches too deep is refused before any request is made, a
// wide one is refused whole by the request's resolution budget, and one field
// may make only so many requests of the Base.
func TestTheBudget(t *testing.T) {
t.Parallel()
f := setup(t)
ids := f.people(t)
deep := `{ person(id: "` + ids["ada"] + `") { company { people_via_company { edges { node {
company { people_via_company { edges { node { company { name } } } } } } } } } } }`
if a := f.gql(t, f.ann, deep, nil); !strings.Contains(a.messages(), "relations deep") {
t.Fatalf("a deep selection: %q", a.messages())
}
for i := range 100 {
f.save(t, "activities", map[string]any{"kind": "note", "subject": fmt.Sprintf("w%d", i), "source_id": fmt.Sprintf("w%d", i), "person": ids["ada"]})
}
var wide strings.Builder
wide.WriteString("{")
for i := range 40 {
fmt.Fprintf(&wide, " a%d: activities(first: 100) { edges { cursor node { id subject kind source_id } } }", i)
}
wide.WriteString(" }")
a := f.gql(t, f.ann, wide.String(), nil)
if a.status != http.StatusBadRequest || a.Data != nil || !strings.Contains(a.messages(), "resolutions") {
t.Fatalf("a wide request answered %d with %q", a.status, a.messages())
}
calls := f.gql(t, f.ann, `{ activities(first: 100) { edges { node { person { activities_via_person(first: 1) { totalCount } } } } } }`, nil)
if !strings.Contains(calls.messages(), "at most 50 requests") {
t.Fatalf("a request per record of a page: %q", calls.messages())
}
}
// Every object answers its own type name, which is what a client caches by.
func TestTypeNames(t *testing.T) {
t.Parallel()
f := setup(t)
f.people(t)
a := f.ok(t, f.ann, `{ people(first: 1, orderBy: [{name: {firstName: Asc}}]) { __typename
edges { __typename node { __typename name { __typename } company { __typename id } phones { __typename } } }
pageInfo { __typename } aggregate { __typename count } } collections { __typename name } }`, nil)
conn := a["people"].(map[string]any)
edge := conn["edges"].([]any)[0].(map[string]any)
node := edge["node"].(map[string]any)
for got, want := range map[any]string{
conn["__typename"]: "BasePersonConnection", edge["__typename"]: "BasePersonEdge",
node["__typename"]: "BasePerson", node["name"].(map[string]any)["__typename"]: "BaseFullName",
node["company"].(map[string]any)["__typename"]: "BaseCompany", node["phones"].(map[string]any)["__typename"]: "BasePhones",
conn["pageInfo"].(map[string]any)["__typename"]: "BasePageInfo",
conn["aggregate"].(map[string]any)["__typename"]: "BaseAggregate",
} {
if got != want {
t.Errorf("got %v, want %s", got, want)
}
}
cols := a["collections"].([]any)
if len(cols) == 0 || cols[0].(map[string]any)["__typename"] != "BaseCollection" {
t.Fatalf("collections %v", cols)
}
for _, c := range cols {
if n := c.(map[string]any)["name"].(string); strings.HasPrefix(n, "_") || n == "users" {
t.Fatalf("collections lists %q", n)
}
}
}
+328
View File
@@ -0,0 +1,328 @@
package graph
import (
"regexp"
"slices"
"sort"
"strings"
"github.com/hanzoai/base/core"
"github.com/hanzoai/base/tools/inflector"
)
// model is one Base's collections as the graph types them: the names every
// collection, field and root answers under, decided once per schema.
type model struct {
colls []*coll // in name order
byID map[string]*coll // collection id → coll
roots map[string]root // query and mutation root fields
segments bool // the Base has a segments collection, so filters take inSegment
history bool // some collection keeps its history
writable bool // some collection takes writes
used map[string]bool // the shared types the collections reach
skipped []string // collections or fields left out, and why
}
// root is one root field: the collection it answers for and what it does.
type root struct {
c *coll
op string // one, list, groupBy, create, update, upsert, delete
}
// coll is one collection in the graph.
type coll struct {
c *core.Collection
typ string // BasePerson
single string // Person, as a mutation spells it
list string // people
one string // person
fields []*field
byName map[string]*field
backs []*back
byBack map[string]*back
write bool // a base collection: records are written
// history: the collection keeps its history, read as a field of each record
history bool
// target: some relation in the graph points here, so the RelationFilter is used
target bool
}
// back is a relation from another collection onto this one, read from the
// record it points at: `opportunities_via_company` on a company.
type back struct {
name string
from *coll
field *field
}
// kind is what a field's value is, which decides how it is typed, filtered,
// sorted and written.
type kind int
const (
kText kind = iota // text, email, url
kRich // editor: HTML a person writes
kNumber
kRating
kBool
kDate
kSelect
kRelation
kFile
kJSON
kComposite
kOther // a type the graph does not know: answered as JSON, never filtered or sorted
)
// field is one field of a collection in the graph.
type field struct {
f core.Field
name string
kind kind
multi bool
id bool // the primary key
target *coll // a relation's target, nil when it is not in the graph
comp *composite // a composite's shape
}
// The types every graph defines, which no collection's may take.
var reserved = []string{
"BaseQuery", "BaseMutation", "BaseJSON", "BaseOrder", "BasePageInfo",
"BaseAggregate", "BaseGroup", "BaseCollection",
"BaseHistoryEntry", "BaseHistoryEdge", "BaseHistoryConnection",
"BaseIDFilter", "BaseTextFilter", "BaseNumberFilter", "BaseBooleanFilter",
"BaseDateFilter", "BaseRelativeDate", "BaseSelectFilter", "BaseContainsFilter",
"BaseRelationFilter",
}
func init() {
for _, c := range composites {
reserved = append(reserved, prefix+c.typ, prefix+c.typ+"Filter", prefix+c.typ+"OrderBy", prefix+c.typ+"Input")
for _, p := range c.parts {
if p.elem != nil {
reserved = append(reserved, prefix+p.elem.typ, prefix+p.elem.typ+"Input")
}
}
}
}
// nameRegex is a GraphQL name; one starting with two underscores is the
// language's own.
var nameRegex = regexp.MustCompile(`^[_A-Za-z][_0-9A-Za-z]*$`)
func validName(s string) bool {
return nameRegex.MatchString(s) && !strings.HasPrefix(s, "__")
}
// filterWords are the keys a filter keeps for itself; a field so named is
// answered and written but not filtered.
var filterWords = []string{"and", "or", "not", "inSegment", "notInSegment"}
// build types the collections a graph serves: every base and view collection
// that is not one Base keeps for itself. An auth collection is identity, which
// IAM owns, so it is not a type here; a relation to one reads as the id it holds.
func build(collections []*core.Collection) *model {
m := &model{byID: map[string]*coll{}, roots: map[string]root{}, used: map[string]bool{}}
sorted := slices.Clone(collections)
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name })
taken := map[string]bool{}
for _, n := range reserved {
taken[n] = true
}
roots := map[string]bool{"collections": true}
for _, c := range sorted {
if c.System || strings.HasPrefix(c.Name, "_") || !(c.IsBase() || c.IsView()) {
continue
}
if c.Name == core.CollectionNameSegments {
m.segments = true
}
single := inflector.Camelize(inflector.Singularize(c.Name))
list := lcFirst(inflector.Camelize(c.Name))
one := lcFirst(single)
if one == list {
one += "Record"
}
typ := prefix + single
types := []string{typ, typ + "Edge", typ + "Connection", typ + "Filter", typ + "OrderBy", typ + "Input", typ + "RelationFilter"}
names := []string{list, one, list + "GroupBy"}
if c.IsBase() {
names = append(names, "create"+single, "update"+single, "upsert"+single, "delete"+single)
}
ok := validName(single) && validName(list)
for _, n := range types {
ok = ok && !taken[n]
}
for _, n := range names {
ok = ok && !roots[n]
}
if !ok {
m.skipped = append(m.skipped, c.Name+": its names are another collection's or not GraphQL names")
continue
}
for _, n := range types {
taken[n] = true
}
for _, n := range names {
roots[n] = true
}
x := &coll{
c: c, typ: typ, single: single, list: list, one: one,
byName: map[string]*field{}, byBack: map[string]*back{},
write: c.IsBase(),
history: c.IsBase() && c.History,
}
m.colls = append(m.colls, x)
m.byID[c.Id] = x
m.roots[list] = root{x, "list"}
m.roots[one] = root{x, "one"}
m.roots[list+"GroupBy"] = root{x, "groupBy"}
if x.write {
m.writable = true
m.roots["create"+single] = root{x, "create"}
m.roots["update"+single] = root{x, "update"}
m.roots["upsert"+single] = root{x, "upsert"}
m.roots["delete"+single] = root{x, "delete"}
}
}
for _, x := range m.colls {
for _, f := range x.c.Fields {
name := f.GetName()
if f.GetHidden() {
continue
}
if !validName(name) {
m.skipped = append(m.skipped, x.c.Name+"."+name+": not a GraphQL name")
continue
}
fd := &field{f: f, name: name, kind: kindOf(f)}
if mv, ok := f.(core.MultiValuer); ok {
fd.multi = mv.IsMultiple()
}
if t, ok := f.(*core.TextField); ok && t.PrimaryKey {
fd.id = true
}
if fd.kind == kComposite {
fd.comp = composites[f.Type()]
}
if rel, ok := f.(*core.RelationField); ok {
fd.target = m.byID[rel.CollectionId]
if fd.target != nil {
fd.target.target = true
}
}
x.fields = append(x.fields, fd)
x.byName[name] = fd
}
if x.history && x.byName["history"] != nil {
// the collection's own field keeps the name
x.history = false
m.skipped = append(m.skipped, x.c.Name+".history: a field of the collection is named so")
}
}
m.history = false
for _, x := range m.colls {
m.history = m.history || x.history
}
// a relation is read back from the record it points at, under the name
// Base's own expand gives it
for _, x := range m.colls {
for _, f := range x.fields {
if f.kind != kRelation || f.target == nil {
continue
}
name := x.c.Name + "_via_" + f.name
t := f.target
if !validName(name) || t.byName[name] != nil || t.byBack[name] != nil {
m.skipped = append(m.skipped, t.c.Name+"."+name+": taken")
continue
}
b := &back{name: name, from: x, field: f}
t.backs = append(t.backs, b)
t.byBack[name] = b
}
}
return m
}
func kindOf(f core.Field) kind {
switch f.Type() {
case core.FieldTypeText, core.FieldTypeEmail, core.FieldTypeURL:
return kText
case core.FieldTypeEditor:
return kRich
case core.FieldTypeNumber:
return kNumber
case core.FieldTypeRating:
return kRating
case core.FieldTypeBool:
return kBool
case core.FieldTypeDate, core.FieldTypeAutodate:
return kDate
case core.FieldTypeSelect:
return kSelect
case core.FieldTypeRelation:
return kRelation
case core.FieldTypeFile:
return kFile
case core.FieldTypeJSON:
return kJSON
}
if _, ok := composites[f.Type()]; ok {
return kComposite
}
return kOther
}
// writable reports whether a field takes a value in an Input: never a value
// Base sets itself (a date it stamps, a writer it records) or a file, which is
// uploaded as a form rather than written as JSON.
func (f *field) writable() bool {
switch {
case f.f.Type() == core.FieldTypeAutodate, f.kind == kFile:
return false
case f.kind == kComposite:
return f.comp.write
}
return true
}
// filterable reports whether a filter can name the field.
func (f *field) filterable() bool {
return f.kind != kOther && !slices.Contains(filterWords, f.name) && f.f.Type() != core.FieldTypeGeoPoint
}
// sortable reports whether an orderBy can name the field.
func (f *field) sortable() bool {
switch f.kind {
case kText, kNumber, kRating, kBool, kDate:
return true
case kSelect:
return !f.multi
case kRelation:
return !f.multi
case kComposite:
return f.comp.sortable()
}
return false
}
func lcFirst(s string) string {
if s == "" {
return s
}
return strings.ToLower(s[:1]) + s[1:]
}
+1018
View File
File diff suppressed because it is too large Load Diff
+181
View File
@@ -0,0 +1,181 @@
package graph
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"sort"
"strings"
"github.com/hanzoai/base/apis"
"github.com/zap-proto/zip"
)
// callsMost is how many requests one root field may make of the Base. A record
// is one request and a page of a connection one or two, so the bound is met
// only by asking for a connection under every record of a list: 50 is a page of
// opportunities under each of a page of companies, with room left over.
const callsMost = 50
// run is one root field in flight: the caller's request, which every call it
// makes on the Base presents as its own, and how many calls it has made.
type run struct {
g *Graph
m *model
header http.Header
host string
remote string
calls int
}
func (g *Graph) run(ctx context.Context, m *model) *run {
h := zip.Headers(ctx)
if h == nil {
h = http.Header{}
}
host := h.Get("Host")
// what describes the GraphQL request itself, not the call made for it
for _, k := range []string{"Host", "Content-Length", "Content-Type", "Transfer-Encoding", "Connection",
"Upgrade", "Accept-Encoding", "Expect", "If-None-Match", "If-Modified-Since"} {
h.Del(k)
}
h.Set("Accept", "application/json")
remote := "127.0.0.1:0"
if ip := zip.CallerOf(ctx).IP; ip != "" {
remote = net.JoinHostPort(ip, "0")
}
return &run{g: g, m: m, header: h, host: host, remote: remote}
}
// call makes one request of the Base's own router, as the caller: the route,
// its middleware and its rules are the ones the same request over REST meets,
// so nothing here decides who may read or write what. It answers the decoded
// JSON body, or nil for an empty one.
func (r *run) call(ctx context.Context, method, path string, q url.Values, body any) (any, error) {
r.calls++
if r.calls > callsMost {
return nil, bad("one field may make at most %d requests of the Base; select fewer records below a list", callsMost)
}
if err := ctx.Err(); err != nil {
return nil, err
}
target := apis.Prefix() + path
if len(q) > 0 {
target += "?" + q.Encode()
}
// a request a server receives always has a body, if an empty one
var rd io.Reader = http.NoBody
if body != nil {
raw, err := json.Marshal(body)
if err != nil {
return nil, bad("the data does not encode: %v", err)
}
rd = bytes.NewReader(raw)
}
req, err := http.NewRequestWithContext(ctx, method, target, rd)
if err != nil {
return nil, err
}
for k, vs := range r.header {
req.Header[k] = append([]string(nil), vs...)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
req.Host = r.host
req.RemoteAddr = r.remote
w := &recorder{header: http.Header{}, status: http.StatusOK}
r.g.router.ServeHTTP(w, req)
if w.status >= 400 {
return nil, refusal(w.status, w.body.Bytes(), method, path)
}
if w.body.Len() == 0 {
return nil, nil
}
var out any
if err := json.Unmarshal(w.body.Bytes(), &out); err != nil {
return nil, fmt.Errorf("base answered %s %s with a body that is not JSON: %w", method, path, err)
}
return out, nil
}
// refusal is what the caller is told of a request the Base refused: its status
// and its message, with each field's own reason. A server error is not the
// caller's to read; zip logs it under the request's id.
func refusal(status int, body []byte, method, path string) error {
var e struct {
Message string `json:"message"`
Data map[string]any `json:"data"`
}
_ = json.Unmarshal(body, &e)
if status >= 500 {
return fmt.Errorf("base answered %d for %s %s: %s", status, method, path, e.Message)
}
msg := strings.TrimSpace(e.Message)
if msg == "" {
msg = http.StatusText(status)
}
if reasons := reasonsOf(e.Data, ""); len(reasons) > 0 {
msg = strings.TrimSuffix(msg, ".") + ": " + strings.Join(reasons, "; ")
}
return &zip.HTTPError{Status: status, Msg: msg}
}
// reasonsOf flattens a validation error's data into "field: reason" lines.
func reasonsOf(data map[string]any, at string) []string {
var out []string
for k, v := range data {
e, ok := v.(map[string]any)
if !ok {
continue
}
if m, ok := e["message"].(string); ok {
out = append(out, at+k+": "+m)
continue
}
out = append(out, reasonsOf(e, at+k+".")...)
}
sort.Strings(out)
return out
}
// isNotFound reports whether err is the Base answering 404.
func isNotFound(err error) bool {
he, ok := err.(*zip.HTTPError)
return ok && he.Status == http.StatusNotFound
}
// recorder holds one response of the Base's router.
type recorder struct {
header http.Header
status int
wrote bool
body bytes.Buffer
}
func (w *recorder) Header() http.Header { return w.header }
func (w *recorder) WriteHeader(status int) {
if !w.wrote {
w.status, w.wrote = status, true
}
}
func (w *recorder) Write(b []byte) (int, error) {
w.wrote = true
return w.body.Write(b)
}
+448
View File
@@ -0,0 +1,448 @@
package graph
import (
"fmt"
"strings"
"github.com/hanzoai/base/core"
)
// op is one operator of a filter input, with the GraphQL type it takes.
type op struct{ name, typ string }
// The filter inputs every graph shares, by name, and what each operator does.
// A field's filter is one of these, its target's RelationFilter or its
// composite's Filter; each operator is one leaf of a where clause
// (core.CompileWhere), so a filter here means what the same where means over
// REST.
var shared = []struct {
name string
desc string
ops []op
}{
{"BaseIDFilter", "A record id is one of `is` and none of `isNot`.", []op{
{"is", "[ID!]"}, {"isNot", "[ID!]"},
}},
{"BaseTextFilter", "Text is one of `is` (exactly) and none of `isNot`; contains any of `contains` (ignoring case) and none of `notContains`; `empty: true` has no text, `empty: false` has some. `@me` in `is` is the caller's id.", []op{
{"is", "[String!]"}, {"isNot", "[String!]"}, {"contains", "[String!]"}, {"notContains", "[String!]"}, {"empty", "Boolean"},
}},
{"BaseNumberFilter", "A number is one of `is`, none of `isNot`, and above or below the bounds given. Base stores no missing number, so `empty: true` is zero.", []op{
{"is", "[Float!]"}, {"isNot", "[Float!]"}, {"gt", "Float"}, {"gte", "Float"}, {"lt", "Float"}, {"lte", "Float"}, {"empty", "Boolean"},
}},
{"BaseBooleanFilter", "A truth value is `is`.", []op{
{"is", "Boolean"},
}},
{"BaseDateFilter", "A date or an instant. `is` and the bounds take a day (`2026-10-19`, in `tz`) or an instant (`2026-10-19 15:30:00.000Z`); `today`, `past` and `future` are relative to now (false negates); `relative` is a span counted from the start of the current unit. `tz` names the IANA time zone days are counted in (UTC when absent) and `weekStart` the first day of a week (0 Sunday to 6 Saturday, Monday when absent).", []op{
{"is", "String"}, {"isNot", "String"}, {"gt", "String"}, {"gte", "String"}, {"lt", "String"}, {"lte", "String"},
{"today", "Boolean"}, {"past", "Boolean"}, {"future", "Boolean"}, {"relative", "BaseRelativeDate"},
{"empty", "Boolean"}, {"tz", "String"}, {"weekStart", "Int"},
}},
{"BaseSelectFilter", "A choice is one of `is` and none of `isNot`; a field holding several choices holds any of `is` and none of `isNot`.", []op{
{"is", "[String!]"}, {"isNot", "[String!]"}, {"empty", "Boolean"},
}},
{"BaseContainsFilter", "A value's text contains any of `contains` (ignoring case) and none of `notContains`.", []op{
{"contains", "[String!]"}, {"notContains", "[String!]"}, {"empty", "Boolean"},
}},
{"BaseRelationFilter", "A relation points at one of `is` and none of `isNot`; `@me` is the caller's id.", []op{
{"is", "[ID!]"}, {"isNot", "[ID!]"}, {"empty", "Boolean"},
}},
}
// sharedOps is each shared filter's operators, by its name.
var sharedOps = func() map[string][]op {
out := map[string][]op{}
for _, s := range shared {
out[s.name] = s.ops
}
return out
}()
// filterOf is the filter input a field takes.
func (f *field) filterOf() string {
switch {
case f.id:
return "BaseIDFilter"
case f.kind == kText, f.kind == kRich:
return "BaseTextFilter"
case f.kind == kNumber, f.kind == kRating:
return "BaseNumberFilter"
case f.kind == kBool:
return "BaseBooleanFilter"
case f.kind == kDate:
return "BaseDateFilter"
case f.kind == kSelect:
return "BaseSelectFilter"
case f.kind == kRelation && f.target != nil:
return f.target.typ + "RelationFilter"
case f.kind == kRelation:
return "BaseRelationFilter"
case f.kind == kComposite:
return prefix + f.comp.typ + "Filter"
}
return "BaseContainsFilter" // file, json
}
// outOf is the GraphQL type a field answers.
func (f *field) outOf() string {
list := func(t string) string {
if f.multi {
return "[" + t + "!]"
}
return t
}
switch f.kind {
case kText, kRich, kDate:
if f.id {
return "ID!"
}
return "String"
case kNumber:
return "Float"
case kRating:
return "Int"
case kBool:
return "Boolean"
case kSelect, kFile:
return list("String")
case kRelation:
if f.target == nil {
return list("ID")
}
return list(f.target.typ)
case kComposite:
return prefix + f.comp.typ
}
return "BaseJSON"
}
// inOf is the GraphQL type a field takes in an Input.
func (f *field) inOf() string {
switch f.kind {
case kText, kRich, kDate:
if f.id {
return "ID"
}
return "String"
case kNumber:
return "Float"
case kRating:
return "Int"
case kBool:
return "Boolean"
case kSelect:
if f.multi {
return "[String!]"
}
return "String"
case kRelation:
if f.multi {
return "[ID!]"
}
return "ID"
case kComposite:
return prefix + f.comp.typ + "Input"
}
return "BaseJSON"
}
// orderOf is the type an orderBy takes for a field.
func (f *field) orderOf() string {
switch f.kind {
case kComposite:
return prefix + f.comp.typ + "OrderBy"
case kRelation:
if f.target != nil {
return f.target.typ + "OrderBy"
}
}
return "BaseOrder"
}
// connectionArgs are the arguments every connection takes.
func connectionArgs(c *coll) string {
return fmt.Sprintf("filter: %sFilter, orderBy: [%sOrderBy!], first: Int, after: String, last: Int, before: String", c.typ, c.typ)
}
// render writes the schema of m.
func (m *model) render() string {
w := &writer{}
if m.writable {
w.line("schema { query: BaseQuery mutation: BaseMutation }")
} else {
w.line("schema { query: BaseQuery }")
}
w.line("")
w.desc("", "Any JSON value: a json field, a geo point, a minimum or a maximum.")
w.line("scalar BaseJSON")
w.line("")
w.desc("", "A direction to sort in. Base places no nulls: an empty text sorts first ascending, and a missing part of a composite sorts where the database engine puts NULL.")
w.line("enum BaseOrder { Asc Desc }")
w.line("")
w.block("type BasePageInfo", "Where a page sits in its connection.", []string{
"hasNextPage: Boolean!", "hasPreviousPage: Boolean!", "startCursor: String", "endCursor: String",
})
w.desc("", "Figures over every record a connection's filter selects, whatever page is read. `field` names a field, or a part of a composite or JSON field (`amount.amountMicros`), of the collection itself.")
w.line("type BaseAggregate {")
w.line(" count: Int!")
for _, a := range []string{"countEmpty: Int!", "countNotEmpty: Int!", "countUnique: Int!", "percentEmpty: Float!", "percentNotEmpty: Float!",
"sum: Float", "avg: Float", "min: BaseJSON", "max: BaseJSON", "countTrue: Int!", "countFalse: Int!"} {
name, typ, _ := strings.Cut(a, ": ")
w.line(" " + name + "(field: String!): " + typ)
}
w.line("}")
w.line("")
w.block("type BaseGroup", "The records that hold one value of the field grouped by.", []string{
"value: BaseJSON", "aggregate: BaseAggregate!",
})
w.block("type BaseCollection", "A collection of this Base, as its schema describes it.", []string{
"id: ID!", "name: String!", "type: String!", "label: String", "plural: String", "icon: String", "description: String",
`"""The collection's fields, as Base's own schema writes them."""`, "fields: BaseJSON!",
})
w.block("input BaseRelativeDate", "A span counted from the start of the current unit: `{direction: past, amount: 7, unit: day}` is the seven days before today; `this` is the current unit itself.", []string{
`"""past, next or this"""`, "direction: String!", "amount: Int", `"""second, minute, hour, day, week, month, quarter or year"""`, "unit: String!",
})
for _, s := range shared {
ops := make([]string, len(s.ops))
for i, o := range s.ops {
ops[i] = o.name + ": " + o.typ
}
w.block("input "+s.name, s.desc, ops)
}
if m.history {
w.block("type BaseHistoryEntry", "One write to a record: who made it, when, and what changed from what to what.", []string{
`"""create, update or delete"""`, "action: String!", "id: ID!", "actor: BaseActor", `"""Each changed field's {from, to}."""`, "changes: BaseJSON!", "created: String!",
})
w.block("type BaseHistoryEdge", "", []string{"node: BaseHistoryEntry!", "cursor: String!"})
w.block("type BaseHistoryConnection", "A record's history, newest first.", []string{
"edges: [BaseHistoryEdge!]!", "pageInfo: BasePageInfo!", "totalCount: Int!",
})
}
m.renderComposites(w)
for _, c := range m.colls {
m.renderCollection(w, c)
}
// roots
w.line("type BaseQuery {")
w.desc(" ", "This Base's collections, as their schemas describe them.")
w.line(" collections: [BaseCollection!]!")
for _, c := range m.colls {
w.desc(" ", "One "+label(c)+" by id, or null when there is none the caller may see.")
w.line(fmt.Sprintf(" %s(id: ID!): %s", c.one, c.typ))
w.desc(" ", "The "+plural(c)+" the caller may list, as a connection. A cursor is a position in the order asked for.")
w.line(fmt.Sprintf(" %s(%s): %sConnection!", c.list, connectionArgs(c), c.typ))
w.desc(" ", "The "+plural(c)+" a filter selects, grouped by one field of the collection, with figures for each group.")
w.line(fmt.Sprintf(" %sGroupBy(field: String!, filter: %sFilter): [BaseGroup!]!", c.list, c.typ))
}
w.line("}")
w.line("")
if m.writable {
w.line("type BaseMutation {")
for _, c := range m.colls {
if !c.write {
continue
}
w.line(fmt.Sprintf(" create%s(data: %sInput!): %s", c.single, c.typ, c.typ))
w.line(fmt.Sprintf(" update%s(id: ID!, data: %sInput!): %s", c.single, c.typ, c.typ))
w.desc(" ", "Updates the "+label(c)+" whose `on` (a field, or a part of a composite, `id` when absent) holds the value data gives it, or creates one when none does.")
w.line(fmt.Sprintf(" upsert%s(data: %sInput!, on: String): %s", c.single, c.typ, c.typ))
w.desc(" ", "Deletes the "+label(c)+" and answers its id.")
w.line(fmt.Sprintf(" delete%s(id: ID!): ID!", c.single))
}
w.line("}")
w.line("")
}
return w.String()
}
func (m *model) renderComposites(w *writer) {
for _, ft := range compositeOrder {
c := composites[ft]
if !m.used[ft] {
continue
}
var out, in, filter, order []string
if c.text {
filter = append(filter, "contains: [String!]", "notContains: [String!]")
}
filter = append(filter, "empty: Boolean")
for _, p := range c.parts {
switch {
case p.list && p.elem != nil:
out = append(out, p.name+": ["+prefix+p.elem.typ+"!]")
in = append(in, p.name+": ["+prefix+p.elem.typ+"Input!]")
filter = append(filter, p.name+": BaseContainsFilter")
case p.list:
out = append(out, p.name+": [String!]")
in = append(in, p.name+": [String!]")
filter = append(filter, p.name+": BaseContainsFilter")
case p.kind == kNumber:
out = append(out, p.name+": Float")
in = append(in, p.name+": Float")
filter = append(filter, p.name+": BaseNumberFilter")
order = append(order, p.name+": BaseOrder")
default:
out = append(out, p.name+": String")
in = append(in, p.name+": String")
filter = append(filter, p.name+": BaseTextFilter")
order = append(order, p.name+": BaseOrder")
}
if p.elem != nil {
fields := make([]string, len(p.elem.fields))
for i, f := range p.elem.fields {
fields[i] = f + ": String"
}
w.block("type "+prefix+p.elem.typ, "", fields)
if c.write {
w.block("input "+prefix+p.elem.typ+"Input", "", fields)
}
}
}
w.block("type "+prefix+c.typ, "", out)
if c.write {
w.block("input "+prefix+c.typ+"Input", "", in)
}
w.block("input "+prefix+c.typ+"Filter", "`contains` reads every part a person searches; each part filters on its own.", filter)
w.block("input "+prefix+c.typ+"OrderBy", "One part, the one to sort by.", order)
}
}
// compositeOrder is the order composite types are written in.
var compositeOrder = []string{
core.FieldTypeActor, core.FieldTypeAddress, core.FieldTypeCurrency, core.FieldTypeEmails,
core.FieldTypeFullName, core.FieldTypeLinks, core.FieldTypePhones,
}
func (m *model) renderCollection(w *writer, c *coll) {
var out []string
for _, f := range c.fields {
if d := describe(f.f); d != "" {
out = append(out, `"""`+escape(d)+`"""`)
}
out = append(out, f.name+": "+f.outOf())
}
for _, b := range c.backs {
out = append(out, fmt.Sprintf(`"""The %s whose %s is this %s."""`, plural(b.from), b.field.name, label(c)))
out = append(out, fmt.Sprintf("%s(%s): %sConnection!", b.name, connectionArgs(b.from), b.from.typ))
}
if c.history {
out = append(out, `"""Every write to this `+label(c)+`, newest first."""`, "history(first: Int, after: String): BaseHistoryConnection!")
}
w.block("type "+c.typ, c.c.Description, out)
w.block("type "+c.typ+"Edge", "", []string{"node: " + c.typ + "!", "cursor: String!"})
w.block("type "+c.typ+"Connection", "", []string{
"edges: [" + c.typ + "Edge!]!", "pageInfo: BasePageInfo!",
`"""How many records the filter selects, counted only when asked for."""`, "totalCount: Int!",
"aggregate: BaseAggregate!",
})
filter := []string{
"and: [" + c.typ + "Filter!]", "or: [" + c.typ + "Filter!]", "not: " + c.typ + "Filter",
}
if m.segments {
filter = append(filter, `"""The records the saved segment with this id selects."""`, "inSegment: ID", "notInSegment: ID")
}
var order, in []string
for _, f := range c.fields {
if f.filterable() {
filter = append(filter, f.name+": "+f.filterOf())
}
if f.sortable() {
order = append(order, f.name+": "+f.orderOf())
}
if c.write && f.writable() {
in = append(in, f.name+": "+f.inOf())
}
}
for _, b := range c.backs {
filter = append(filter, fmt.Sprintf(`"""Some %s whose %s is this %s matches."""`, label(b.from), b.field.name, label(c)))
filter = append(filter, b.name+": "+b.from.typ+"Filter")
}
w.block("input "+c.typ+"Filter", "Every key given holds (and), and an empty filter selects everything.", filter)
w.block("input "+c.typ+"OrderBy", "One field, the one to sort by; a list of these sorts by each in turn.", order)
if c.write {
w.block("input "+c.typ+"Input", "", in)
}
if c.target {
w.block("input "+c.typ+"RelationFilter", "A relation to a "+label(c)+": one of `is`, none of `isNot`, or one that `where` selects.", []string{
"is: [ID!]", "isNot: [ID!]", "empty: Boolean", "where: " + c.typ + "Filter",
})
}
}
// describe is what a person reads about a field.
func describe(f core.Field) string {
h, ok := f.(core.FieldMetaHolder)
if !ok {
return ""
}
meta := h.Meta()
switch {
case meta.Label != "" && meta.Description != "":
return meta.Label + ": " + meta.Description
case meta.Description != "":
return meta.Description
}
return meta.Label
}
func label(c *coll) string {
if c.c.Label != "" {
return strings.ToLower(c.c.Label)
}
return c.c.Name
}
func plural(c *coll) string {
if c.c.Plural != "" {
return strings.ToLower(c.c.Plural)
}
return c.c.Name
}
// writer writes SDL.
type writer struct{ strings.Builder }
func (w *writer) line(s string) {
w.WriteString(s)
w.WriteByte('\n')
}
func (w *writer) desc(indent, s string) {
if s != "" {
w.line(indent + `"""` + escape(s) + `"""`)
}
}
// block writes one type with its fields, or nothing when it has none: a type
// with no fields is not GraphQL.
func (w *writer) block(head, desc string, fields []string) {
n := 0
for _, f := range fields {
if !strings.HasPrefix(f, `"""`) {
n++
}
}
if n == 0 {
return
}
w.desc("", desc)
w.line(head + " {")
for _, f := range fields {
w.line(" " + f)
}
w.line("}")
w.line("")
}
func escape(s string) string {
return strings.ReplaceAll(strings.ReplaceAll(s, `\`, `\\`), `"""`, `\"""`)
}
+71
View File
@@ -0,0 +1,71 @@
package tests
import (
"crypto/rand"
"crypto/rsa"
"encoding/base64"
"encoding/json"
"math/big"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
)
// IAM signs tokens the way Hanzo IAM puts them on the wire (RS256, with the key
// id in the header) and serves the JWKS that verifies them at URL, which is what
// an app's apis.StoreKeyJWKSURL names.
type IAM struct {
URL string
key *rsa.PrivateKey
}
const iamKeyID = "iam-test"
// NewIAM starts an IAM for one test.
func NewIAM(t testing.TB) *IAM {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
jwks := map[string]any{"keys": []map[string]any{{
"kty": "RSA",
"kid": iamKeyID,
"alg": "RS256",
"use": "sig",
"n": base64.RawURLEncoding.EncodeToString(key.PublicKey.N.Bytes()),
"e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(key.PublicKey.E)).Bytes()),
}}}
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(jwks)
}))
t.Cleanup(ts.Close)
return &IAM{URL: ts.URL, key: key}
}
// Token signs claims, valid for the next hour.
func (i *IAM) Token(t testing.TB, claims jwt.MapClaims) string {
t.Helper()
signed := jwt.MapClaims{"iat": time.Now().Unix(), "exp": time.Now().Add(time.Hour).Unix()}
for k, v := range claims {
signed[k] = v
}
jt := jwt.NewWithClaims(jwt.SigningMethodRS256, signed)
jt.Header["kid"] = iamKeyID
token, err := jt.SignedString(i.key)
if err != nil {
t.Fatal(err)
}
return token
}