Adding a REST endpoint
The aveloxis REST API runs as a separate process on :8383 and is consumed by the web GUI’s Chart.js panels and any external client. Adding an endpoint is a thin task — most of the work is shaping the query and the response.
Where the API lives
internal/api/
├── server.go # Mux + route registration + handler signatures
├── metrics.go # /api/v1/repos/{id}/stats and similar count endpoints
├── server_test.go # End-to-end handler tests via httptest
├── cors_test.go # CORS preflight + Access-Control-* header pins
├── metrics_test.go # Per-handler behavioral tests
├── scancode_files_test.go
└── scancode_freshness_test.go
The server uses Go 1.22+ ServeMux pattern matching ({repoID} path params). It’s intentionally plain — no router library, no middleware stack beyond CORS.
Existing endpoints (as of v0.23.5)
GET /api/v1/health
GET /api/v1/repos/{repoID}/stats
GET /api/v1/repos/stats?ids=1,2,3 (batch)
GET /api/v1/repos/{repoID}/sbom?format=...
GET /api/v1/repos/{repoID}/timeseries?since=...
GET /api/v1/repos/{repoID}/licenses
GET /api/v1/repos/{repoID}/scancode-licenses
GET /api/v1/repos/{repoID}/scancode-files
GET /api/v1/repos/search?q=...
The pattern: path-versioned (/api/v1/), resource-noun, optional path params + query params.
Walkthrough: add GET /api/v1/repos/{repoID}/contributors
We’ll add an endpoint that returns the top-N contributors for a repo, ranked by commit count. This is a representative example.
Step 1 — write the failing test
// internal/api/contributors_test.go
// SPDX-FileCopyrightText: 2026 Sean Goggins, University of Missouri, Derek Howard
// SPDX-License-Identifier: MIT
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestRepoContributorsHandler(t *testing.T) {
srv := newTestServer(t)
defer srv.Close()
resp, err := http.Get(srv.URL + "/api/v1/repos/1/contributors?limit=5")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("expected 200, got %d", resp.StatusCode)
}
var body struct {
Contributors []struct {
Login string `json:"login"`
Commits int `json:"commits"`
Email string `json:"email"`
} `json:"contributors"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
// Test fixtures set up by newTestServer should produce specific
// contributors — assert on them.
}
func TestRepoContributorsHandlerRejectsInvalidRepoID(t *testing.T) {
srv := newTestServer(t)
defer srv.Close()
resp, _ := http.Get(srv.URL + "/api/v1/repos/notanumber/contributors")
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("expected 400 for non-numeric repoID, got %d", resp.StatusCode)
}
}
func TestRepoContributorsHandlerLimitDefaults(t *testing.T) {
srv := newTestServer(t)
defer srv.Close()
resp, _ := http.Get(srv.URL + "/api/v1/repos/1/contributors") // no limit
if resp.StatusCode != http.StatusOK {
t.Errorf("expected default limit to succeed, got %d", resp.StatusCode)
}
}
Run it — it’ll fail because the route doesn’t exist.
Step 2 — add the store method
The handler should be a thin shell. Put the SQL in internal/db/:
// internal/db/repo_contributors.go
// SPDX-FileCopyrightText: 2026 Sean Goggins, University of Missouri, Derek Howard
// SPDX-License-Identifier: MIT
package db
import (
"context"
"fmt"
)
// RepoContributor is the per-row return type for GetRepoContributors.
type RepoContributor struct {
Login string `json:"login"`
Email string `json:"email"`
Commits int `json:"commits"`
}
// GetRepoContributors returns up to `limit` contributors ranked by
// commit count for the given repo. Returns an empty slice (no error)
// when the repo has no commits yet.
func (s *PostgresStore) GetRepoContributors(ctx context.Context, repoID int64, limit int) ([]RepoContributor, error) {
if limit <= 0 {
limit = 25 // default
}
if limit > 500 {
limit = 500 // cap
}
rows, err := s.pool.Query(ctx, `
SELECT
c.gh_login,
COALESCE(c.cntrb_canonical, c.cntrb_email, '') AS email,
COUNT(DISTINCT cm.cmt_commit_hash) AS commits
FROM aveloxis_data.commits cm
JOIN aveloxis_data.contributors c ON cm.cmt_ght_author_id = c.cntrb_id
WHERE cm.repo_id = $1 AND COALESCE(c.cntrb_deleted, 0) = 0
GROUP BY c.cntrb_id, c.gh_login, c.cntrb_canonical, c.cntrb_email
ORDER BY commits DESC, c.gh_login ASC
LIMIT $2`, repoID, limit)
if err != nil {
return nil, fmt.Errorf("query repo contributors: %w", err)
}
defer rows.Close()
var out []RepoContributor
for rows.Next() {
var rc RepoContributor
if err := rows.Scan(&rc.Login, &rc.Email, &rc.Commits); err != nil {
return nil, fmt.Errorf("scan: %w", err)
}
out = append(out, rc)
}
return out, rows.Err()
}
Step 3 — add the handler
// internal/api/contributors.go
// SPDX-FileCopyrightText: 2026 Sean Goggins, University of Missouri, Derek Howard
// SPDX-License-Identifier: MIT
package api
import (
"encoding/json"
"net/http"
"strconv"
)
func (s *Server) handleRepoContributors(w http.ResponseWriter, r *http.Request) {
repoID, err := strconv.ParseInt(r.PathValue("repoID"), 10, 64)
if err != nil {
http.Error(w, "invalid repoID", http.StatusBadRequest)
return
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
contributors, err := s.store.GetRepoContributors(r.Context(), repoID, limit)
if err != nil {
s.logger.Warn("GetRepoContributors failed",
"repo_id", repoID, "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"repo_id": repoID,
"contributors": contributors,
})
}
Step 4 — register the route
// internal/api/server.go — inside NewServer / registerRoutes
s.mux.HandleFunc("GET /api/v1/repos/{repoID}/contributors", s.handleRepoContributors)
Step 5 — run the tests
go test ./internal/api/ -run TestRepoContributors -v
Iterate until green.
Step 6 — bump version, document
Bump internal/db/version.go. Add a changelog entry in CLAUDE.md. Update docs/guide/api.md with the new endpoint signature and response shape.
Patterns to follow
Path parameters via r.PathValue("name")
Go 1.22+ ServeMux pattern matching. Mux registration uses {name} placeholders; the handler reads via r.PathValue("name"). Always parse + validate immediately and return 400 on parse failure.
Query params via r.URL.Query().Get
Don’t bind query params via reflection / a library. Read them directly. Validate. Apply defaults and caps.
Errors
400 for client errors (invalid input).
404 for resources that don’t exist (use the existing patterns; look at
handleRepoStatsfor the pattern of distinguishing “repo not found” from “repo has no data yet”).500 for server-side errors. Log the actual error; return a generic message to the client.
Never leak SQL errors or stack traces to the client.
Response shape
JSON. Wrap collections in a named field:
{"contributors": [...], "repo_id": 123}
Not just [...] — that locks you in if you ever need to add pagination metadata.
For paginated endpoints, follow the existing ListQueuePage shape:
{"items": [...], "total": 1234, "page": 1, "page_size": 100}
CORS
The existing server.go enables CORS via middleware. Don’t disable it. The web GUI’s Chart.js fetches go through CORS — without it, charts silently fail.
Caching
If the underlying query is expensive (full table scan, complex aggregate), consider adding a process-level cache like internal/monitor/queue_stats_cache.go. 60-second TTL + stale-on-error is the established pattern. See v0.18.30 for the rationale.
Logging
s.logger.Warn on errors. Include repo_id and any other context. NEVER log the API key or session token — those don’t appear on REST endpoints anyway since the API is currently unauthenticated, but be careful if you add auth.
What NOT to do
Don’t add middleware libraries. Stdlib net/http is sufficient. Aveloxis has resisted gin/echo/chi for years and isn’t adding them now.
Don’t write SQL in the handler. Put it in
internal/db/. Handlers are thin shells.Don’t change response shape of existing endpoints. The web GUI templates and external consumers pin them. Add new endpoints; don’t repurpose old ones.
Don’t add authentication ad hoc. The current REST API is intentionally unauthenticated (read-only, local-network). If you need auth, design it across all endpoints with the maintainers; don’t slip it into one handler.
Wiring to the web GUI
If your endpoint feeds a chart or panel, that’s a separate task — see adding-a-visualization.md.
The web GUI consumes the REST API via fetch() in the templates. The base URL is hardcoded to http://localhost:8383 in the templates currently (one of the known-issues items in the CLAUDE.md status section). When you add an endpoint, you’ll likely also update a template under internal/web/templates.go to call it.