Skip to content

feat(api): Describe the API from the code and serve the description - #207

Open
nfebe wants to merge 5 commits into
mainfrom
feat/openapi-spec
Open

feat(api): Describe the API from the code and serve the description#207
nfebe wants to merge 5 commits into
mainfrom
feat/openapi-spec

Conversation

@nfebe

@nfebe nfebe commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Anything talking to the agent had to read the agent's source to learn what an endpoint accepts. The
CLI's generated commands take arbitrary key=value fields for exactly that reason, and a typo comes
back as a 400 naming nothing.

The agent now describes itself in OpenAPI, read out of the code by tools/genspec rather than
annotated, so it cannot claim something the code does not do:

go run ./tools/genspec -o internal/api/openapi.json

Served at GET /api/openapi.json, so a client asks the instance it is connected to instead of
assuming whatever was true when the client was built.

What it carries:

  • 294 endpoints, with their path and query parameters
  • 91 of 172 writes with a described body, from the type each handler binds
  • 260 with the permission they are gated on
  • 20 responses, the thin part, because 968 handlers answer with gin.H against 33 typed

Deployments, backups and certificates are converted here and name their table columns on the type,
which is what lets a client lay out results without being taught each endpoint. The rest stay
undescribed until their handlers return types too.

Three tests guard it: the description is regenerated and compared, so it fails the build when it
drifts from the routes; every reference must resolve and every operation must be identified; and the
fields a handler requires must arrive as required.

Anything talking to the agent had to learn what an endpoint accepts by reading the agent's source,
so a client could only guess at field names and find out it was wrong from a 400. The CLI's
generated commands take arbitrary key=value fields for exactly this reason.

The agent now describes itself: routes, path and query parameters, the body each handler binds,
the permission each is gated on, and the response where a handler returns a type rather than an
inline map. It is read out of the code, so it cannot claim something the code does not do, and a
test fails the build if it drifts from the routes.

The description is served, so a client asks the instance it is connected to rather than assuming
whatever was true when the client was built.

Deployments, backups and certificates now answer with declared types, and the fields worth showing
as a table are named on those types. A client can lay out results without being taught each
endpoint by hand. The rest still answer with maps and remain undescribed until they are converted.
@sourceant

sourceant Bot commented Aug 13, 2026

Copy link
Copy Markdown

Code Review Summary

This PR introduces an automated OpenAPI specification generator (tools/genspec) and standardizes API responses using a generic List[T] wrapper. It also embeds and serves the generated spec at /api/openapi.json.

🚀 Key Improvements

  • Standardized list response shape with pagination support via List[T] in internal/api/render.go.
  • Automated documentation generation that reads from source code rather than manual annotations.
  • Embedded OpenAPI spec served directly by the agent, ensuring clients can always discover the actual capabilities of the instance they are connected to.

💡 Minor Suggestions

  • The isScalar check in schema.go determines which fields are shown as 'columns' in the CLI. It might be useful to exclude sensitive fields like password from this list by default.

🚨 Critical Issues

  • Handler collision risk: The tool indexes handlers by method name in a flat map, leading to spec corruption if multiple structs have the same method names.
  • False positives in parameter scanning: The tool incorrectly identifies database Query calls as API query parameters because it doesn't check the receiver type.

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review complete. See the overview comment for a summary.

Comment thread tools/genspec/main.go Outdated
Comment thread tools/genspec/main.go Outdated
Comment thread tools/genspec/main.go Outdated
Comment thread tools/genspec/schema.go
A generated description can be current and still be unusable: a reference pointing at nothing, an
operation without an identifier, a path a client cannot reach. Each of those parses as JSON and
breaks whoever trusts it.

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review complete. See the overview comment for a summary.

Comment thread tools/genspec/main.go

// queryParams are the query keys a handler reads, which is what makes them checkable by a caller
// rather than something to be discovered by trial.
func queryParams(api *packages.Package, fn *ast.FuncDecl) []string {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current query parameter extraction logic misses several common Gin methods like GetQuery. Including GetQuery would improve the completeness of the generated specification.

Suggested change
func queryParams(api *packages.Package, fn *ast.FuncDecl) []string {
func queryParams(api *packages.Package, fn *ast.FuncDecl) []string {
seen := map[string]bool{}
var names []string
ast.Inspect(fn, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok || len(call.Args) == 0 {
return true
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok {
return true
}
name := sel.Sel.Name
if name != "Query" && name != "DefaultQuery" && name != "GetQuery" {
return true
}
lit, ok := call.Args[0].(*ast.BasicLit)
if !ok {
return true
}
paramName, err := strconv.Unquote(lit.Value)
if err != nil || seen[paramName] {
return true
}
seen[paramName] = true
names = append(names, paramName)
return true
})
sort.Strings(names)
return names
}

Comment thread tools/genspec/main.go
A response type named after its resource tells a client nothing it did not already know, so each
one had to be learned separately. A collection is a collection whatever it holds: there is now one
shape for a list, one for a single thing, and one for a report of what happened, and the generated
description says which an endpoint answers in.

A client renders any collection the same way, and an endpoint converted tomorrow renders without a
line of client code. The fields worth showing as columns are every scalar the row declares, in the
order it declares them, rather than a list repeated on each type; a field that is never worth a
column says so once.

Collections keep answering under their old name alongside the new one, so nothing reading the
current shape breaks while it moves.
@nfebe
nfebe force-pushed the feat/openapi-spec branch from 87ded86 to c6350d4 Compare August 13, 2026 22:25

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review complete. See the overview comment for a summary.

Comment thread internal/api/render.go Outdated
Comment on lines +22 to +23
// where every collection ends up.
func NewList[T any](items []T, legacy string) List[T] {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current NewList implementation hardcodes Total to len(items). This is semantically incorrect for paginated endpoints where items is just one page of a larger collection. In such cases, the client needs the actual total count to handle pagination UI (e.g., 'Showing 1-10 of 100'). The List struct and NewList constructor should allow providing an explicit total.

Suggested change
// where every collection ends up.
func NewList[T any](items []T, legacy string) List[T] {
func NewList[T any](items []T, legacy string) List[T] {
return NewListWithTotal(items, len(items), legacy)
}
// NewListWithTotal creates a list response with an explicit total count for pagination.
func NewListWithTotal[T any](items []T, total int, legacy string) List[T] {
if items == nil {
items = []T{}
}
return List[T]{Items: items, Total: total, legacy: legacy}
}

Comment thread internal/api/server.go
"deployments": deployments,
"path": s.manager.BasePath(),
})
c.JSON(http.StatusOK, NewList(deployments, "deployments"))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change removes the path field (returned by s.manager.BasePath()) from the response. This is a breaking change for clients relying on knowing the agent's base deployment directory. Since the generic List shape cannot carry extra top-level fields, use an anonymous struct to preserve compatibility while still providing the standard list fields for the generator.

Suggested change
c.JSON(http.StatusOK, NewList(deployments, "deployments"))
c.JSON(http.StatusOK, struct {
List[models.Deployment]
Path string `json:"path"`
}{
List: NewList(deployments, "deployments"),
Path: s.manager.BasePath(),
})

Comment thread tools/genspec/main.go Outdated
if !ok {
return false
}
return named.Obj().Name() == "H" && strings.HasSuffix(named.Obj().Pkg().Path(), "gin")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential nil pointer dereference: named.Obj().Pkg() can be nil for types in the universe scope (like error). Always check for nil before calling .Path().

Suggested change
return named.Obj().Name() == "H" && strings.HasSuffix(named.Obj().Pkg().Path(), "gin")
pkg := named.Obj().Pkg()
return named.Obj().Name() == "H" && pkg != nil && strings.HasSuffix(pkg.Path(), "gin")

Comment thread tools/genspec/main.go
return found
}

func isStatusOK(expr ast.Expr) bool {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The generator currently only recognizes types returned with http.StatusOK. Handlers using 201 Created or 202 Accepted (like createBackup) will have their response bodies omitted from the spec. Rename and expand this check to cover other success status codes.

Suggested change
func isStatusOK(expr ast.Expr) bool {
func isSuccessStatus(expr ast.Expr) bool {
sel, ok := expr.(*ast.SelectorExpr)
if !ok {
return false
}
return strings.HasPrefix(sel.Sel.Name, "Status") && (strings.Contains(sel.Sel.Name, "OK") || strings.Contains(sel.Sel.Name, "Created") || strings.Contains(sel.Sel.Name, "Accepted"))
}

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review complete. See the overview comment for a summary.

Comment thread internal/api/server.go
"deployments": deployments,
"path": s.manager.BasePath(),
})
c.JSON(http.StatusOK, NewList(deployments, "deployments"))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Standardizing the response with NewList has removed the path field (s.manager.BasePath()) that was previously returned by GET /api/deployments. If any CLI or UI clients rely on this field to locate the deployment directory on the host, this change will break them. Consider if this metadata should be moved to a separate field or if the List type needs to support custom metadata.

Suggested change
c.JSON(http.StatusOK, NewList(deployments, "deployments"))
c.JSON(http.StatusOK, struct {
List[models.Deployment]
Path string `json:"path"`
}{
List: NewList(deployments, "deployments"),
Path: s.manager.BasePath(),
})

Comment thread tools/genspec/main.go
return found
}

func isStatusOK(expr ast.Expr) bool {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current check only captures http.StatusOK (200). Many write operations in this API use http.StatusAccepted (202) or http.StatusCreated (201) (e.g., createBackup). Restricting this to StatusOK prevents the generator from extracting and documenting the response body schemas for those endpoints. Expanding this to cover standard success codes would improve documentation coverage.

Suggested change
func isStatusOK(expr ast.Expr) bool {
func isSuccessStatus(api *packages.Package, expr ast.Expr) bool {
sel, ok := expr.(*ast.SelectorExpr)
if !ok {
return false
}
x, ok := sel.X.(*ast.Ident)
if !ok {
return false
}
// Verify it is the net/http package
if obj := api.TypesInfo.ObjectOf(x); obj == nil || obj.Pkg() == nil || obj.Pkg().Path() != "net/http" {
return false
}
switch sel.Sel.Name {
case "StatusOK", "StatusCreated", "StatusAccepted", "StatusNoContent":
return true
}
return false
}

Comment thread tools/genspec/main.go
}

// groupPrefix is what each router group prepends to the paths registered on it.
var groupPrefix = map[string]string{

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The groupPrefix map is a manual lookup that must be updated whenever a new Gin router group is introduced. This creates a risk of silent failures where new endpoints are omitted from the documentation. Since the tool already loads the API package syntax, it would be more robust to dynamically discover these groups and their associated prefixes by tracing Group() calls in the AST.

Suggested change
var groupPrefix = map[string]string{
// Consider replacing this static map with dynamic discovery by scanning the AST for Group() calls
// and identifying the variable names assigned to each prefix.

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review complete. See the overview comment for a summary.

Comment thread tools/genspec/main.go
if m := permPattern.FindStringSubmatch(rest); m != nil {
r.Permission = m[1]
}
if m := handlerPattern.FindStringSubmatch(strings.TrimSpace(rest)); m != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current regex-based extraction of handlers will fail if the route registration line includes a trailing comment (e.g., api.GET("/path", s.handler) // comment), because the $ anchor in handlerPattern prevents matching anything after the closing parenthesis. Stripping comments before matching ensures handlers are correctly identified.

Suggested change
if m := handlerPattern.FindStringSubmatch(strings.TrimSpace(rest)); m != nil {
if i := strings.Index(line, "//"); i != -1 { line = line[:i] }
if m := handlerPattern.FindStringSubmatch(strings.TrimSpace(rest)); m != nil {

Comment thread tools/genspec/main.go
if len(call.Args) != 1 {
return true
}
unary, ok := call.Args[0].(*ast.UnaryExpr)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Restricting the extraction to *ast.UnaryExpr (like &req) misses cases where a pointer variable is passed directly to the binding method (e.g., req := &MyRequest{}; c.ShouldBindJSON(req)). Evaluating the type of the argument expression directly is more robust as the schema generator already handles pointer resolution.

Suggested change
unary, ok := call.Args[0].(*ast.UnaryExpr)
if t := api.TypesInfo.TypeOf(call.Args[0]); t != nil {
found = t
}

Comment thread tools/genspec/main.go
return true
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok || sel.Sel.Name != "JSON" {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The generator currently only picks up standard JSON calls. Supporting other Gin response methods like AbortWithStatusJSON, PureJSON, or IndentedJSON will improve documentation completeness for endpoints that use these variations.

Suggested change
if !ok || sel.Sel.Name != "JSON" {
if !ok || (sel.Sel.Name != "JSON" && sel.Sel.Name != "AbortWithStatusJSON" && sel.Sel.Name != "PureJSON" && sel.Sel.Name != "IndentedJSON") {

Two endpoints' permissions were being invented from the name of a constant rather than read from
it, so the ones with an acronym came out mangled and no client could match them against a key.
They are now read from the value the agent itself checks.

Nine paths kept the router's wildcard notation, which OpenAPI has no notion of, so they named no
path a client could build and left the segment carrying the filename undescribed.

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review complete. See the overview comment for a summary.

Comment thread tools/genspec/main.go
pkg *packages.Package
}

func indexHandlers(pkgs []*packages.Package) map[string]*handler {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indexing handlers using only their method names in a flat map causes collisions when different structs (e.g., Server and ClusterManager) define methods with the same name (like List or Delete). Since readRoutes currently only extracts the method name, one documentation schema will overwrite the other in the spec. Handlers should be indexed by a fully qualified key (ReceiverType + MethodName).

Suggested change
func indexHandlers(pkgs []*packages.Package) map[string]*handler {
func indexHandlers(pkgs []*packages.Package) map[string]*handler {
handlers := map[string]*handler{}
for _, pkg := range pkgs {
for _, file := range pkg.Syntax {
for _, decl := range file.Decls {
fn, ok := decl.(*ast.FuncDecl)
if !ok || fn.Recv == nil || len(fn.Recv.List) == 0 {
continue
}
t := pkg.TypesInfo.TypeOf(fn.Recv.List[0].Type)
if t == nil {
continue
}
// Index by ReceiverType.MethodName to avoid collisions
key := strings.TrimPrefix(t.String(), "*") + "." + fn.Name.Name
handlers[key] = &handler{decl: fn, pkg: pkg}
}
}
}
return handlers
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant