The Prometheus monitoring system and time series database.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
prometheus/web/api/v1/openapi_coverage_test.go

258 lines
6.7 KiB

// Copyright The Prometheus Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package v1
import (
_ "embed"
"go/ast"
"go/parser"
"go/token"
"strconv"
"strings"
"testing"
v3 "github.com/pb33f/libopenapi/datamodel/high/v3"
"github.com/prometheus/common/promslog"
"github.com/stretchr/testify/require"
)
//go:embed api.go
var apiGoSource string
// routeInfo represents a route extracted from the Register function.
type routeInfo struct {
method string
path string
}
// extractRoutesFromRegister parses the api.go source and extracts all routes
// registered in the (*API) Register function using AST.
func extractRoutesFromRegister(t *testing.T, source string) []routeInfo {
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "api.go", source, parser.ParseComments)
require.NoError(t, err, "failed to parse api.go")
var registerFunc *ast.FuncDecl
// Find the Register method on *API.
ast.Inspect(f, func(n ast.Node) bool {
fn, ok := n.(*ast.FuncDecl)
if !ok || fn.Body == nil {
return true
}
if fn.Name.Name != "Register" {
return true
}
// Ensure it's a method on *API.
if fn.Recv == nil || len(fn.Recv.List) != 1 {
return true
}
star, ok := fn.Recv.List[0].Type.(*ast.StarExpr)
if !ok {
return true
}
ident, ok := star.X.(*ast.Ident)
if !ok || ident.Name != "API" {
return true
}
registerFunc = fn
return false // Stop walking once found.
})
require.NotNil(t, registerFunc, "Register method not found")
var routes []routeInfo
// Extract all r.Get, r.Post, r.Put, r.Delete, r.Options calls.
ast.Inspect(registerFunc.Body, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok {
return true
}
// Check if it's a router method call.
method := sel.Sel.Name
if method != "Get" && method != "Post" && method != "Put" && method != "Delete" && method != "Del" && method != "Options" {
return true
}
// Ensure the receiver is 'r'.
if x, ok := sel.X.(*ast.Ident); !ok || x.Name != "r" {
return true
}
if len(call.Args) == 0 {
return true
}
// Extract the path from the first argument.
lit, ok := call.Args[0].(*ast.BasicLit)
if !ok || lit.Kind != token.STRING {
return true
}
path, err := strconv.Unquote(lit.Value)
if err != nil {
return true
}
// Normalize Del to DELETE.
if method == "Del" {
method = "Delete"
}
routes = append(routes, routeInfo{
method: strings.ToUpper(method),
path: path,
})
return true
})
return routes
}
// normalizePathForOpenAPI converts route paths with colon parameters to OpenAPI format.
// e.g., "/label/:name/values" -> "/label/{name}/values".
func normalizePathForOpenAPI(path string) string {
// Replace :param with {param}.
parts := strings.Split(path, "/")
for i, part := range parts {
if trimmed, ok := strings.CutPrefix(part, ":"); ok {
parts[i] = "{" + trimmed + "}"
}
}
return strings.Join(parts, "/")
}
// TestOpenAPICoverage verifies that all routes registered in the Register function
// are documented in the OpenAPI specification.
func TestOpenAPICoverage(t *testing.T) {
// Extract routes from api.go using AST.
routes := extractRoutesFromRegister(t, apiGoSource)
require.NotEmpty(t, routes, "no routes found in Register function")
// Build OpenAPI spec.
builder := NewOpenAPIBuilder(OpenAPIOptions{}, promslog.NewNopLogger())
allPaths := builder.getAllPathDefinitions()
// Create a map of OpenAPI paths for quick lookup.
// Key is the normalized path, value is the PathItem.
openAPIPaths := make(map[string]bool)
for pair := allPaths.First(); pair != nil; pair = pair.Next() {
pathItem := pair.Value()
path := pair.Key()
// Track which methods are defined for this path.
if pathItem.Get != nil {
openAPIPaths[path+":GET"] = true
}
if pathItem.Post != nil {
openAPIPaths[path+":POST"] = true
}
if pathItem.Put != nil {
openAPIPaths[path+":PUT"] = true
}
if pathItem.Delete != nil {
openAPIPaths[path+":DELETE"] = true
}
if pathItem.Options != nil {
openAPIPaths[path+":OPTIONS"] = true
}
}
// Check coverage for each route.
var missingRoutes []string
ignoredRoutes := map[string]bool{
"/*path:OPTIONS": true, // Wildcard OPTIONS handler.
"/openapi.yaml:GET": true, // Self-referential endpoint.
"/notifications/live:GET": true, // SSE endpoint (version-specific).
}
for _, route := range routes {
normalizedPath := normalizePathForOpenAPI(route.path)
key := normalizedPath + ":" + route.method
// Skip ignored routes.
if ignoredRoutes[key] {
continue
}
if !openAPIPaths[key] {
missingRoutes = append(missingRoutes, key)
}
}
if len(missingRoutes) > 0 {
t.Errorf("The following routes are registered but not documented in OpenAPI spec:\n%s",
strings.Join(missingRoutes, "\n"))
}
}
// TestOpenAPIHasNoExtraRoutes verifies that the OpenAPI spec doesn't document
// routes that aren't actually registered.
func TestOpenAPIHasNoExtraRoutes(t *testing.T) {
// Extract routes from api.go using AST.
routes := extractRoutesFromRegister(t, apiGoSource)
require.NotEmpty(t, routes, "no routes found in Register function")
// Create a map of registered routes.
registeredRoutes := make(map[string]bool)
for _, route := range routes {
normalizedPath := normalizePathForOpenAPI(route.path)
key := normalizedPath + ":" + route.method
registeredRoutes[key] = true
}
// Build OpenAPI spec.
builder := NewOpenAPIBuilder(OpenAPIOptions{}, promslog.NewNopLogger())
allPaths := builder.getAllPathDefinitions()
// Check if any OpenAPI paths are not registered.
var extraRoutes []string
for pair := allPaths.First(); pair != nil; pair = pair.Next() {
pathItem := pair.Value()
path := pair.Key()
checkMethod := func(method string, op *v3.Operation) {
if op != nil {
key := path + ":" + method
if !registeredRoutes[key] {
extraRoutes = append(extraRoutes, key)
}
}
}
checkMethod("GET", pathItem.Get)
checkMethod("POST", pathItem.Post)
checkMethod("PUT", pathItem.Put)
checkMethod("DELETE", pathItem.Delete)
checkMethod("OPTIONS", pathItem.Options)
}
if len(extraRoutes) > 0 {
t.Errorf("The following routes are documented in OpenAPI but not registered:\n%s",
strings.Join(extraRoutes, "\n"))
}
}