Files
go-omni/cmd/go-omni/apigen/responseGraph.go

371 lines
11 KiB
Go

package apigen
import (
"encoding/json"
"fmt"
"go/ast"
goparser "go/parser"
"reflect"
"sort"
"strconv"
"strings"
"git.yuntuo.hk/k2safe/go-omni/contract"
"git.yuntuo.hk/k2safe/go-omni/openapi"
)
// Named response types form a graph, not an infinitely expanded reflect.StructOf
// tree. Compile each object's local fields with the existing contract compiler,
// then link named fields through OpenAPI references. Request binders still use
// the complete, nonrecursive reflection tree.
type responseGraph struct {
types map[string]Type
exprs map[string][]ast.Expr
recursive map[string]bool // includes types that can reach a cycle
scalars map[string]reflect.Type
scalarDefs []contract.Scalar
components map[string]*openapi.Schema
}
func newResponseGraph(spec *Spec, types map[string]Type, scalars map[string]reflect.Type, defs []contract.Scalar) (*responseGraph, error) {
g := &responseGraph{types: types, exprs: map[string][]ast.Expr{}, recursive: map[string]bool{}, scalars: scalars, scalarDefs: defs, components: map[string]*openapi.Schema{}}
dependencies, values := map[string][]string{}, map[string][]string{}
for _, typ := range spec.Types {
seen := map[string]bool{}
for _, f := range typ.Fields {
if !exportedName.MatchString(f.Name) || seen[f.Name] {
return nil, fmt.Errorf("%s: invalid or duplicate field %s", typ.Name, f.Name)
}
seen[f.Name] = true
e, err := goparser.ParseExpr(f.Type)
if err != nil {
return nil, err
}
// Validate every expression, including otherwise unused declarations.
if _, err = g.expression(e, 0, func(name string, _ int) (reflect.Type, error) {
dependencies[typ.Name] = append(dependencies[typ.Name], name)
return reflect.TypeOf(struct{}{}), nil
}); err != nil {
return nil, fmt.Errorf("%s.%s: %w", typ.Name, f.Name, err)
}
g.exprs[typ.Name] = append(g.exprs[typ.Name], e)
if id, ok := e.(*ast.Ident); ok && types[id.Name].Name != "" {
values[typ.Name] = append(values[typ.Name], id.Name)
}
}
}
// Only pointer/slice/map indirection can break a Go struct layout cycle.
state := map[string]int{}
var valueCycle func(string) error
valueCycle = func(name string) error {
if state[name] == 1 {
return fmt.Errorf("%s: invalid recursive value type; use a pointer, slice or map", name)
}
if state[name] == 2 {
return nil
}
state[name] = 1
for _, next := range values[name] {
if err := valueCycle(next); err != nil {
return err
}
}
state[name] = 2
return nil
}
for _, typ := range spec.Types {
if err := valueCycle(typ.Name); err != nil {
return nil, err
}
}
state = map[string]int{}
var cyclic func(string) bool
cyclic = func(name string) bool {
if state[name] == 1 {
return true
}
if state[name] == 2 {
return g.recursive[name]
}
state[name] = 1
for _, next := range dependencies[name] {
if cyclic(next) {
g.recursive[name] = true
}
}
state[name] = 2
return g.recursive[name]
}
for _, typ := range spec.Types {
cyclic(typ.Name)
}
return g, nil
}
func (g *responseGraph) expression(e ast.Expr, depth int, named func(string, int) (reflect.Type, error)) (reflect.Type, error) {
if depth > 32 {
return nil, fmt.Errorf("type nesting exceeds 32")
}
switch e := e.(type) {
case *ast.Ident:
if t := g.scalars[e.Name]; t != nil {
return t, nil
}
if t := primitiveTypes[e.Name]; t != nil {
return t, nil
}
if g.types[e.Name].Name == "" {
return nil, fmt.Errorf("unknown type %s", e.Name)
}
return named(e.Name, depth+1)
case *ast.StarExpr:
if _, double := e.X.(*ast.StarExpr); double {
return nil, fmt.Errorf("multiple pointer levels unsupported")
}
t, err := g.expression(e.X, depth+1, named)
if err != nil {
return nil, err
}
return reflect.PointerTo(t), nil
case *ast.ArrayType:
if e.Len != nil {
return nil, fmt.Errorf("use slices, not fixed arrays")
}
t, err := g.expression(e.Elt, depth+1, named)
if err != nil {
return nil, err
}
return reflect.SliceOf(t), nil
case *ast.MapType:
key, ok := e.Key.(*ast.Ident)
if !ok || key.Name != "string" {
return nil, fmt.Errorf("map keys must be string")
}
t, err := g.expression(e.Value, depth+1, named)
if err != nil {
return nil, err
}
return reflect.MapOf(reflect.TypeOf(""), t), nil
default:
return nil, fmt.Errorf("unsupported type expression")
}
}
func (g *responseGraph) component(name string) (*openapi.Schema, error) {
key := "DTO_" + name
ref := &openapi.Schema{Ref: "#/components/schemas/" + key}
if _, ok := g.components[key]; ok {
return ref, nil
}
// Register before following edges so both self and mutual references terminate.
g.components[key] = nil
typ := g.types[name]
var fields []reflect.StructField
for i, f := range typ.Fields {
t, err := g.expression(g.exprs[name][i], 0, func(string, int) (reflect.Type, error) { return reflect.TypeOf(struct{}{}), nil })
if err != nil {
return nil, err
}
tag, err := withoutExample(f)
if err != nil {
return nil, err
}
fields = append(fields, reflect.StructField{Name: f.Name, Type: t, Tag: tag})
}
b, err := contract.Describe(reflect.StructOf(fields), g.scalarDefs...)
if err != nil {
return nil, fmt.Errorf("%s: %w", typ.Position, err)
}
s, err := b.ResponseSchema()
if err != nil {
return nil, fmt.Errorf("%s: %w", typ.Position, err)
}
for i, f := range typ.Fields {
wire := strings.Split(reflect.StructTag(fieldTag(f)).Get("json"), ",")[0]
property := s.Properties[wire]
// Examples are checked against a finite projection of their actual values,
// with the same contract rules and without running application decoders.
if _, ok := reflect.StructTag(fieldTag(f)).Lookup("example"); ok {
example, err := g.example(f, g.exprs[name][i])
if err != nil {
return nil, fmt.Errorf("%s.%s: %w", name, f.Name, err)
}
property.Example = example
}
property, err = g.link(g.exprs[name][i], property)
if err != nil {
return nil, err
}
s.Properties[wire] = property
}
g.components[key] = s
return ref, nil
}
func (g *responseGraph) link(e ast.Expr, s *openapi.Schema) (*openapi.Schema, error) {
switch e := e.(type) {
case *ast.Ident:
if g.types[e.Name].Name == "" {
return s, nil
}
ref, err := g.component(e.Name)
if err != nil {
return nil, err
}
if s.Nullable {
ref = nullableReference(ref)
}
if s.Description != "" || s.Example != nil {
// OpenAPI 3.0 ignores siblings on Reference Objects.
ref = &openapi.Schema{AllOf: []*openapi.Schema{ref}, Description: s.Description, Example: s.Example}
}
return ref, nil
case *ast.StarExpr:
return g.link(e.X, s)
case *ast.ArrayType:
item, err := g.link(e.Elt, s.Items)
if err != nil {
return nil, err
}
s.Items = item
case *ast.MapType:
item, err := g.link(e.Value, s.AdditionalProperties)
if err != nil {
return nil, err
}
s.AdditionalProperties = item
}
return s, nil
}
func nullableReference(s *openapi.Schema) *openapi.Schema {
return &openapi.Schema{AnyOf: []*openapi.Schema{s, {Type: "object", Nullable: true, Enum: []any{nil}}}}
}
func withoutExample(f Field) (reflect.StructTag, error) {
tags, err := contract.ParseTags(fieldTag(f))
if err != nil {
return "", err
}
delete(tags, "example")
keys := make([]string, 0, len(tags))
for key := range tags {
keys = append(keys, key)
}
sort.Strings(keys)
var parts []string
for _, key := range keys {
parts = append(parts, key+":"+strconv.Quote(tags[key]))
}
return reflect.StructTag(strings.Join(parts, " ")), nil
}
func (g *responseGraph) example(f Field, e ast.Expr) (any, error) {
raw := reflect.StructTag(fieldTag(f)).Get("example")
var value any
// Plain string examples intentionally are not JSON literals.
if f.Type == "string" || f.Type == "*string" {
value = raw
} else {
decoder := json.NewDecoder(strings.NewReader(raw))
decoder.UseNumber()
if err := decoder.Decode(&value); err != nil {
return nil, fmt.Errorf("invalid example JSON: %w", err)
}
}
budget := 10000
t, err := g.projectExample(e, []any{value}, 0, &budget)
if err != nil {
return nil, err
}
b, err := contract.Describe(reflect.StructOf([]reflect.StructField{{Name: f.Name, Type: t, Tag: reflect.StructTag(fieldTag(f))}}), g.scalarDefs...)
if err != nil {
return nil, err
}
s, err := b.ResponseSchema()
if err != nil {
return nil, err
}
wire := strings.Split(reflect.StructTag(fieldTag(f)).Get("json"), ",")[0]
return s.Properties[wire].Example, nil
}
// Only materialize named objects where the finite example has a non-null value.
// Empty slices/maps and null pointers stop expansion. Unioning collection values
// checks every item using one reflection type, including missing/unknown fields.
func (g *responseGraph) projectExample(e ast.Expr, values []any, depth int, budget *int) (reflect.Type, error) {
*budget--
if depth > 32 || *budget < 0 {
return nil, fmt.Errorf("example type expansion exceeds 32 levels or 10000 nodes")
}
var present []any
for _, v := range values {
if v != nil {
present = append(present, v)
}
}
switch e := e.(type) {
case *ast.Ident:
return g.expression(e, 0, func(name string, _ int) (reflect.Type, error) {
if len(present) == 0 {
return reflect.TypeOf(struct{}{}), nil
}
var fields []reflect.StructField
for i, f := range g.types[name].Fields {
wire := strings.Split(reflect.StructTag(fieldTag(f)).Get("json"), ",")[0]
var nested []any
for _, v := range present {
if obj, ok := v.(map[string]any); ok {
if child, found := obj[wire]; found {
nested = append(nested, child)
}
}
}
t, err := g.projectExample(g.exprs[name][i], nested, depth+1, budget)
if err != nil {
return nil, err
}
tag, err := withoutExample(f)
if err != nil {
return nil, err
}
fields = append(fields, reflect.StructField{Name: f.Name, Type: t, Tag: tag})
}
return reflect.StructOf(fields), nil
})
case *ast.StarExpr:
t, err := g.projectExample(e.X, present, depth+1, budget)
if err != nil {
return nil, err
}
return reflect.PointerTo(t), nil
case *ast.ArrayType:
var nested []any
for _, v := range present {
if list, ok := v.([]any); ok {
nested = append(nested, list...)
}
}
t, err := g.projectExample(e.Elt, nested, depth+1, budget)
if err != nil {
return nil, err
}
return reflect.SliceOf(t), nil
case *ast.MapType:
var nested []any
for _, v := range present {
if obj, ok := v.(map[string]any); ok {
for _, v := range obj {
nested = append(nested, v)
}
}
}
t, err := g.projectExample(e.Value, nested, depth+1, budget)
if err != nil {
return nil, err
}
return reflect.MapOf(reflect.TypeOf(""), t), nil
}
return nil, fmt.Errorf("unsupported example type")
}