mirror of
https://github.com/hanzoai/base.git
synced 2026-10-21 03:41:50 +00:00
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:
@@ -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`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+181
@@ -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
@@ -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, `\`, `\\`), `"""`, `\"""`)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user