mirror of
https://github.com/zap-proto/zip.git
synced 2026-10-19 15:38:57 +00:00
CheckSchema reads the type-system half of GraphQL and says whether a client can build against a schema: every type it names defined, nothing defined twice, a field answering an output type and an argument taking an input type, unions of objects, roots present. It runs on no request; a graph's tests run it, so a host composing a schema no longer needs a second GraphQL library to find out whether the schema parses. It found one in zip's own: GraphQLSDL named JSON for every map, interface and self-marshalling value without ever declaring it. It declares the scalar now when a field uses it.
302 lines
9.7 KiB
Go
302 lines
9.7 KiB
Go
package zip
|
|
|
|
// GraphQL — the SIXTH projection. The same typed-op registry (a.Registry()) that
|
|
// produces the REST routes, the OpenAPI document, the MCP tool list, the CLI and
|
|
// the op-call plane also produces a GraphQL schema, from the same two types an op
|
|
// already declares: In becomes the field's arguments, Out becomes its result.
|
|
// One value (the op), six projections (REST · OpenAPI · MCP · CLI · call · graph).
|
|
//
|
|
// # Nothing new is declared
|
|
//
|
|
// There is no schema file, no resolver map, and no second description of an
|
|
// endpoint that could disagree with the first. A field exists because an op
|
|
// exists; it disappears when the op does. That is the whole reason to derive a
|
|
// projection rather than write one — the failure this avoids is a GraphQL schema
|
|
// that still advertises an operation the service stopped serving.
|
|
//
|
|
// # A safe read is a query, everything else is a mutation
|
|
//
|
|
// GET ops become Query fields and the rest become Mutation fields, because that
|
|
// is the only signal an op gives about whether it changes anything. It is the
|
|
// same split OpenAPI publishes and the same one the CLI uses to decide whether
|
|
// to confirm, so a reader who knows one knows all three.
|
|
//
|
|
// # GraphQL is not a graph database
|
|
//
|
|
// Nothing here needs one. GraphQL is a query language over resolvers, and the
|
|
// resolver is op.direct — the handler the REST route calls, reached without a
|
|
// URL, sharing validate → authorize → run. Whatever backs the op — sqlite, a
|
|
// document store, another service over the call plane — is untouched by this
|
|
// file and invisible to it.
|
|
|
|
import (
|
|
"reflect"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// GraphQLSDL renders the schema this app's ops describe, in GraphQL's schema
|
|
// definition language.
|
|
//
|
|
// SDL and not a Go type graph, because SDL is what every GraphQL client, code
|
|
// generator and editor already reads. The document is the artifact; publishing
|
|
// it is what makes the projection usable by anything that is not this process.
|
|
func (a *App) GraphQLSDL() string {
|
|
ops := a.Registry()
|
|
sort.Slice(ops, func(i, j int) bool { return ops[i].OperationID < ops[j].OperationID })
|
|
|
|
g := &sdl{types: map[string]string{}, building: map[string]bool{}}
|
|
var query, mutation []string
|
|
for _, op := range ops {
|
|
if op.OperationID == "" {
|
|
// An op with no id has no name to be called by. It is reachable over
|
|
// REST by its path, which GraphQL has no equivalent of, so it is
|
|
// absent here rather than given an invented name that would change
|
|
// the first time the path did.
|
|
continue
|
|
}
|
|
if !op.plain() {
|
|
// A field is one JSON value in and one out. Bytes, a form, a
|
|
// stream, an upgrade, a redirect and a union are not that, so they
|
|
// are absent rather than published as a shape they do not have.
|
|
continue
|
|
}
|
|
f := g.field(op)
|
|
if op.Method == "GET" {
|
|
query = append(query, f)
|
|
continue
|
|
}
|
|
mutation = append(mutation, f)
|
|
}
|
|
|
|
var b strings.Builder
|
|
b.WriteString("# Generated from this app's typed ops. Do not edit.\n")
|
|
b.WriteString("# Every field below is one op; there is no separate schema to drift from it.\n\n")
|
|
if g.json {
|
|
// Declared because it is used: a schema naming a type it never defines
|
|
// is one no client can build against.
|
|
b.WriteString("scalar JSON\n\n")
|
|
}
|
|
writeBlock(&b, "Query", query)
|
|
writeBlock(&b, "Mutation", mutation)
|
|
|
|
names := make([]string, 0, len(g.types))
|
|
for n := range g.types {
|
|
names = append(names, n)
|
|
}
|
|
sort.Strings(names)
|
|
for _, n := range names {
|
|
b.WriteString(g.types[n])
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// writeBlock emits one root type, or nothing when it has no fields. An empty
|
|
// `type Query {}` is invalid SDL, and an app with only mutations is a real thing.
|
|
func writeBlock(b *strings.Builder, name string, fields []string) {
|
|
if len(fields) == 0 {
|
|
return
|
|
}
|
|
b.WriteString("type " + name + " {\n")
|
|
for _, f := range fields {
|
|
b.WriteString(" " + f + "\n")
|
|
}
|
|
b.WriteString("}\n\n")
|
|
}
|
|
|
|
// sdl accumulates the named types the fields refer to.
|
|
//
|
|
// `building` is the cycle guard and it is separate from `types` on purpose: a
|
|
// self-referential struct is reached again while its own definition is still
|
|
// being written, so membership in `types` is not yet true and cannot be the test.
|
|
type sdl struct {
|
|
types map[string]string
|
|
building map[string]bool
|
|
json bool // a field is published as the JSON scalar, which must then be declared
|
|
}
|
|
|
|
// opaque names a value the schema cannot describe, and records that the JSON
|
|
// scalar it is named by is in use.
|
|
func (g *sdl) opaque() string {
|
|
g.json = true
|
|
return "JSON"
|
|
}
|
|
|
|
func (g *sdl) field(op *registeredOp) string {
|
|
var b strings.Builder
|
|
if op.Summary != "" {
|
|
b.WriteString(`"""` + op.Summary + `""" `)
|
|
}
|
|
b.WriteString(gqlName(op.OperationID))
|
|
if args := g.args(op.InType); args != "" {
|
|
b.WriteString("(" + args + ")")
|
|
}
|
|
b.WriteString(": " + g.typeRef(op.OutType, "Out"))
|
|
return b.String()
|
|
}
|
|
|
|
// args flattens the input struct's fields into the field's arguments.
|
|
//
|
|
// FLATTENED rather than a single `input:` object, because an op's In IS its
|
|
// argument list — wrapping it would add a level no other projection has and make
|
|
// the GraphQL call read differently from the same call over REST or the CLI.
|
|
func (g *sdl) args(t reflect.Type) string {
|
|
var out []string
|
|
for _, f := range wireFields(t) {
|
|
if headerFieldName(f) != "" || cookieFieldName(f) != "" {
|
|
// A header or a cookie is AMBIENT, not an argument. Over REST its
|
|
// value comes from the request — set by whatever runs in front of the
|
|
// handler, or by the browser — so publishing it as an argument would
|
|
// invite a client to supply its own instead. The executor refuses it
|
|
// by the same rule, and binds no cookie: the graph, like MCP, carries
|
|
// no browser credential.
|
|
continue
|
|
}
|
|
name := jsonFieldName(f)
|
|
if name == "-" {
|
|
continue
|
|
}
|
|
ref := g.typeRef(f.Type, f.Name)
|
|
if strings.Contains(f.Tag.Get("validate"), "required") {
|
|
ref += "!"
|
|
}
|
|
out = append(out, gqlName(name)+": "+ref)
|
|
}
|
|
return strings.Join(out, ", ")
|
|
}
|
|
|
|
// typeRef names t in GraphQL, defining it first when it is a struct.
|
|
func (g *sdl) typeRef(t reflect.Type, hint string) string {
|
|
t = deref(t)
|
|
if t == nil {
|
|
// An op with no Out still answers — it answers nothing. GraphQL has no
|
|
// void, so it is Boolean: the call either happened or it errored.
|
|
return "Boolean"
|
|
}
|
|
if t == timeType {
|
|
// time.Time writes itself as an RFC 3339 string, so a string is what a
|
|
// caller sees. Describing it by its fields would describe something that
|
|
// never crosses the wire.
|
|
return "String"
|
|
}
|
|
if isMarshaler(t) {
|
|
// The type writes its own JSON, so its Go fields say nothing about the
|
|
// shape that arrives. JSON is the honest name for a value only the type
|
|
// itself can describe.
|
|
return g.opaque()
|
|
}
|
|
switch t.Kind() {
|
|
case reflect.Bool:
|
|
return "Boolean"
|
|
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
|
|
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
|
return "Int"
|
|
case reflect.Float32, reflect.Float64:
|
|
return "Float"
|
|
case reflect.String:
|
|
return "String"
|
|
case reflect.Slice, reflect.Array:
|
|
if t.Elem().Kind() == reflect.Uint8 {
|
|
return "String" // []byte crosses as text, as it does in the JSON document
|
|
}
|
|
return "[" + g.typeRef(t.Elem(), hint) + "]"
|
|
case reflect.Struct:
|
|
return g.define(t, hint)
|
|
case reflect.Map, reflect.Interface:
|
|
// GraphQL has no untyped node. Naming this JSON is honest about what it
|
|
// is — an opaque blob the schema cannot describe — rather than inventing
|
|
// a shape a client would then rely on.
|
|
return g.opaque()
|
|
}
|
|
return g.opaque()
|
|
}
|
|
|
|
// define writes a named object type once and returns its name.
|
|
func (g *sdl) define(t reflect.Type, hint string) string {
|
|
name := gqlType(t, hint)
|
|
if g.building[name] {
|
|
return name // reached while still being written: the cycle guard
|
|
}
|
|
if _, done := g.types[name]; done {
|
|
return name
|
|
}
|
|
g.building[name] = true
|
|
defer delete(g.building, name)
|
|
|
|
var b strings.Builder
|
|
b.WriteString("type " + name + " {\n")
|
|
n := 0
|
|
for _, f := range wireFields(t) {
|
|
fname := jsonFieldName(f)
|
|
if fname == "-" {
|
|
continue
|
|
}
|
|
b.WriteString(" " + gqlName(fname) + ": " + g.typeRef(f.Type, f.Name) + "\n")
|
|
n++
|
|
}
|
|
if n == 0 {
|
|
// A type with no exported fields is not expressible: `type X {}` is
|
|
// invalid SDL. It becomes JSON rather than an empty type nobody can query.
|
|
return g.opaque()
|
|
}
|
|
b.WriteString("}\n\n")
|
|
g.types[name] = b.String()
|
|
return name
|
|
}
|
|
|
|
func deref(t reflect.Type) reflect.Type {
|
|
for t != nil && t.Kind() == reflect.Pointer {
|
|
t = t.Elem()
|
|
}
|
|
return t
|
|
}
|
|
|
|
// gqlType names a struct. The Go type name is used when it has one; an anonymous
|
|
// struct borrows the field or op it hangs off, which is the only name it has.
|
|
//
|
|
// Capitalised, because a GraphQL type conventionally is, and a schema that reads
|
|
// as though somebody wrote it is the point of generating one. openapi's sanitize
|
|
// is not reused here: it lower-cases, which is right for a component key and
|
|
// wrong for a type.
|
|
func gqlType(t reflect.Type, hint string) string {
|
|
if n := typeName(t); n != "" {
|
|
return title(gqlName(n))
|
|
}
|
|
if hint == "" {
|
|
return "JSON"
|
|
}
|
|
return title(gqlName(hint))
|
|
}
|
|
|
|
// title upper-cases the first letter and nothing else — the rest of the name is
|
|
// the Go author's, and re-casing it would turn UserID into Userid.
|
|
func title(s string) string {
|
|
if s == "" {
|
|
return s
|
|
}
|
|
if c := s[0]; c >= 'a' && c <= 'z' {
|
|
return string(c-32) + s[1:]
|
|
}
|
|
return s
|
|
}
|
|
|
|
// gqlName makes a field name GraphQL accepts: /[_A-Za-z][_0-9A-Za-z]*/.
|
|
func gqlName(s string) string {
|
|
var b strings.Builder
|
|
for i, r := range s {
|
|
switch {
|
|
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r == '_':
|
|
b.WriteRune(r)
|
|
case r >= '0' && r <= '9' && i > 0:
|
|
b.WriteRune(r)
|
|
default:
|
|
b.WriteRune('_')
|
|
}
|
|
}
|
|
out := b.String()
|
|
if out == "" {
|
|
return "_"
|
|
}
|
|
return out
|
|
}
|