Files
zeekayandHanzo Dev 404de134f3 Point the API spec and its route checker at the new base
The OpenAPI document keys every operation by its full path, so the spec
described endpoints that no longer exist the moment the server moved.

The openApiSync analyzer cross-checks each registered subrouter against
that document and reconstructs the prefix from a literal of its own, so
spec and analyzer have to move together or every route reports as
missing. Its special case for the v5 root goes away with the root
itself.

The api/v4 directory keeps its name: it is where the spec sources live,
not a URL anyone requests.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-14 10:58:08 -07:00

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}", "v1/team/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 == "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 = "v1/team"
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
}