mirror of
https://github.com/hanzoai/base.git
synced 2026-10-21 03:41:50 +00:00
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.
182 lines
4.7 KiB
Go
182 lines
4.7 KiB
Go
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)
|
|
}
|