mattermost/server/public/pluginapi/grpc/cmd/apiverify/main.go
Nick Misasi 54d0d6cc8b feat(02-01): add PluginAPI proto skeleton and parity verifier
- Create api.proto with PluginAPI service definition covering all 236
  methods from server/public/plugin/api.go
- Create api_user_team.proto with User, Session, and Team request/response
  messages with placeholder fields
- Create api_channel_post.proto with Channel, Post, and Emoji messages
- Create api_kv_config.proto with KV store, config, plugin, and logging
  messages
- Create api_file_bot.proto with File, Upload, and Bot messages
- Create api_remaining.proto with Server, Command, Preference, OAuth,
  Group, SharedChannel, Property, and Audit messages
- Add ViewUsersRestrictions to common.proto
- Add apiverify tool that parses Go API interface and proto service
  to ensure parity
- Update Makefile with new proto file mappings

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 11:24:09 -05:00

231 lines
5.7 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// Package main provides the apiverify tool which ensures that the gRPC PluginAPI
// service definition stays in sync with the Go plugin.API interface.
//
// Usage:
//
// go run ./server/public/pluginapi/grpc/cmd/apiverify
//
// Exit codes:
// - 0: All methods match
// - 1: Mismatch detected (missing or extra RPCs)
// - 2: Error parsing files
package main
import (
"bufio"
"fmt"
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
)
func main() {
// Find the repository root by looking for go.mod
repoRoot, err := findRepoRoot()
if err != nil {
fmt.Fprintf(os.Stderr, "Error finding repository root: %v\n", err)
os.Exit(2)
}
apiGoPath := filepath.Join(repoRoot, "server/public/plugin/api.go")
apiProtoPath := filepath.Join(repoRoot, "server/public/pluginapi/grpc/proto/api.proto")
// Parse Go API interface
goMethods, err := parseGoAPIInterface(apiGoPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Error parsing Go API interface: %v\n", err)
os.Exit(2)
}
// Parse proto service RPCs
protoRPCs, err := parseProtoServiceRPCs(apiProtoPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Error parsing proto service RPCs: %v\n", err)
os.Exit(2)
}
// Compare
missingInProto := difference(goMethods, protoRPCs)
extraInProto := difference(protoRPCs, goMethods)
if len(missingInProto) == 0 && len(extraInProto) == 0 {
fmt.Printf("OK: All %d API methods have corresponding RPCs in api.proto\n", len(goMethods))
os.Exit(0)
}
if len(missingInProto) > 0 {
fmt.Printf("MISSING in api.proto (%d methods):\n", len(missingInProto))
sort.Strings(missingInProto)
for _, m := range missingInProto {
fmt.Printf(" - %s\n", m)
}
}
if len(extraInProto) > 0 {
fmt.Printf("EXTRA in api.proto (not in plugin.API interface, %d RPCs):\n", len(extraInProto))
sort.Strings(extraInProto)
for _, m := range extraInProto {
fmt.Printf(" + %s\n", m)
}
}
fmt.Printf("\nSummary: Go API has %d methods, proto has %d RPCs\n", len(goMethods), len(protoRPCs))
os.Exit(1)
}
// findRepoRoot walks up from the current directory looking for the server/public/plugin/api.go file
// This handles both running from the repo root and from the server subdirectory.
func findRepoRoot() (string, error) {
dir, err := os.Getwd()
if err != nil {
return "", err
}
for {
// Check if server/public/plugin/api.go exists (we're at repo root)
if _, err := os.Stat(filepath.Join(dir, "server/public/plugin/api.go")); err == nil {
return dir, nil
}
// Check if public/plugin/api.go exists (we're in server directory)
if _, err := os.Stat(filepath.Join(dir, "public/plugin/api.go")); err == nil {
// Return parent to normalize paths
return filepath.Dir(dir), nil
}
parent := filepath.Dir(dir)
if parent == dir {
// Reached filesystem root without finding the file
return "", fmt.Errorf("could not find plugin/api.go in any parent directory")
}
dir = parent
}
}
// parseGoAPIInterface extracts method names from the API interface in api.go
func parseGoAPIInterface(path string) ([]string, error) {
fset := token.NewFileSet()
node, err := parser.ParseFile(fset, path, nil, parser.ParseComments)
if err != nil {
return nil, fmt.Errorf("failed to parse %s: %w", path, err)
}
var methods []string
ast.Inspect(node, func(n ast.Node) bool {
// Look for type declarations
typeSpec, ok := n.(*ast.TypeSpec)
if !ok {
return true
}
// Check if this is the API interface
if typeSpec.Name.Name != "API" {
return true
}
// Get the interface type
iface, ok := typeSpec.Type.(*ast.InterfaceType)
if !ok {
return true
}
// Extract method names
for _, method := range iface.Methods.List {
// Each method has exactly one name
if len(method.Names) == 1 {
methods = append(methods, method.Names[0].Name)
}
}
return false // Found the API interface, no need to continue
})
if len(methods) == 0 {
return nil, fmt.Errorf("no methods found in API interface in %s", path)
}
return methods, nil
}
// parseProtoServiceRPCs extracts RPC names from the PluginAPI service in api.proto
func parseProtoServiceRPCs(path string) ([]string, error) {
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("failed to open %s: %w", path, err)
}
defer file.Close()
var rpcs []string
inService := false
// Match "service PluginAPI {"
serviceStartRe := regexp.MustCompile(`^\s*service\s+PluginAPI\s*\{`)
// Match "rpc MethodName(RequestType) returns (ResponseType);"
rpcRe := regexp.MustCompile(`^\s*rpc\s+(\w+)\s*\(`)
// Match closing brace
closeBraceRe := regexp.MustCompile(`^\s*\}`)
braceCount := 0
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
if !inService {
if serviceStartRe.MatchString(line) {
inService = true
braceCount = 1
}
continue
}
// Track nested braces
braceCount += strings.Count(line, "{")
braceCount -= strings.Count(line, "}")
if braceCount <= 0 {
break // End of service definition
}
// Look for RPC definitions
if matches := rpcRe.FindStringSubmatch(line); matches != nil {
rpcs = append(rpcs, matches[1])
}
// Also handle closing brace on its own line
if closeBraceRe.MatchString(line) && braceCount <= 0 {
break
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("error reading %s: %w", path, err)
}
return rpcs, nil
}
// difference returns elements in a that are not in b
func difference(a, b []string) []string {
bSet := make(map[string]bool)
for _, x := range b {
bSet[x] = true
}
var diff []string
for _, x := range a {
if !bSet[x] {
diff = append(diff, x)
}
}
return diff
}