feat: migrate receiver to Fuego framework with OpenAPI generation
Replace net/http handlers with Fuego framework for automatic OpenAPI 3.0 spec generation. Add generated Go client package, OpenAPI extraction script, and update Makefile with separate build/run targets for both binaries. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
479c13f596
commit
bc9d0dd8ea
11 changed files with 2245 additions and 252 deletions
|
|
@ -14,6 +14,8 @@ import (
|
|||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-fuego/fuego"
|
||||
|
||||
"edp.buildth.ing/DevFW-CICD/forgejo-runner-sizer/internal/receiver"
|
||||
"edp.buildth.ing/DevFW-CICD/forgejo-runner-sizer/internal/summary"
|
||||
)
|
||||
|
|
@ -33,10 +35,17 @@ func setupTestReceiver(t *testing.T) (*receiver.Store, *httptest.Server, func())
|
|||
}
|
||||
|
||||
handler := receiver.NewHandler(store, slog.New(slog.NewTextHandler(io.Discard, nil)), testReadToken, testHMACKey, 0)
|
||||
mux := http.NewServeMux()
|
||||
handler.RegisterRoutes(mux)
|
||||
s := fuego.NewServer(
|
||||
fuego.WithoutStartupMessages(),
|
||||
fuego.WithEngineOptions(
|
||||
fuego.WithOpenAPIConfig(fuego.OpenAPIConfig{
|
||||
Disabled: true,
|
||||
}),
|
||||
),
|
||||
)
|
||||
handler.RegisterRoutes(s)
|
||||
|
||||
server := httptest.NewServer(mux)
|
||||
server := httptest.NewServer(s.Mux)
|
||||
|
||||
cleanup := func() {
|
||||
server.Close()
|
||||
|
|
@ -372,10 +381,17 @@ func setupTestReceiverWithToken(t *testing.T, readToken, hmacKey string) (*recei
|
|||
}
|
||||
|
||||
handler := receiver.NewHandler(store, slog.New(slog.NewTextHandler(io.Discard, nil)), readToken, hmacKey, 0)
|
||||
mux := http.NewServeMux()
|
||||
handler.RegisterRoutes(mux)
|
||||
s := fuego.NewServer(
|
||||
fuego.WithoutStartupMessages(),
|
||||
fuego.WithEngineOptions(
|
||||
fuego.WithOpenAPIConfig(fuego.OpenAPIConfig{
|
||||
Disabled: true,
|
||||
}),
|
||||
),
|
||||
)
|
||||
handler.RegisterRoutes(s)
|
||||
|
||||
server := httptest.NewServer(mux)
|
||||
server := httptest.NewServer(s.Mux)
|
||||
|
||||
cleanup := func() {
|
||||
server.Close()
|
||||
|
|
|
|||
|
|
@ -1,15 +1,18 @@
|
|||
// ABOUTME: HTTP handlers for the metrics receiver service.
|
||||
// ABOUTME: Provides endpoints for receiving and querying metrics.
|
||||
// ABOUTME: HTTP handlers for the metrics receiver service using Fuego framework.
|
||||
// ABOUTME: Provides endpoints for receiving and querying metrics with automatic OpenAPI generation.
|
||||
package receiver
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-fuego/fuego"
|
||||
)
|
||||
|
||||
// Handler handles HTTP requests for the metrics receiver
|
||||
|
|
@ -32,128 +35,175 @@ func NewHandler(store *Store, logger *slog.Logger, readToken, hmacKey string, to
|
|||
return &Handler{store: store, logger: logger, readToken: readToken, hmacKey: hmacKey, tokenTTL: tokenTTL}
|
||||
}
|
||||
|
||||
// RegisterRoutes registers all HTTP routes on the given mux
|
||||
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("POST /api/v1/metrics", h.handleReceiveMetrics)
|
||||
mux.HandleFunc("POST /api/v1/token", h.handleGenerateToken)
|
||||
mux.HandleFunc("GET /api/v1/metrics/repo/{org}/{repo}/{workflow}/{job}", h.handleGetByWorkflowJob)
|
||||
mux.HandleFunc("GET /api/v1/sizing/repo/{org}/{repo}/{workflow}/{job}", h.handleGetSizing)
|
||||
mux.HandleFunc("GET /health", h.handleHealth)
|
||||
// Common errors
|
||||
var (
|
||||
ErrUnauthorized = errors.New("authorization required")
|
||||
ErrInvalidToken = errors.New("invalid token")
|
||||
ErrInvalidFormat = errors.New("invalid authorization format")
|
||||
ErrMissingHMACKey = errors.New("token generation requires a configured HMAC key")
|
||||
ErrMissingFields = errors.New("organization, repository, workflow, and job are required")
|
||||
ErrMissingRunID = errors.New("run_id is required")
|
||||
ErrInvalidParams = errors.New("org, repo, workflow and job are required")
|
||||
ErrNoMetrics = errors.New("no metrics found for this workflow/job")
|
||||
ErrInvalidPercent = errors.New("invalid cpu_percentile: must be one of peak, p99, p95, p75, p50, avg")
|
||||
)
|
||||
|
||||
// HealthResponse is the response for the health endpoint
|
||||
type HealthResponse struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// validateReadToken checks the Authorization header for a valid Bearer token.
|
||||
func (h *Handler) validateReadToken(w http.ResponseWriter, r *http.Request) bool {
|
||||
if h.readToken == "" {
|
||||
h.logger.Warn("no read-token configured, rejecting request", slog.String("path", r.URL.Path))
|
||||
http.Error(w, "authorization required", http.StatusUnauthorized)
|
||||
return false
|
||||
}
|
||||
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
h.logger.Warn("missing authorization header", slog.String("path", r.URL.Path))
|
||||
http.Error(w, "authorization required", http.StatusUnauthorized)
|
||||
return false
|
||||
}
|
||||
|
||||
const bearerPrefix = "Bearer "
|
||||
if !strings.HasPrefix(authHeader, bearerPrefix) {
|
||||
h.logger.Warn("invalid authorization format", slog.String("path", r.URL.Path))
|
||||
http.Error(w, "invalid authorization format", http.StatusUnauthorized)
|
||||
return false
|
||||
}
|
||||
|
||||
token := strings.TrimPrefix(authHeader, bearerPrefix)
|
||||
if subtle.ConstantTimeCompare([]byte(token), []byte(h.readToken)) != 1 {
|
||||
h.logger.Warn("invalid token", slog.String("path", r.URL.Path))
|
||||
http.Error(w, "invalid token", http.StatusUnauthorized)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
// MetricCreatedResponse is the response when a metric is successfully created
|
||||
type MetricCreatedResponse struct {
|
||||
ID uint `json:"id"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
func (h *Handler) handleGenerateToken(w http.ResponseWriter, r *http.Request) {
|
||||
if h.hmacKey == "" {
|
||||
http.Error(w, "token generation requires a configured HMAC key", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if !h.validateReadToken(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
var req TokenRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid JSON body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Organization == "" || req.Repository == "" || req.Workflow == "" || req.Job == "" {
|
||||
http.Error(w, "organization, repository, workflow, and job are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
token := GenerateToken(h.hmacKey, req.Organization, req.Repository, req.Workflow, req.Job)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(TokenResponse{Token: token})
|
||||
// GetMetricsRequest contains path parameters for getting metrics
|
||||
type GetMetricsRequest struct {
|
||||
Org string `path:"org"`
|
||||
Repo string `path:"repo"`
|
||||
Workflow string `path:"workflow"`
|
||||
Job string `path:"job"`
|
||||
}
|
||||
|
||||
// validatePushToken checks push authentication via scoped HMAC token.
|
||||
func (h *Handler) validatePushToken(w http.ResponseWriter, r *http.Request, exec ExecutionContext) bool {
|
||||
// GetSizingRequest contains path and query parameters for sizing endpoint
|
||||
type GetSizingRequest struct {
|
||||
Org string `path:"org"`
|
||||
Repo string `path:"repo"`
|
||||
Workflow string `path:"workflow"`
|
||||
Job string `path:"job"`
|
||||
Runs int `query:"runs" default:"5" validate:"min=1,max=100" description:"Number of recent runs to analyze"`
|
||||
Buffer int `query:"buffer" default:"20" validate:"min=0,max=100" description:"Buffer percentage to add"`
|
||||
CPUPercentile string `query:"cpu_percentile" default:"p95" description:"CPU percentile to use (peak, p99, p95, p75, p50, avg)"`
|
||||
}
|
||||
|
||||
// RegisterRoutes registers all HTTP routes on the Fuego server
|
||||
func (h *Handler) RegisterRoutes(s *fuego.Server) {
|
||||
// Health endpoint (no auth)
|
||||
fuego.Get(s, "/health", h.Health)
|
||||
|
||||
// API group with authentication
|
||||
api := fuego.Group(s, "/api/v1")
|
||||
|
||||
// Token generation (requires read token)
|
||||
fuego.Post(api, "/token", h.GenerateToken, fuego.OptionMiddleware(h.requireReadToken))
|
||||
|
||||
// Metrics endpoints
|
||||
fuego.Post(api, "/metrics", h.ReceiveMetrics) // Uses push token validated in handler
|
||||
fuego.Get(api, "/metrics/repo/{org}/{repo}/{workflow}/{job}", h.GetMetricsByWorkflowJob, fuego.OptionMiddleware(h.requireReadToken))
|
||||
|
||||
// Sizing endpoint
|
||||
fuego.Get(api, "/sizing/repo/{org}/{repo}/{workflow}/{job}", h.GetSizing, fuego.OptionMiddleware(h.requireReadToken))
|
||||
}
|
||||
|
||||
// requireReadToken is middleware that validates the read token
|
||||
func (h *Handler) requireReadToken(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if h.readToken == "" {
|
||||
h.logger.Warn("no read-token configured, rejecting request", slog.String("path", r.URL.Path))
|
||||
http.Error(w, "authorization required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
h.logger.Warn("missing authorization header", slog.String("path", r.URL.Path))
|
||||
http.Error(w, "authorization required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
const bearerPrefix = "Bearer "
|
||||
if !strings.HasPrefix(authHeader, bearerPrefix) {
|
||||
h.logger.Warn("invalid authorization format", slog.String("path", r.URL.Path))
|
||||
http.Error(w, "invalid authorization format", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
token := strings.TrimPrefix(authHeader, bearerPrefix)
|
||||
if subtle.ConstantTimeCompare([]byte(token), []byte(h.readToken)) != 1 {
|
||||
h.logger.Warn("invalid token", slog.String("path", r.URL.Path))
|
||||
http.Error(w, "invalid token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// validatePushToken checks push authentication via scoped HMAC token
|
||||
func (h *Handler) validatePushToken(r *http.Request, exec ExecutionContext) error {
|
||||
if h.hmacKey == "" {
|
||||
h.logger.Warn("no HMAC key configured, rejecting push", slog.String("path", r.URL.Path))
|
||||
http.Error(w, "authorization required", http.StatusUnauthorized)
|
||||
return false
|
||||
return ErrUnauthorized
|
||||
}
|
||||
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
h.logger.Warn("missing push authorization", slog.String("path", r.URL.Path))
|
||||
http.Error(w, "authorization required", http.StatusUnauthorized)
|
||||
return false
|
||||
return ErrUnauthorized
|
||||
}
|
||||
|
||||
const bearerPrefix = "Bearer "
|
||||
if !strings.HasPrefix(authHeader, bearerPrefix) {
|
||||
h.logger.Warn("invalid push authorization format", slog.String("path", r.URL.Path))
|
||||
http.Error(w, "invalid authorization format", http.StatusUnauthorized)
|
||||
return false
|
||||
return ErrInvalidFormat
|
||||
}
|
||||
|
||||
token := strings.TrimPrefix(authHeader, bearerPrefix)
|
||||
if !ValidateToken(h.hmacKey, token, exec.Organization, exec.Repository, exec.Workflow, exec.Job, h.tokenTTL) {
|
||||
h.logger.Warn("invalid push token", slog.String("path", r.URL.Path))
|
||||
http.Error(w, "invalid token", http.StatusUnauthorized)
|
||||
return false
|
||||
return ErrInvalidToken
|
||||
}
|
||||
|
||||
return true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handler) handleReceiveMetrics(w http.ResponseWriter, r *http.Request) {
|
||||
// Health returns the service health status
|
||||
func (h *Handler) Health(c fuego.ContextNoBody) (HealthResponse, error) {
|
||||
return HealthResponse{Status: "ok"}, nil
|
||||
}
|
||||
|
||||
// GenerateToken generates a scoped HMAC push token for a workflow/job
|
||||
func (h *Handler) GenerateToken(c fuego.ContextWithBody[TokenRequest]) (TokenResponse, error) {
|
||||
if h.hmacKey == "" {
|
||||
return TokenResponse{}, fuego.BadRequestError{Detail: ErrMissingHMACKey.Error()}
|
||||
}
|
||||
|
||||
req, err := c.Body()
|
||||
if err != nil {
|
||||
return TokenResponse{}, fuego.BadRequestError{Detail: "invalid JSON body"}
|
||||
}
|
||||
|
||||
if req.Organization == "" || req.Repository == "" || req.Workflow == "" || req.Job == "" {
|
||||
return TokenResponse{}, fuego.BadRequestError{Detail: ErrMissingFields.Error()}
|
||||
}
|
||||
|
||||
token := GenerateToken(h.hmacKey, req.Organization, req.Repository, req.Workflow, req.Job)
|
||||
return TokenResponse{Token: token}, nil
|
||||
}
|
||||
|
||||
// ReceiveMetrics receives and stores metrics from a collector
|
||||
func (h *Handler) ReceiveMetrics(c fuego.ContextNoBody) (MetricCreatedResponse, error) {
|
||||
var payload MetricsPayload
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(&payload); err != nil {
|
||||
h.logger.Error("failed to decode payload", slog.String("error", err.Error()))
|
||||
http.Error(w, "invalid JSON payload", http.StatusBadRequest)
|
||||
return
|
||||
return MetricCreatedResponse{}, fuego.BadRequestError{Detail: "invalid JSON payload"}
|
||||
}
|
||||
|
||||
if payload.Execution.RunID == "" {
|
||||
http.Error(w, "run_id is required", http.StatusBadRequest)
|
||||
return
|
||||
return MetricCreatedResponse{}, fuego.BadRequestError{Detail: ErrMissingRunID.Error()}
|
||||
}
|
||||
|
||||
if !h.validatePushToken(w, r, payload.Execution) {
|
||||
return
|
||||
// Validate push token
|
||||
if err := h.validatePushToken(c.Request(), payload.Execution); err != nil {
|
||||
return MetricCreatedResponse{}, fuego.UnauthorizedError{Detail: err.Error()}
|
||||
}
|
||||
|
||||
id, err := h.store.SaveMetric(&payload)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to save metric", slog.String("error", err.Error()))
|
||||
http.Error(w, "failed to save metric", http.StatusInternalServerError)
|
||||
return
|
||||
return MetricCreatedResponse{}, fuego.InternalServerError{Detail: "failed to save metric"}
|
||||
}
|
||||
|
||||
h.logger.Info("metric saved",
|
||||
|
|
@ -162,30 +212,25 @@ func (h *Handler) handleReceiveMetrics(w http.ResponseWriter, r *http.Request) {
|
|||
slog.String("repository", payload.Execution.Repository),
|
||||
)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"id": id, "status": "created"})
|
||||
c.SetStatus(http.StatusCreated)
|
||||
return MetricCreatedResponse{ID: id, Status: "created"}, nil
|
||||
}
|
||||
|
||||
func (h *Handler) handleGetByWorkflowJob(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.validateReadToken(w, r) {
|
||||
return
|
||||
}
|
||||
// GetMetricsByWorkflowJob retrieves all metrics for a specific workflow/job
|
||||
func (h *Handler) GetMetricsByWorkflowJob(c fuego.ContextNoBody) ([]MetricResponse, error) {
|
||||
org := c.PathParam("org")
|
||||
repo := c.PathParam("repo")
|
||||
workflow := c.PathParam("workflow")
|
||||
job := c.PathParam("job")
|
||||
|
||||
org := r.PathValue("org")
|
||||
repo := r.PathValue("repo")
|
||||
workflow := r.PathValue("workflow")
|
||||
job := r.PathValue("job")
|
||||
if org == "" || repo == "" || workflow == "" || job == "" {
|
||||
http.Error(w, "org, repo, workflow and job are required", http.StatusBadRequest)
|
||||
return
|
||||
return nil, fuego.BadRequestError{Detail: ErrInvalidParams.Error()}
|
||||
}
|
||||
|
||||
metrics, err := h.store.GetMetricsByWorkflowJob(org, repo, workflow, job)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to get metrics", slog.String("error", err.Error()))
|
||||
http.Error(w, "failed to get metrics", http.StatusInternalServerError)
|
||||
return
|
||||
return nil, fuego.InternalServerError{Detail: "failed to get metrics"}
|
||||
}
|
||||
|
||||
// Convert to response type with Payload as JSON object
|
||||
|
|
@ -194,67 +239,53 @@ func (h *Handler) handleGetByWorkflowJob(w http.ResponseWriter, r *http.Request)
|
|||
response[i] = m.ToResponse()
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(response)
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (h *Handler) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
}
|
||||
// GetSizing computes Kubernetes resource sizing recommendations
|
||||
func (h *Handler) GetSizing(c fuego.ContextNoBody) (SizingResponse, error) {
|
||||
org := c.PathParam("org")
|
||||
repo := c.PathParam("repo")
|
||||
workflow := c.PathParam("workflow")
|
||||
job := c.PathParam("job")
|
||||
|
||||
func (h *Handler) handleGetSizing(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.validateReadToken(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
org := r.PathValue("org")
|
||||
repo := r.PathValue("repo")
|
||||
workflow := r.PathValue("workflow")
|
||||
job := r.PathValue("job")
|
||||
if org == "" || repo == "" || workflow == "" || job == "" {
|
||||
http.Error(w, "org, repo, workflow and job are required", http.StatusBadRequest)
|
||||
return
|
||||
return SizingResponse{}, fuego.BadRequestError{Detail: ErrInvalidParams.Error()}
|
||||
}
|
||||
|
||||
// Parse query parameters with defaults
|
||||
runs := parseIntQueryParam(r, "runs", 5, 1, 100)
|
||||
buffer := parseIntQueryParam(r, "buffer", 20, 0, 100)
|
||||
cpuPercentile := r.URL.Query().Get("cpu_percentile")
|
||||
runs := parseIntQueryParamFromContext(c, "runs", 5, 1, 100)
|
||||
buffer := parseIntQueryParamFromContext(c, "buffer", 20, 0, 100)
|
||||
cpuPercentile := c.QueryParam("cpu_percentile")
|
||||
if cpuPercentile == "" {
|
||||
cpuPercentile = "p95"
|
||||
}
|
||||
if !IsValidPercentile(cpuPercentile) {
|
||||
http.Error(w, "invalid cpu_percentile: must be one of peak, p99, p95, p75, p50, avg", http.StatusBadRequest)
|
||||
return
|
||||
return SizingResponse{}, fuego.BadRequestError{Detail: ErrInvalidPercent.Error()}
|
||||
}
|
||||
|
||||
metrics, err := h.store.GetRecentMetricsByWorkflowJob(org, repo, workflow, job, runs)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to get metrics", slog.String("error", err.Error()))
|
||||
http.Error(w, "failed to get metrics", http.StatusInternalServerError)
|
||||
return
|
||||
return SizingResponse{}, fuego.InternalServerError{Detail: "failed to get metrics"}
|
||||
}
|
||||
|
||||
if len(metrics) == 0 {
|
||||
http.Error(w, "no metrics found for this workflow/job", http.StatusNotFound)
|
||||
return
|
||||
return SizingResponse{}, fuego.NotFoundError{Detail: ErrNoMetrics.Error()}
|
||||
}
|
||||
|
||||
response, err := computeSizing(metrics, buffer, cpuPercentile)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to compute sizing", slog.String("error", err.Error()))
|
||||
http.Error(w, "failed to compute sizing", http.StatusInternalServerError)
|
||||
return
|
||||
return SizingResponse{}, fuego.InternalServerError{Detail: "failed to compute sizing"}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(response)
|
||||
return *response, nil
|
||||
}
|
||||
|
||||
// parseIntQueryParam parses an integer query parameter with default, min, and max values
|
||||
func parseIntQueryParam(r *http.Request, name string, defaultVal, minVal, maxVal int) int {
|
||||
strVal := r.URL.Query().Get(name)
|
||||
// parseIntQueryParamFromContext parses an integer query parameter with default, min, and max values
|
||||
func parseIntQueryParamFromContext(c fuego.ContextNoBody, name string, defaultVal, minVal, maxVal int) int {
|
||||
strVal := c.QueryParam(name)
|
||||
if strVal == "" {
|
||||
return defaultVal
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ import (
|
|||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-fuego/fuego"
|
||||
|
||||
"edp.buildth.ing/DevFW-CICD/forgejo-runner-sizer/internal/summary"
|
||||
)
|
||||
|
||||
|
|
@ -42,9 +44,8 @@ func TestHandler_ReceiveMetrics(t *testing.T) {
|
|||
req.Header.Set("Authorization", "Bearer "+pushToken)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
mux.ServeHTTP(rec, req)
|
||||
s := newTestServer(h)
|
||||
s.Mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Errorf("status = %d, want %d", rec.Code, http.StatusCreated)
|
||||
|
|
@ -69,9 +70,8 @@ func TestHandler_ReceiveMetrics_InvalidJSON(t *testing.T) {
|
|||
req := httptest.NewRequest(http.MethodPost, "/api/v1/metrics", bytes.NewReader([]byte("not json")))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
mux.ServeHTTP(rec, req)
|
||||
s := newTestServer(h)
|
||||
s.Mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
||||
|
|
@ -95,9 +95,8 @@ func TestHandler_ReceiveMetrics_MissingRunID(t *testing.T) {
|
|||
req := httptest.NewRequest(http.MethodPost, "/api/v1/metrics", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
mux.ServeHTTP(rec, req)
|
||||
s := newTestServer(h)
|
||||
s.Mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
||||
|
|
@ -125,9 +124,8 @@ func TestHandler_GetByWorkflowJob(t *testing.T) {
|
|||
req.Header.Set("Authorization", "Bearer "+readToken)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
mux.ServeHTTP(rec, req)
|
||||
s := newTestServer(h)
|
||||
s.Mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
|
|
@ -151,9 +149,8 @@ func TestHandler_GetByWorkflowJob_NotFound(t *testing.T) {
|
|||
req.Header.Set("Authorization", "Bearer "+readToken)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
mux.ServeHTTP(rec, req)
|
||||
s := newTestServer(h)
|
||||
s.Mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
|
|
@ -180,8 +177,7 @@ func TestHandler_GetByWorkflowJob_WithToken(t *testing.T) {
|
|||
t.Fatalf("SaveMetric() error = %v", err)
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
s := newTestServer(h)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
|
|
@ -201,7 +197,7 @@ func TestHandler_GetByWorkflowJob_WithToken(t *testing.T) {
|
|||
req.Header.Set("Authorization", tt.authHeader)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
s.Mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != tt.wantCode {
|
||||
t.Errorf("status = %d, want %d", rec.Code, tt.wantCode)
|
||||
|
|
@ -217,9 +213,8 @@ func TestHandler_Health(t *testing.T) {
|
|||
req := httptest.NewRequest(http.MethodGet, "/health", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
mux.ServeHTTP(rec, req)
|
||||
s := newTestServer(h)
|
||||
s.Mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
|
|
@ -250,9 +245,8 @@ func TestHandler_GenerateToken(t *testing.T) {
|
|||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
mux.ServeHTTP(rec, req)
|
||||
s := newTestServer(h)
|
||||
s.Mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
|
|
@ -289,9 +283,8 @@ func TestHandler_GenerateToken_NoAuth(t *testing.T) {
|
|||
req := httptest.NewRequest(http.MethodPost, "/api/v1/token", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
mux.ServeHTTP(rec, req)
|
||||
s := newTestServer(h)
|
||||
s.Mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
|
||||
|
|
@ -314,9 +307,8 @@ func TestHandler_GenerateToken_MissingFields(t *testing.T) {
|
|||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
mux.ServeHTTP(rec, req)
|
||||
s := newTestServer(h)
|
||||
s.Mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
||||
|
|
@ -338,12 +330,12 @@ func TestHandler_GenerateToken_NoReadToken(t *testing.T) {
|
|||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
mux.ServeHTTP(rec, req)
|
||||
s := newTestServer(h)
|
||||
s.Mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
||||
// With no read token, the middleware rejects before we reach the handler
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -352,8 +344,7 @@ func TestHandler_ReceiveMetrics_WithPushToken(t *testing.T) {
|
|||
h, cleanup := newTestHandlerWithToken(t, readToken)
|
||||
defer cleanup()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
s := newTestServer(h)
|
||||
|
||||
exec := ExecutionContext{
|
||||
Organization: "org",
|
||||
|
|
@ -391,7 +382,7 @@ func TestHandler_ReceiveMetrics_WithPushToken(t *testing.T) {
|
|||
req.Header.Set("Authorization", tt.authHeader)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
s.Mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != tt.wantCode {
|
||||
t.Errorf("status = %d, want %d", rec.Code, tt.wantCode)
|
||||
|
|
@ -420,9 +411,8 @@ func TestHandler_ReceiveMetrics_RejectsWhenNoReadToken(t *testing.T) {
|
|||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
mux.ServeHTTP(rec, req)
|
||||
s := newTestServer(h)
|
||||
s.Mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
|
||||
|
|
@ -436,15 +426,27 @@ func TestHandler_GetByWorkflowJob_RejectsWhenNoReadToken(t *testing.T) {
|
|||
req := httptest.NewRequest(http.MethodGet, "/api/v1/metrics/repo/org/repo/ci.yml/build", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
mux.ServeHTTP(rec, req)
|
||||
s := newTestServer(h)
|
||||
s.Mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestServer(h *Handler) *fuego.Server {
|
||||
s := fuego.NewServer(
|
||||
fuego.WithoutStartupMessages(),
|
||||
fuego.WithEngineOptions(
|
||||
fuego.WithOpenAPIConfig(fuego.OpenAPIConfig{
|
||||
Disabled: true,
|
||||
}),
|
||||
),
|
||||
)
|
||||
h.RegisterRoutes(s)
|
||||
return s
|
||||
}
|
||||
|
||||
func newTestHandler(t *testing.T) (*Handler, func()) {
|
||||
t.Helper()
|
||||
dbPath := filepath.Join(t.TempDir(), "test.db")
|
||||
|
|
|
|||
|
|
@ -342,9 +342,8 @@ func TestHandler_GetSizing(t *testing.T) {
|
|||
req.Header.Set("Authorization", "Bearer "+readToken)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
mux.ServeHTTP(rec, req)
|
||||
s := newTestServer(h)
|
||||
s.Mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
|
|
@ -396,9 +395,8 @@ func TestHandler_GetSizing_CustomParams(t *testing.T) {
|
|||
req.Header.Set("Authorization", "Bearer "+readToken)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
mux.ServeHTTP(rec, req)
|
||||
s := newTestServer(h)
|
||||
s.Mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
|
|
@ -432,9 +430,8 @@ func TestHandler_GetSizing_NotFound(t *testing.T) {
|
|||
req.Header.Set("Authorization", "Bearer "+readToken)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
mux.ServeHTTP(rec, req)
|
||||
s := newTestServer(h)
|
||||
s.Mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("status = %d, want %d", rec.Code, http.StatusNotFound)
|
||||
|
|
@ -450,9 +447,8 @@ func TestHandler_GetSizing_InvalidPercentile(t *testing.T) {
|
|||
req.Header.Set("Authorization", "Bearer "+readToken)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
mux.ServeHTTP(rec, req)
|
||||
s := newTestServer(h)
|
||||
s.Mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
||||
|
|
@ -474,8 +470,7 @@ func TestHandler_GetSizing_AuthRequired(t *testing.T) {
|
|||
{"valid token", "Bearer " + readToken, http.StatusNotFound}, // no metrics, but auth works
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
s := newTestServer(h)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
|
@ -484,7 +479,7 @@ func TestHandler_GetSizing_AuthRequired(t *testing.T) {
|
|||
req.Header.Set("Authorization", tt.authHeader)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
s.Mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != tt.wantCode {
|
||||
t.Errorf("status = %d, want %d", rec.Code, tt.wantCode)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue