mirror of
https://github.com/hanzoai/team.git
synced 2026-11-01 07:55:31 +00:00
350 lines
11 KiB
Go
350 lines
11 KiB
Go
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
package openApiSync
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"go/ast"
|
|
"go/printer"
|
|
"go/token"
|
|
"net/http"
|
|
"os"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/pb33f/libopenapi"
|
|
v3high "github.com/pb33f/libopenapi/datamodel/high/v3"
|
|
"github.com/pkg/errors"
|
|
"github.com/sajari/fuzzy"
|
|
"golang.org/x/tools/go/analysis"
|
|
)
|
|
|
|
var (
|
|
Analyzer = &analysis.Analyzer{
|
|
Name: "openApiSync",
|
|
Doc: "check for inconsistencies between OpenAPI spec and the source code",
|
|
Run: run,
|
|
}
|
|
|
|
specFile string
|
|
groupSplitRegexp = regexp.MustCompile(`{([a-z_]*):([a-z_]*)\|([a-z_]*)}`)
|
|
// Excludes | so group-alternation patterns like {type:a|b} are left intact for splitHandlerByGroup.
|
|
pathParamConstraintRegex = regexp.MustCompile(`\{([^:}]+):[^|}]+\}`)
|
|
openAPIParamRegex = regexp.MustCompile(`\{[^}]+\}`)
|
|
IgnoredCases = []string{"{websocket}", "api/v4/remotecluster"}
|
|
)
|
|
|
|
func init() {
|
|
Analyzer.Flags.StringVar(&specFile, "spec", "", "Path to the OpenAPI 3 YAML spec file")
|
|
}
|
|
|
|
// formatNode converts AST node to string
|
|
func formatNode(fset *token.FileSet, node any) string {
|
|
var typeNameBuf bytes.Buffer
|
|
_ = printer.Fprint(&typeNameBuf, fset, node)
|
|
return typeNameBuf.String()
|
|
}
|
|
|
|
// stringInSlice checks presence of a string in a slice
|
|
func stringInSlice(str string, slice []string, partial bool) bool {
|
|
for _, s := range slice {
|
|
if s == str {
|
|
return true
|
|
}
|
|
if partial && strings.Contains(str, s) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// cleanRegexp strips regex constraints from Go router path parameters so they
|
|
// can be matched against OpenAPI path templates.
|
|
// e.g., "/users/{user_id:[0-9]+}" → "/users/{user_id}"
|
|
func cleanRegexp(s string) string {
|
|
return pathParamConstraintRegex.ReplaceAllString(s, "{$1}")
|
|
}
|
|
|
|
// matchesTemplate returns true if the OpenAPI specPath template matches handlerPath.
|
|
// Spec path parameters (e.g., {foo}) match any single non-slash segment, mirroring
|
|
// the behavior of kin-openapi's Paths.Find().
|
|
func matchesTemplate(specPath, handlerPath string) bool {
|
|
specParts := strings.Split(strings.Trim(specPath, "/"), "/")
|
|
handlerParts := strings.Split(strings.Trim(handlerPath, "/"), "/")
|
|
if len(specParts) != len(handlerParts) {
|
|
return false
|
|
}
|
|
for i, specPart := range specParts {
|
|
if openAPIParamRegex.MatchString(specPart) {
|
|
continue
|
|
}
|
|
if specPart != handlerParts[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// findPathItem returns the path item matching handlerPath in the spec.
|
|
// It first tries exact key lookup, then falls back to template matching.
|
|
func findPathItem(paths *v3high.Paths, handlerPath string) *v3high.PathItem {
|
|
if item := paths.PathItems.GetOrZero(handlerPath); item != nil {
|
|
return item
|
|
}
|
|
for specPath, item := range paths.PathItems.FromOldest() {
|
|
if matchesTemplate(specPath, handlerPath) {
|
|
return item
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// splitHandlerByGroup checks if URL path regexp contains named groups, and splits them in separate paths to be compatible with OpenAPI paths
|
|
func splitHandlerByGroup(str string) []string {
|
|
matches := groupSplitRegexp.FindAllStringSubmatch(str, -1)
|
|
if len(matches) != 1 {
|
|
return []string{str}
|
|
}
|
|
group := matches[0][0]
|
|
part1 := matches[0][2]
|
|
part2 := matches[0][3]
|
|
return []string{strings.Replace(str, group, part1, 1), strings.Replace(str, group, part2, 1)}
|
|
}
|
|
|
|
// getOperation returns the operation for the given HTTP method on a path item, or nil if not defined.
|
|
func getOperation(pathItem *v3high.PathItem, method string) *v3high.Operation {
|
|
switch strings.ToUpper(method) {
|
|
case http.MethodGet:
|
|
return pathItem.Get
|
|
case http.MethodPost:
|
|
return pathItem.Post
|
|
case http.MethodPut:
|
|
return pathItem.Put
|
|
case http.MethodDelete:
|
|
return pathItem.Delete
|
|
case http.MethodPatch:
|
|
return pathItem.Patch
|
|
case http.MethodHead:
|
|
return pathItem.Head
|
|
case http.MethodOptions:
|
|
return pathItem.Options
|
|
case http.MethodTrace:
|
|
return pathItem.Trace
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// processRouterInit checks that all Init functions defined in `names` are properly documented
|
|
func processRouterInit(pass *analysis.Pass, names []string, routerPrefixes map[string]string, paths *v3high.Paths, cm *fuzzy.Model) {
|
|
for _, file := range pass.Files {
|
|
ast.Inspect(file, func(n ast.Node) bool {
|
|
decl, ok := n.(*ast.FuncDecl)
|
|
if !ok || !stringInSlice(decl.Name.Name, names, false) {
|
|
return true
|
|
}
|
|
for _, stmt := range decl.Body.List {
|
|
expr, ok := stmt.(*ast.ExprStmt)
|
|
if !ok {
|
|
continue
|
|
}
|
|
aexpr, ok := expr.X.(*ast.CallExpr).Fun.(*ast.SelectorExpr).X.(*ast.CallExpr)
|
|
if !ok || len(aexpr.Args) != 2 {
|
|
continue
|
|
}
|
|
prefix, _ := strconv.Unquote(aexpr.Args[0].(*ast.BasicLit).Value)
|
|
method := getMethodFromExpr(expr)
|
|
|
|
name := aexpr.Fun.(*ast.SelectorExpr).X.(*ast.SelectorExpr).Sel.Name
|
|
handler := cleanRegexp(routerPrefixes[name]) + cleanRegexp(prefix)
|
|
if stringInSlice(handler, IgnoredCases, true) { // ignore special cases
|
|
continue
|
|
}
|
|
if !strings.HasPrefix(handler, "/") {
|
|
handler = "/" + handler
|
|
}
|
|
for _, h := range splitHandlerByGroup(handler) {
|
|
pathItem := findPathItem(paths, h)
|
|
if pathItem == nil {
|
|
suffix := ""
|
|
if suggestions := cm.Suggestions(h, false); len(suggestions) > 0 {
|
|
suffix = fmt.Sprintf(" (maybe you meant: %v)", suggestions)
|
|
}
|
|
pass.Reportf(aexpr.Pos(), "Cannot find %v method: %v in OpenAPI 3 spec.%s", h, method, suffix)
|
|
} else if getOperation(pathItem, method) == nil {
|
|
pass.Reportf(aexpr.Pos(), "Handler %v is defined with method %s, but it's not in the spec", h, method)
|
|
}
|
|
}
|
|
}
|
|
|
|
return true
|
|
})
|
|
}
|
|
}
|
|
|
|
// parseRoutesStruct scans Routes struct for mux.Router fields and scans their comments
|
|
func parseRoutesStruct(pass *analysis.Pass, decl *ast.GenDecl, routerPrefixes map[string]string) {
|
|
spec, ok := decl.Specs[0].(*ast.TypeSpec)
|
|
if !ok || spec.Name.String() != "Routes" {
|
|
return
|
|
}
|
|
for _, f := range spec.Type.(*ast.StructType).Fields.List {
|
|
typeName := formatNode(pass.Fset, f.Type)
|
|
if typeName != "*mux.Router" {
|
|
continue
|
|
}
|
|
routerName := f.Names[0].Name
|
|
if routerName == "ApiRoot" || routerName == "Root" {
|
|
continue
|
|
}
|
|
if f.Comment != nil && len(f.Comment.List) > 0 {
|
|
comment := f.Comment.List[0].Text
|
|
if strings.HasPrefix(comment, "// '") && strings.HasSuffix(comment, "'") {
|
|
routerPrefixes[routerName] = comment[4 : len(comment)-1]
|
|
} else {
|
|
pass.Reportf(f.Comment.List[0].Pos(), "Comment for field %s is not formatted correctly\n", routerName)
|
|
}
|
|
} else {
|
|
pass.Reportf(f.Comment.Pos(), "Router field %s in Router struct is not commented properly\n", routerName)
|
|
}
|
|
}
|
|
}
|
|
|
|
// parseInitFunction checks a specific Init function for proper documentation of registered API handlers
|
|
func parseInitFunction(pass *analysis.Pass, decl *ast.FuncDecl, routerPrefixes map[string]string, initFunctions []string) []string {
|
|
for _, stmt := range decl.Body.List {
|
|
switch node := stmt.(type) {
|
|
case *ast.ExprStmt:
|
|
call, ok := node.X.(*ast.CallExpr)
|
|
if !ok {
|
|
continue
|
|
}
|
|
selector, ok := call.Fun.(*ast.SelectorExpr)
|
|
if !ok {
|
|
continue
|
|
}
|
|
ident, ok := selector.X.(*ast.Ident)
|
|
if ok && ident.Name == "api" {
|
|
initFunctions = append(initFunctions, selector.Sel.Name)
|
|
}
|
|
case *ast.AssignStmt:
|
|
if len(node.Lhs) != 1 || !strings.HasPrefix(formatNode(pass.Fset, node.Lhs[0]), "api.BaseRoutes") {
|
|
continue
|
|
}
|
|
subRouterName := formatNode(pass.Fset, node.Lhs[0])[15:]
|
|
if subRouterName == "APIRoot" || subRouterName == "APIRoot5" || subRouterName == "Root" {
|
|
continue
|
|
}
|
|
|
|
rhs := formatNode(pass.Fset, node.Rhs[0])[15:]
|
|
router := rhs[:strings.Index(rhs, ".")]
|
|
path := rhs[strings.Index(rhs, ".")+13 : strings.LastIndex(rhs, ".")-2]
|
|
prefix := ""
|
|
switch router {
|
|
case "APIRoot":
|
|
prefix = "api/v4"
|
|
case "Root":
|
|
prefix = ""
|
|
default:
|
|
if s, ok := routerPrefixes[router]; ok {
|
|
prefix = s
|
|
} else {
|
|
pass.Reportf(node.Rhs[0].Pos(), "cannot find prefix for %s\n", router)
|
|
}
|
|
}
|
|
s := fmt.Sprintf("%v%v", prefix, path)
|
|
s2 := routerPrefixes[subRouterName]
|
|
if s2 != s {
|
|
pass.Reportf(node.Rhs[0].Pos(), "PathPrefix doesn't match field comment for field '%s': '%s' vs '%s'\n", subRouterName, s, s2)
|
|
}
|
|
}
|
|
}
|
|
return initFunctions
|
|
}
|
|
|
|
func validateComments(pass *analysis.Pass) ([]string, map[string]string) {
|
|
initFunctions := []string{}
|
|
routerPrefixes := map[string]string{}
|
|
for _, file := range pass.Files {
|
|
ast.Inspect(file, func(n ast.Node) bool {
|
|
if decl, ok := n.(*ast.GenDecl); ok && len(decl.Specs) == 1 {
|
|
parseRoutesStruct(pass, decl, routerPrefixes)
|
|
} else if decl, ok := n.(*ast.FuncDecl); ok && decl.Name.Name == "Init" {
|
|
initFunctions = parseInitFunction(pass, decl, routerPrefixes, initFunctions)
|
|
}
|
|
return true
|
|
})
|
|
}
|
|
return initFunctions, routerPrefixes
|
|
}
|
|
|
|
func run(pass *analysis.Pass) (any, error) {
|
|
if specFile == "" {
|
|
return nil, errors.New("Please supply a path to OpenAPI spec yaml file via -openApiSync.spec")
|
|
}
|
|
if _, err := os.Stat(specFile); err != nil {
|
|
return nil, errors.Wrapf(err, "spec file does not exist")
|
|
}
|
|
data, err := os.ReadFile(specFile)
|
|
if err != nil {
|
|
return nil, errors.Wrapf(err, "Unable to read spec file")
|
|
}
|
|
doc, err := libopenapi.NewDocument(data)
|
|
if err != nil {
|
|
return nil, errors.Wrapf(err, "Unable to parse spec file. Expected OpenAPI3 format.")
|
|
}
|
|
model, err := doc.BuildV3Model()
|
|
if err != nil {
|
|
return nil, errors.Wrapf(err, "Unable to build OpenAPI3 model")
|
|
}
|
|
|
|
initFunctions, routerPrefixes := validateComments(pass)
|
|
|
|
var swaggerPaths []string
|
|
for p := range model.Model.Paths.PathItems.KeysFromOldest() {
|
|
swaggerPaths = append(swaggerPaths, p)
|
|
}
|
|
fuzzyModel := fuzzy.NewModel()
|
|
fuzzyModel.Train(swaggerPaths)
|
|
|
|
processRouterInit(pass, initFunctions, routerPrefixes, model.Model.Paths, fuzzyModel)
|
|
|
|
return nil, nil
|
|
}
|
|
|
|
func getMethodFromExpr(expr *ast.ExprStmt) string {
|
|
var method string
|
|
methodArg := expr.X.(*ast.CallExpr).Args[0]
|
|
|
|
if methodSelectorExpr, ok := methodArg.(*ast.SelectorExpr); ok {
|
|
if methodSelectorExpr.X.(*ast.Ident).Name == "http" {
|
|
switch methodSelectorExpr.Sel.Name {
|
|
case "MethodGet":
|
|
method = http.MethodGet
|
|
case "MethodHead":
|
|
method = http.MethodHead
|
|
case "MethodPost":
|
|
method = http.MethodPost
|
|
case "MethodPut":
|
|
method = http.MethodPut
|
|
case "MethodPatch":
|
|
method = http.MethodPatch
|
|
case "MethodDelete":
|
|
method = http.MethodDelete
|
|
case "MethodConnect":
|
|
method = http.MethodConnect
|
|
case "MethodOptions":
|
|
method = http.MethodOptions
|
|
case "MethodTrace":
|
|
method = http.MethodTrace
|
|
}
|
|
}
|
|
} else if methodBasicLit, ok := methodArg.(*ast.BasicLit); ok {
|
|
method, _ = strconv.Unquote(methodBasicLit.Value)
|
|
}
|
|
|
|
return method
|
|
}
|