246 lines
8.1 KiB
Go
246 lines
8.1 KiB
Go
package apigen
|
|
|
|
import (
|
|
"fmt"
|
|
"go/ast"
|
|
goparser "go/parser"
|
|
"reflect"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// Diagnostic points to preserved project code that needs attention. Review
|
|
// notices describe contract changes; errors describe definite source problems.
|
|
type Diagnostic struct {
|
|
Severity, Code, Path, Operation, Message string
|
|
Line int
|
|
Before, After string
|
|
Details []string
|
|
}
|
|
|
|
// Snapshots describe the last successfully generated contract, never business
|
|
// source or absolute paths. They are persisted in the ownership transaction.
|
|
type contractSnapshot struct {
|
|
Routes map[string]routeContract `json:"routes"`
|
|
}
|
|
|
|
type routeContract struct {
|
|
Method string `json:"method"`
|
|
Path string `json:"path"`
|
|
Group string `json:"group"`
|
|
Handler string `json:"handler"`
|
|
Signature string `json:"signature"`
|
|
Request string `json:"request,omitempty"`
|
|
Response string `json:"response,omitempty"`
|
|
BodyMode string `json:"bodyMode,omitempty"`
|
|
Produces string `json:"produces,omitempty"`
|
|
MaxBodyBytes int64 `json:"maxBodyBytes,omitempty"`
|
|
Manual bool `json:"manual,omitempty"`
|
|
CustomBinding bool `json:"customBinding,omitempty"`
|
|
Types map[string]typeContract `json:"types,omitempty"`
|
|
}
|
|
|
|
type typeContract struct {
|
|
Fields []contractField `json:"fields,omitempty"`
|
|
Scalar string `json:"scalar,omitempty"`
|
|
}
|
|
|
|
type contractField struct {
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
Tag string `json:"tag"`
|
|
}
|
|
|
|
func snapshotContract(spec *Spec) *contractSnapshot {
|
|
definitions := map[string]typeContract{}
|
|
for _, t := range spec.Types {
|
|
d := typeContract{}
|
|
for _, f := range t.Fields {
|
|
d.Fields = append(d.Fields, contractField{f.Name, f.Type, f.Tag})
|
|
}
|
|
definitions[t.Name] = d
|
|
}
|
|
for _, s := range spec.Scalars {
|
|
definitions[s.Name] = typeContract{Scalar: fmt.Sprintf("%s.%s (jsonInput=%s, jsonOutput=%s, text=%t)", s.GoImport, s.GoType, s.JSONInput, s.JSONOutput, s.Text)}
|
|
}
|
|
snapshot := &contractSnapshot{Routes: map[string]routeContract{}}
|
|
for _, r := range spec.Routes {
|
|
dependencies := map[string]typeContract{}
|
|
var visit func(string)
|
|
visit = func(name string) {
|
|
d, ok := definitions[name]
|
|
if !ok {
|
|
return
|
|
}
|
|
if _, exists := dependencies[name]; exists {
|
|
return
|
|
}
|
|
dependencies[name] = d
|
|
for _, f := range d.Fields {
|
|
expr, err := goparser.ParseExpr(f.Type)
|
|
if err != nil {
|
|
continue
|
|
} // Compile already validates expressions.
|
|
ast.Inspect(expr, func(n ast.Node) bool {
|
|
if id, ok := n.(*ast.Ident); ok {
|
|
visit(id.Name)
|
|
}
|
|
return true
|
|
})
|
|
}
|
|
}
|
|
visit(r.Request)
|
|
_, responseName := responseShape(r.Response)
|
|
visit(responseName)
|
|
op := routeContract{
|
|
Method: strings.ToUpper(r.Method), Path: r.Path, Group: r.Group, Handler: r.Handler,
|
|
Request: r.Request, Response: r.Response, BodyMode: r.BodyMode, Produces: r.Produces,
|
|
MaxBodyBytes: r.MaxBodyBytes, Manual: r.Manual, CustomBinding: r.CustomBinding, Types: dependencies,
|
|
}
|
|
if !r.Manual {
|
|
op.Signature = expectedLogicSignature(r)
|
|
}
|
|
snapshot.Routes[r.Group+"/"+lower(r.Handler)] = op
|
|
}
|
|
return snapshot
|
|
}
|
|
|
|
func (r routeContract) file() string {
|
|
if r.Manual {
|
|
return "internal/handler/" + r.Group + "/" + lower(r.Handler) + "Manual.go"
|
|
}
|
|
return "internal/logic/" + r.Group + "/" + lower(r.Handler) + ".go"
|
|
}
|
|
|
|
func (r routeContract) operation() string { return r.Method + " " + r.Path }
|
|
|
|
func compareContracts(old, next *contractSnapshot) []Diagnostic {
|
|
if old == nil {
|
|
return nil
|
|
}
|
|
var diagnostics []Diagnostic
|
|
for key, previous := range old.Routes {
|
|
current, exists := next.Routes[key]
|
|
if !exists {
|
|
diagnostics = append(diagnostics, Diagnostic{Severity: "review", Code: "routeRemoved", Path: previous.file(), Operation: previous.operation(), Message: "route removed or moved; its handwritten implementation is preserved; review obsolete code and types"})
|
|
continue
|
|
}
|
|
d := Diagnostic{Severity: "review", Code: "contractChanged", Path: current.file(), Operation: current.operation(), Message: "contract changed; review the preserved implementation"}
|
|
if previous.Signature != current.Signature {
|
|
d.Before, d.After = previous.Signature, current.Signature
|
|
d.Details = append(d.Details, "Logic call signature changed")
|
|
}
|
|
if previous.Method != current.Method || previous.Path != current.Path {
|
|
d.Details = append(d.Details, previous.operation()+" -> "+current.operation())
|
|
}
|
|
if previous.Manual != current.Manual || previous.CustomBinding != current.CustomBinding || previous.BodyMode != current.BodyMode || previous.MaxBodyBytes != current.MaxBodyBytes || previous.Produces != current.Produces {
|
|
d.Details = append(d.Details, fmt.Sprintf("HTTP access: manual=%t customBinding=%t body=%s maxBytes=%d produces=%q -> manual=%t customBinding=%t body=%s maxBytes=%d produces=%q", previous.Manual, previous.CustomBinding, previous.BodyMode, previous.MaxBodyBytes, previous.Produces, current.Manual, current.CustomBinding, current.BodyMode, current.MaxBodyBytes, current.Produces))
|
|
}
|
|
d.Details = append(d.Details, changedTypes(previous.Types, current.Types)...)
|
|
if len(d.Details) > 0 {
|
|
diagnostics = append(diagnostics, d)
|
|
}
|
|
}
|
|
return diagnostics
|
|
}
|
|
|
|
func changedTypes(old, next map[string]typeContract) []string {
|
|
names := map[string]bool{}
|
|
for name := range old {
|
|
names[name] = true
|
|
}
|
|
for name := range next {
|
|
names[name] = true
|
|
}
|
|
var details []string
|
|
for name := range names {
|
|
a, existed := old[name]
|
|
b, exists := next[name]
|
|
switch {
|
|
case !existed:
|
|
details = append(details, "DTO dependency added: "+name)
|
|
case !exists:
|
|
details = append(details, "DTO dependency removed: "+name)
|
|
case a.Scalar != b.Scalar:
|
|
details = append(details, fmt.Sprintf("scalar %s: %s -> %s", name, a.Scalar, b.Scalar))
|
|
case !reflect.DeepEqual(a.Fields, b.Fields):
|
|
oldFields, newFields := map[string]contractField{}, map[string]contractField{}
|
|
for _, f := range a.Fields {
|
|
oldFields[f.Name] = f
|
|
}
|
|
for _, f := range b.Fields {
|
|
newFields[f.Name] = f
|
|
}
|
|
before := len(details)
|
|
for field, f := range oldFields {
|
|
if n, ok := newFields[field]; !ok {
|
|
details = append(details, fmt.Sprintf("DTO %s.%s removed", name, field))
|
|
} else if f != n {
|
|
details = append(details, fmt.Sprintf("DTO %s.%s: %s `%s` -> %s `%s`", name, field, f.Type, f.Tag, n.Type, n.Tag))
|
|
}
|
|
}
|
|
for field := range newFields {
|
|
if _, ok := oldFields[field]; !ok {
|
|
details = append(details, fmt.Sprintf("DTO %s.%s added", name, field))
|
|
}
|
|
}
|
|
if len(details) == before {
|
|
details = append(details, "DTO "+name+" field order changed; review positional struct literals")
|
|
}
|
|
}
|
|
}
|
|
sort.Strings(details)
|
|
return details
|
|
}
|
|
|
|
func sortDiagnostics(diagnostics []Diagnostic) {
|
|
sort.Slice(diagnostics, func(i, j int) bool {
|
|
a, b := diagnostics[i], diagnostics[j]
|
|
if a.Path != b.Path {
|
|
return a.Path < b.Path
|
|
}
|
|
if a.Line != b.Line {
|
|
return a.Line < b.Line
|
|
}
|
|
if a.Code != b.Code {
|
|
return a.Code < b.Code
|
|
}
|
|
return a.Operation < b.Operation
|
|
})
|
|
}
|
|
|
|
func diagnosticErrors(diagnostics []Diagnostic) int {
|
|
count := 0
|
|
for _, d := range diagnostics {
|
|
if d.Severity == "error" {
|
|
count++
|
|
}
|
|
}
|
|
return count
|
|
}
|
|
|
|
func expectedLogicSignature(r Route) string {
|
|
params := []string{"context.Context"}
|
|
if r.Request != "" {
|
|
params = append(params, "*types."+r.Request)
|
|
}
|
|
if streamsBody(r) {
|
|
params = append(params, "*httpx.Body")
|
|
}
|
|
results := []string{"error"}
|
|
if r.Produces != "" {
|
|
results = []string{"*httpx.Stream", "error"}
|
|
} else if result := responseGoType(r.Response); result != "" {
|
|
results = []string{result, "error"}
|
|
}
|
|
return displaySignature(upper(r.Handler), params, results)
|
|
}
|
|
|
|
func displaySignature(name string, params, results []string) string {
|
|
result := strings.Join(results, ", ")
|
|
if len(results) != 1 {
|
|
result = "(" + result + ")"
|
|
}
|
|
return name + "(" + strings.Join(params, ", ") + ") " + result
|
|
}
|