feat: phase 8 serviceadapter crd rollout (32/33 tasks)

This commit is contained in:
Admin Bot
2026-08-26 13:47:36 -07:00
parent 63893d41a5
commit 425611ec42
85 changed files with 4238 additions and 5702 deletions
+127
View File
@@ -0,0 +1,127 @@
package observability
import (
"fmt"
"strings"
)
// ExportPrometheus exports metrics in Prometheus text format.
func (m *Metrics) ExportPrometheus() string {
m.mu.RLock()
defer m.mu.RUnlock()
var sb strings.Builder
// Help and type for request_total counter
sb.WriteString("# HELP gateway_requests_total Total number of HTTP requests\n")
sb.WriteString("# TYPE gateway_requests_total counter\n")
for key, count := range m.requestTotal {
parts := strings.Split(key, ":")
if len(parts) == 3 {
route, upstream, status := parts[0], parts[1], parts[2]
sb.WriteString(fmt.Sprintf("gateway_requests_total{route=\"%s\",upstream=\"%s\",status=\"%s\"} %d\n",
route, upstream, status, count))
}
}
sb.WriteString("\n")
// Help and type for request_duration_seconds histogram
sb.WriteString("# HELP gateway_request_duration_seconds Request latency in seconds\n")
sb.WriteString("# TYPE gateway_request_duration_seconds histogram\n")
buckets := []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10}
for key := range m.requestDurationBuckets {
parts := strings.Split(key, ":")
if len(parts) == 2 {
route, upstream := parts[0], parts[1]
// Write buckets
cumulativeCount := int64(0)
for _, bucket := range buckets {
if count, ok := m.requestDurationBuckets[key][bucket]; ok {
cumulativeCount += count
}
sb.WriteString(fmt.Sprintf("gateway_request_duration_seconds_bucket{route=\"%s\",upstream=\"%s\",le=\"%g\"} %d\n",
route, upstream, bucket, cumulativeCount))
}
// Write +Inf bucket
totalCount := int64(0)
for _, count := range m.requestDurationBuckets[key] {
totalCount += count
}
sb.WriteString(fmt.Sprintf("gateway_request_duration_seconds_bucket{route=\"%s\",upstream=\"%s\",le=\"+Inf\"} %d\n",
route, upstream, totalCount))
// Write sum
totalDuration := m.requestDuration[key]
sb.WriteString(fmt.Sprintf("gateway_request_duration_seconds_sum{route=\"%s\",upstream=\"%s\"} %g\n",
route, upstream, float64(totalDuration)/1000.0)) // convert ms to seconds
// Write count
sb.WriteString(fmt.Sprintf("gateway_request_duration_seconds_count{route=\"%s\",upstream=\"%s\"} %d\n",
route, upstream, totalCount))
}
}
sb.WriteString("\n")
// Help and type for gateway_bytes_in counter
sb.WriteString("# HELP gateway_bytes_in_total Total bytes received from clients\n")
sb.WriteString("# TYPE gateway_bytes_in_total counter\n")
for key, count := range m.bytesIn {
parts := strings.Split(key, ":")
if len(parts) == 2 {
route, upstream := parts[0], parts[1]
sb.WriteString(fmt.Sprintf("gateway_bytes_in_total{route=\"%s\",upstream=\"%s\"} %d\n",
route, upstream, count))
}
}
sb.WriteString("\n")
// Help and type for gateway_bytes_out counter
sb.WriteString("# HELP gateway_bytes_out_total Total bytes sent to clients\n")
sb.WriteString("# TYPE gateway_bytes_out_total counter\n")
for key, count := range m.bytesOut {
parts := strings.Split(key, ":")
if len(parts) == 2 {
route, upstream := parts[0], parts[1]
sb.WriteString(fmt.Sprintf("gateway_bytes_out_total{route=\"%s\",upstream=\"%s\"} %d\n",
route, upstream, count))
}
}
sb.WriteString("\n")
// Help and type for upstream_health gauge
sb.WriteString("# HELP gateway_upstream_health Upstream health status (1=healthy, 0=unhealthy)\n")
sb.WriteString("# TYPE gateway_upstream_health gauge\n")
for upstream, health := range m.upstreamHealth {
sb.WriteString(fmt.Sprintf("gateway_upstream_health{upstream=\"%s\"} %d\n", upstream, health))
}
sb.WriteString("\n")
// Help and type for streaming responses
sb.WriteString("# HELP gateway_streaming_responses_total Total streaming responses\n")
sb.WriteString("# TYPE gateway_streaming_responses_total counter\n")
for key, count := range m.streamingResponsesTotal {
parts := strings.Split(key, ":")
if len(parts) == 2 {
route, upstream := parts[0], parts[1]
sb.WriteString(fmt.Sprintf("gateway_streaming_responses_total{route=\"%s\",upstream=\"%s\"} %d\n",
route, upstream, count))
}
}
sb.WriteString("\n")
// Help and type for streaming byte count
sb.WriteString("# HELP gateway_streaming_bytes_total Total bytes in streaming responses\n")
sb.WriteString("# TYPE gateway_streaming_bytes_total counter\n")
for key, count := range m.streamingByteCount {
parts := strings.Split(key, ":")
if len(parts) == 2 {
route, upstream := parts[0], parts[1]
sb.WriteString(fmt.Sprintf("gateway_streaming_bytes_total{route=\"%s\",upstream=\"%s\"} %d\n",
route, upstream, count))
}
}
return sb.String()
}
+165
View File
@@ -0,0 +1,165 @@
package observability
import (
"fmt"
"sync"
"time"
)
// Metrics holds all Prometheus metrics for the gateway.
type Metrics struct {
mu sync.RWMutex
// Request counters: request_total{route, upstream, status}
requestTotal map[string]int64
// Request latencies: request_duration_seconds (histogram)
// Stored as cumulative buckets for Prometheus text format
requestDuration map[string]int64 // stores duration samples in milliseconds
requestDurationBuckets map[string]map[float64]int64 // histogram buckets
// Bytes counters: gateway_bytes{direction, route, upstream}
bytesIn map[string]int64
bytesOut map[string]int64
// Upstream health: upstream_health{upstream} = 1 or 0
upstreamHealth map[string]int
// Streaming metrics
streamingResponsesTotal map[string]int64
streamingByteCount map[string]int64
}
// NewMetrics creates a new Metrics instance.
func NewMetrics() *Metrics {
return &Metrics{
requestTotal: make(map[string]int64),
requestDuration: make(map[string]int64),
requestDurationBuckets: make(map[string]map[float64]int64),
bytesIn: make(map[string]int64),
bytesOut: make(map[string]int64),
upstreamHealth: make(map[string]int),
streamingResponsesTotal: make(map[string]int64),
streamingByteCount: make(map[string]int64),
}
}
// RecordRequest records a request with its route, upstream, status, and duration.
func (m *Metrics) RecordRequest(route, upstream string, statusCode int, duration time.Duration) {
m.mu.Lock()
defer m.mu.Unlock()
key := fmt.Sprintf("%s:%s:%d", route, upstream, statusCode)
m.requestTotal[key]++
// Record duration in milliseconds
durationKey := fmt.Sprintf("%s:%s", route, upstream)
m.requestDuration[durationKey] += int64(duration.Milliseconds())
// Record in histogram buckets
if _, ok := m.requestDurationBuckets[durationKey]; !ok {
m.requestDurationBuckets[durationKey] = make(map[float64]int64)
}
// Prometheus histogram buckets: .005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10
buckets := []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10}
durationSeconds := duration.Seconds()
for _, bucket := range buckets {
if durationSeconds <= bucket {
m.requestDurationBuckets[durationKey][bucket]++
}
}
}
// RecordBytesIn records incoming bytes.
func (m *Metrics) RecordBytesIn(route, upstream string, bytes int64) {
m.mu.Lock()
defer m.mu.Unlock()
key := fmt.Sprintf("%s:%s", route, upstream)
m.bytesIn[key] += bytes
}
// RecordBytesOut records outgoing bytes.
func (m *Metrics) RecordBytesOut(route, upstream string, bytes int64) {
m.mu.Lock()
defer m.mu.Unlock()
key := fmt.Sprintf("%s:%s", route, upstream)
m.bytesOut[key] += bytes
}
// SetUpstreamHealth sets the health status of an upstream (1 = healthy, 0 = unhealthy).
func (m *Metrics) SetUpstreamHealth(upstream string, healthy bool) {
m.mu.Lock()
defer m.mu.Unlock()
if healthy {
m.upstreamHealth[upstream] = 1
} else {
m.upstreamHealth[upstream] = 0
}
}
// RecordStreamingResponse records a streaming response with its total byte count and duration.
func (m *Metrics) RecordStreamingResponse(route, upstream string, totalBytes int64, duration time.Duration) {
m.mu.Lock()
defer m.mu.Unlock()
key := fmt.Sprintf("%s:%s", route, upstream)
m.streamingResponsesTotal[key]++
m.streamingByteCount[key] += totalBytes
// Also record as request duration
m.recordDuration(key, duration)
}
func (m *Metrics) recordDuration(key string, duration time.Duration) {
m.requestDuration[key] += int64(duration.Milliseconds())
if _, ok := m.requestDurationBuckets[key]; !ok {
m.requestDurationBuckets[key] = make(map[float64]int64)
}
buckets := []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10}
durationSeconds := duration.Seconds()
for _, bucket := range buckets {
if durationSeconds <= bucket {
m.requestDurationBuckets[key][bucket]++
}
}
}
// GetMetrics returns a copy of current metrics (for testing/export).
func (m *Metrics) GetMetrics() map[string]interface{} {
m.mu.RLock()
defer m.mu.RUnlock()
return map[string]interface{}{
"request_total": m.requestTotal,
"request_duration": m.requestDuration,
"request_duration_buckets": m.requestDurationBuckets,
"bytes_in": m.bytesIn,
"bytes_out": m.bytesOut,
"upstream_health": m.upstreamHealth,
"streaming_responses_total": m.streamingResponsesTotal,
"streaming_byte_count": m.streamingByteCount,
}
}
// Reset clears all metrics (for testing).
func (m *Metrics) Reset() {
m.mu.Lock()
defer m.mu.Unlock()
m.requestTotal = make(map[string]int64)
m.requestDuration = make(map[string]int64)
m.requestDurationBuckets = make(map[string]map[float64]int64)
m.bytesIn = make(map[string]int64)
m.bytesOut = make(map[string]int64)
m.upstreamHealth = make(map[string]int)
m.streamingResponsesTotal = make(map[string]int64)
m.streamingByteCount = make(map[string]int64)
}
+162
View File
@@ -0,0 +1,162 @@
package observability
import (
"strings"
"testing"
"time"
)
func TestMetricsRecordRequest(t *testing.T) {
m := NewMetrics()
// Record some requests
m.RecordRequest("v1-chat", "reasoning", 200, 500*time.Millisecond)
m.RecordRequest("v1-chat", "reasoning", 200, 600*time.Millisecond)
m.RecordRequest("v1-chat", "reasoning", 500, 100*time.Millisecond)
metrics := m.GetMetrics()
requestTotal := metrics["request_total"].(map[string]int64)
if requestTotal["v1-chat:reasoning:200"] != 2 {
t.Errorf("expected 2 successful requests, got %d", requestTotal["v1-chat:reasoning:200"])
}
if requestTotal["v1-chat:reasoning:500"] != 1 {
t.Errorf("expected 1 error request, got %d", requestTotal["v1-chat:reasoning:500"])
}
}
func TestMetricsRecordBytes(t *testing.T) {
m := NewMetrics()
m.RecordBytesIn("v1-chat", "reasoning", 1024)
m.RecordBytesOut("v1-chat", "reasoning", 2048)
metrics := m.GetMetrics()
bytesIn := metrics["bytes_in"].(map[string]int64)
bytesOut := metrics["bytes_out"].(map[string]int64)
if bytesIn["v1-chat:reasoning"] != 1024 {
t.Errorf("expected 1024 bytes in, got %d", bytesIn["v1-chat:reasoning"])
}
if bytesOut["v1-chat:reasoning"] != 2048 {
t.Errorf("expected 2048 bytes out, got %d", bytesOut["v1-chat:reasoning"])
}
}
func TestMetricsUpstreamHealth(t *testing.T) {
m := NewMetrics()
m.SetUpstreamHealth("reasoning", true)
m.SetUpstreamHealth("embedding", false)
metrics := m.GetMetrics()
health := metrics["upstream_health"].(map[string]int)
if health["reasoning"] != 1 {
t.Errorf("expected reasoning upstream healthy (1), got %d", health["reasoning"])
}
if health["embedding"] != 0 {
t.Errorf("expected embedding upstream unhealthy (0), got %d", health["embedding"])
}
}
func TestExportPrometheus(t *testing.T) {
m := NewMetrics()
// Record some data
m.RecordRequest("v1-chat", "reasoning", 200, 500*time.Millisecond)
m.RecordBytesIn("v1-chat", "reasoning", 1024)
m.RecordBytesOut("v1-chat", "reasoning", 2048)
m.SetUpstreamHealth("reasoning", true)
export := m.ExportPrometheus()
// Check for expected metric families
if !strings.Contains(export, "# HELP gateway_requests_total") {
t.Errorf("missing gateway_requests_total help")
}
if !strings.Contains(export, "# TYPE gateway_requests_total counter") {
t.Errorf("missing gateway_requests_total type")
}
if !strings.Contains(export, "gateway_requests_total{route=\"v1-chat\",upstream=\"reasoning\",status=\"200\"} 1") {
t.Errorf("missing or incorrect request_total metric")
}
if !strings.Contains(export, "# HELP gateway_bytes_in_total") {
t.Errorf("missing gateway_bytes_in_total help")
}
if !strings.Contains(export, "gateway_bytes_in_total{route=\"v1-chat\",upstream=\"reasoning\"} 1024") {
t.Errorf("missing or incorrect bytes_in metric")
}
if !strings.Contains(export, "gateway_bytes_out_total{route=\"v1-chat\",upstream=\"reasoning\"} 2048") {
t.Errorf("missing or incorrect bytes_out metric")
}
if !strings.Contains(export, "gateway_upstream_health{upstream=\"reasoning\"} 1") {
t.Errorf("missing or incorrect upstream_health metric")
}
}
func TestExportPrometheusHistogram(t *testing.T) {
m := NewMetrics()
// Record requests with different durations
m.RecordRequest("v1-chat", "reasoning", 200, 50*time.Millisecond)
m.RecordRequest("v1-chat", "reasoning", 200, 200*time.Millisecond)
m.RecordRequest("v1-chat", "reasoning", 200, 1*time.Second)
export := m.ExportPrometheus()
// Check for histogram structure
if !strings.Contains(export, "# HELP gateway_request_duration_seconds Request latency in seconds") {
t.Errorf("missing duration_seconds help")
}
if !strings.Contains(export, "# TYPE gateway_request_duration_seconds histogram") {
t.Errorf("missing histogram type")
}
if !strings.Contains(export, "gateway_request_duration_seconds_bucket") {
t.Errorf("missing histogram bucket")
}
if !strings.Contains(export, "gateway_request_duration_seconds_count") {
t.Errorf("missing histogram count")
}
}
func TestMetricsThreadSafety(t *testing.T) {
m := NewMetrics()
// Concurrent recordings
done := make(chan bool, 2)
go func() {
for i := 0; i < 100; i++ {
m.RecordRequest("route1", "upstream1", 200, time.Millisecond)
}
done <- true
}()
go func() {
for i := 0; i < 100; i++ {
m.RecordBytesIn("route2", "upstream2", 1024)
}
done <- true
}()
<-done
<-done
metrics := m.GetMetrics()
if len(metrics["request_total"].(map[string]int64)) == 0 {
t.Errorf("expected metrics to be recorded")
}
}
+124
View File
@@ -0,0 +1,124 @@
package problem
import (
"encoding/json"
"net/http"
"strconv"
)
// Problem represents an RFC 7807 / RFC 9457 problem document.
// We use 9457 as the canonical reference (HTTP Semantics updates).
type Problem struct {
Type string `json:"type"` // stable URI per rejection reason
Title string `json:"title"` // human-readable summary
Status int `json:"status"` // HTTP status code
Detail string `json:"detail"` // human-useful detail, names offending input
Instance string `json:"instance,omitempty"` // URI of the affected resource
RetryAfter *int `json:"retry_after,omitempty"` // seconds until retry is safe
Extra map[string]interface{} `json:"extra,omitempty"` // additional fields
}
// NewProblem creates a new problem document with the given parameters.
func NewProblem(statusCode int, typeURI, title, detail string) *Problem {
return &Problem{
Type: typeURI,
Title: title,
Status: statusCode,
Detail: detail,
Extra: make(map[string]interface{}),
}
}
// WithRetryAfter sets the Retry-After field (in seconds).
func (p *Problem) WithRetryAfter(seconds int) *Problem {
p.RetryAfter = &seconds
return p
}
// WithInstance sets the Instance field.
func (p *Problem) WithInstance(instance string) *Problem {
p.Instance = instance
return p
}
// WithExtra adds extra fields to the problem document.
func (p *Problem) WithExtra(key string, value interface{}) *Problem {
p.Extra[key] = value
return p
}
// Write sends the problem document to the HTTP response writer.
func (p *Problem) Write(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/problem+json")
// Set Retry-After header if present
if p.RetryAfter != nil {
w.Header().Set("Retry-After", strconv.Itoa(*p.RetryAfter))
}
w.WriteHeader(p.Status)
body, err := json.Marshal(p)
if err != nil {
return err
}
_, err = w.Write(body)
return err
}
// Common problem types
const (
TypeBadRequest = "about:blank#bad-request"
TypeUnauthorized = "about:blank#unauthorized"
TypeForbidden = "about:blank#forbidden"
TypeNotFound = "about:blank#not-found"
TypeMethodNotAllowed = "about:blank#method-not-allowed"
TypeConflict = "about:blank#conflict"
TypeGone = "about:blank#gone"
TypePayloadTooLarge = "about:blank#payload-too-large"
TypeUnprocessable = "about:blank#unprocessable-entity"
TypeTooManyRequests = "about:blank#too-many-requests"
TypeInternalError = "about:blank#internal-server-error"
TypeNotImplemented = "about:blank#not-implemented"
TypeUnavailable = "about:blank#service-unavailable"
)
// Common constructors
func BadRequest(detail string) *Problem {
return NewProblem(http.StatusBadRequest, TypeBadRequest, "Bad Request", detail)
}
func Unauthorized(detail string) *Problem {
return NewProblem(http.StatusUnauthorized, TypeUnauthorized, "Unauthorized", detail)
}
func Forbidden(detail string) *Problem {
return NewProblem(http.StatusForbidden, TypeForbidden, "Forbidden", detail)
}
func NotFound(detail string) *Problem {
return NewProblem(http.StatusNotFound, TypeNotFound, "Not Found", detail)
}
func PayloadTooLarge(detail string) *Problem {
return NewProblem(http.StatusRequestEntityTooLarge, TypePayloadTooLarge, "Payload Too Large", detail)
}
func UnprocessableEntity(detail string) *Problem {
return NewProblem(http.StatusUnprocessableEntity, TypeUnprocessable, "Unprocessable Entity", detail)
}
func TooManyRequests(detail string, retryAfter int) *Problem {
return NewProblem(http.StatusTooManyRequests, TypeTooManyRequests, "Too Many Requests", detail).
WithRetryAfter(retryAfter)
}
func InternalServerError(detail string) *Problem {
return NewProblem(http.StatusInternalServerError, TypeInternalError, "Internal Server Error", detail)
}
func ServiceUnavailable(detail string, retryAfter int) *Problem {
return NewProblem(http.StatusServiceUnavailable, TypeUnavailable, "Service Unavailable", detail).
WithRetryAfter(retryAfter)
}
+162
View File
@@ -0,0 +1,162 @@
package problem
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestProblemDocument(t *testing.T) {
tests := []struct {
name string
problem *Problem
statusCode int
hasType bool
hasTitle bool
hasStatus bool
hasDetail bool
}{
{
name: "BadRequest",
problem: BadRequest("missing field: model"),
statusCode: http.StatusBadRequest,
hasType: true,
hasTitle: true,
hasStatus: true,
hasDetail: true,
},
{
name: "PayloadTooLarge",
problem: PayloadTooLarge("request body 1001 bytes exceeds max 1000"),
statusCode: http.StatusRequestEntityTooLarge,
hasType: true,
hasTitle: true,
hasStatus: true,
hasDetail: true,
},
{
name: "TooManyRequests",
problem: TooManyRequests("rate limit exceeded", 60),
statusCode: http.StatusTooManyRequests,
hasType: true,
hasTitle: true,
hasStatus: true,
hasDetail: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
w := httptest.NewRecorder()
err := tc.problem.Write(w)
if err != nil {
t.Fatalf("Write failed: %v", err)
}
// Check status code
if w.Code != tc.statusCode {
t.Errorf("expected status %d, got %d", tc.statusCode, w.Code)
}
// Check Content-Type
if ct := w.Header().Get("Content-Type"); ct != "application/problem+json" {
t.Errorf("expected Content-Type: application/problem+json, got %s", ct)
}
// Parse response body
var p Problem
err = json.Unmarshal(w.Body.Bytes(), &p)
if err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
// Verify required fields
if tc.hasType && p.Type == "" {
t.Errorf("expected 'type' field, got empty")
}
if tc.hasTitle && p.Title == "" {
t.Errorf("expected 'title' field, got empty")
}
if tc.hasStatus && p.Status == 0 {
t.Errorf("expected 'status' field, got 0")
}
if tc.hasDetail && p.Detail == "" {
t.Errorf("expected 'detail' field, got empty")
}
// Verify status matches HTTP response code
if p.Status != w.Code {
t.Errorf("status field %d does not match HTTP status %d", p.Status, w.Code)
}
})
}
}
func TestProblemRetryAfter(t *testing.T) {
p := TooManyRequests("rate limit", 120)
w := httptest.NewRecorder()
err := p.Write(w)
if err != nil {
t.Fatalf("Write failed: %v", err)
}
// Check Retry-After header is set
if ra := w.Header().Get("Retry-After"); ra == "" {
t.Errorf("expected Retry-After header, got empty")
}
var body Problem
json.Unmarshal(w.Body.Bytes(), &body)
if body.RetryAfter == nil || *body.RetryAfter != 120 {
t.Errorf("expected RetryAfter=120, got %v", body.RetryAfter)
}
}
func TestProblemWithExtra(t *testing.T) {
p := BadRequest("invalid request")
p.WithExtra("field", "model")
p.WithExtra("reason", "unknown_model")
w := httptest.NewRecorder()
err := p.Write(w)
if err != nil {
t.Fatalf("Write failed: %v", err)
}
var body Problem
json.Unmarshal(w.Body.Bytes(), &body)
if body.Extra["field"] != "model" {
t.Errorf("expected extra field 'model', got %v", body.Extra["field"])
}
if body.Extra["reason"] != "unknown_model" {
t.Errorf("expected extra reason 'unknown_model', got %v", body.Extra["reason"])
}
}
func TestNoSecretsInProblem(t *testing.T) {
// Verify that secrets, tokens, bodies are never leaked
p := Unauthorized("invalid bearer token").
WithExtra("attempted_route", "/v1/chat/completions")
w := httptest.NewRecorder()
p.Write(w)
body := w.Body.String()
// Should not contain any auth-related secrets
if len(body) > 200 {
t.Errorf("problem document too large for detail: %d bytes (check for leaked content)", len(body))
}
// Parse and verify no sensitive fields are present
var doc Problem
json.Unmarshal(w.Body.Bytes(), &doc)
// Detail should describe the problem, not echo the token
if len(doc.Detail) > 100 {
t.Errorf("detail too long: %s", doc.Detail)
}
}
+128
View File
@@ -6,7 +6,9 @@ import (
"io"
"net/http"
"net/http/httptest"
"runtime"
"strings"
"sync"
"testing"
"time"
@@ -473,3 +475,129 @@ func TestNoFullBuffering(t *testing.T) {
t.Errorf("expected to read %d bytes, got %d", len(largeData), totalRead)
}
}
// TestClientDisconnectCancelsUpstream verifies that when a client closes mid-stream,
// the upstream request context is cancelled immediately and no goroutines are leaked.
func TestClientDisconnectCancelsUpstream(t *testing.T) {
contextCancelledAt := time.Time{}
contextCancelledMu := sync.Mutex{}
upstreamRequestedAt := time.Time{}
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
upstreamRequestedAt = time.Now()
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
rc := http.NewResponseController(w)
// Send events until context is cancelled
for i := 0; i < 100; i++ {
select {
case <-r.Context().Done():
contextCancelledMu.Lock()
contextCancelledAt = time.Now()
contextCancelledMu.Unlock()
return
default:
}
fmt.Fprintf(w, "data: event%d\n\n", i)
if err := rc.Flush(); err != nil {
contextCancelledMu.Lock()
contextCancelledAt = time.Now()
contextCancelledMu.Unlock()
return
}
time.Sleep(50 * time.Millisecond)
}
}))
defer upstreamServer.Close()
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
cfg := &config.Config{
Routes: map[string]*config.Route{
"disconnect-route": {
Name: "disconnect-route",
Upstream: config.Upstream{
Address: upstreamAddr,
ConnectTimeout: 5 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
MaxBodySize: 1024 * 1024,
AuthRequired: false,
},
},
},
}
handler := New(cfg)
defer handler.Close()
server := httptest.NewServer(handler)
defer server.Close()
// Baseline goroutine count
baselineGoroutines := runtime.NumGoroutine()
// Make a request with a custom HTTP client that allows us to close the connection
client := &http.Client{
Timeout: 30 * time.Second,
}
req, err := http.NewRequest("GET", server.URL+"/disconnect", nil)
if err != nil {
t.Fatalf("request creation failed: %v", err)
}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
// Read a few events
reader := bufio.NewReader(resp.Body)
for i := 0; i < 2; i++ {
line, err := reader.ReadString('\n')
if err != nil {
t.Fatalf("read failed: %v", err)
}
if !strings.Contains(line, "data:") {
i-- // skip non-data lines
}
}
// Close the response body (simulating client disconnect)
resp.Body.Close()
// Wait a bit for cancellation to propagate
time.Sleep(200 * time.Millisecond)
// Verify context was cancelled
contextCancelledMu.Lock()
cancelled := !contextCancelledAt.IsZero()
cancelDelay := time.Duration(0)
if cancelled {
cancelDelay = contextCancelledAt.Sub(upstreamRequestedAt)
}
contextCancelledMu.Unlock()
if !cancelled {
t.Errorf("expected upstream context to be cancelled, but it was not")
}
// Verify cancellation happened quickly (within 1s)
if cancelDelay > 1*time.Second {
t.Errorf("context cancellation took %.2fs (expected < 1s)", cancelDelay.Seconds())
}
// Wait a bit for goroutines to clean up
time.Sleep(100 * time.Millisecond)
// Check for goroutine leaks
finalGoroutines := runtime.NumGoroutine()
if finalGoroutines > baselineGoroutines+5 {
t.Errorf("possible goroutine leak: baseline=%d, final=%d", baselineGoroutines, finalGoroutines)
}
}
+135
View File
@@ -0,0 +1,135 @@
package resilience
import (
"context"
"math/rand"
"net/http"
"time"
)
// RetryConfig holds retry settings.
type RetryConfig struct {
// MaxAttempts is the maximum number of attempts (includes initial).
MaxAttempts int
// InitialBackoff is the initial backoff duration.
InitialBackoff time.Duration
// MaxBackoff is the maximum backoff duration.
MaxBackoff time.Duration
// BackoffMultiplier is the exponential backoff multiplier.
BackoffMultiplier float64
}
// DefaultRetryConfig provides sensible defaults.
func DefaultRetryConfig() *RetryConfig {
return &RetryConfig{
MaxAttempts: 3,
InitialBackoff: 100 * time.Millisecond,
MaxBackoff: 2 * time.Second,
BackoffMultiplier: 2.0,
}
}
// RetryFunc executes a function with blind retry on 5xx.
// Returns the response and any error from the function itself (not retry logic).
type RetryFunc func(ctx context.Context, attempt int) (*http.Response, error)
// DoRetry executes the function with exponential backoff on 5xx responses.
// Returns the final response (could be 5xx if all retries exhausted) and any error.
func DoRetry(ctx context.Context, cfg *RetryConfig, fn RetryFunc) (*http.Response, error) {
if cfg == nil {
cfg = DefaultRetryConfig()
}
var lastResp *http.Response
var lastErr error
for attempt := 0; attempt < cfg.MaxAttempts; attempt++ {
// Check context before attempting
select {
case <-ctx.Done():
if lastResp != nil {
lastResp.Body.Close()
}
return nil, ctx.Err()
default:
}
resp, err := fn(ctx, attempt)
if err != nil {
lastErr = err
// Don't retry on network errors in the retry loop itself
// Let caller decide if those should be retried
return nil, err
}
// Success (not 5xx)
if resp.StatusCode < 500 {
return resp, nil
}
// 5xx — close and retry
if lastResp != nil {
lastResp.Body.Close()
}
lastResp = resp
// If this was the last attempt, return the 5xx response
if attempt == cfg.MaxAttempts-1 {
return resp, nil
}
// Calculate backoff with jitter
backoff := calculateBackoff(attempt, cfg)
select {
case <-ctx.Done():
resp.Body.Close()
return nil, ctx.Err()
case <-time.After(backoff):
// Continue to next attempt
}
}
return lastResp, lastErr
}
// calculateBackoff computes exponential backoff with jitter.
func calculateBackoff(attempt int, cfg *RetryConfig) time.Duration {
// Exponential: initial * (multiplier ^ attempt)
backoff := time.Duration(float64(cfg.InitialBackoff) * (pow(cfg.BackoffMultiplier, float64(attempt))))
// Cap at max
if backoff > cfg.MaxBackoff {
backoff = cfg.MaxBackoff
}
// Add jitter: ±20%
jitterRange := backoff / 5
if jitterRange <= 0 {
return backoff
}
jitter := time.Duration(rand.Int63n(int64(2 * jitterRange)) - int64(jitterRange))
return backoff + jitter
}
func pow(base, exp float64) float64 {
result := 1.0
for i := 0; i < int(exp); i++ {
result *= base
}
return result
}
// RetryPolicy determines whether to retry based on response and config.
type RetryPolicy struct {
Retryable bool // Whether this adapter allows retries
}
// ShouldRetry determines if a response should be retried.
func (p *RetryPolicy) ShouldRetry(resp *http.Response) bool {
if !p.Retryable {
return false
}
return resp != nil && resp.StatusCode >= 500
}
+158
View File
@@ -0,0 +1,158 @@
package resilience
import (
"context"
"io"
"net/http"
"strings"
"testing"
"time"
)
func TestRetryOnSuccess(t *testing.T) {
cfg := &RetryConfig{MaxAttempts: 3}
attempts := 0
resp, err := DoRetry(context.Background(), cfg, func(ctx context.Context, attempt int) (*http.Response, error) {
attempts++
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(strings.NewReader("ok")),
}, nil
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if attempts != 1 {
t.Errorf("expected 1 attempt on success, got %d", attempts)
}
if resp.StatusCode != 200 {
t.Errorf("expected status 200, got %d", resp.StatusCode)
}
resp.Body.Close()
}
func TestRetryOn5xx(t *testing.T) {
cfg := &RetryConfig{
MaxAttempts: 3,
InitialBackoff: 10 * time.Millisecond,
MaxBackoff: 50 * time.Millisecond,
BackoffMultiplier: 2.0,
}
attempts := 0
resp, err := DoRetry(context.Background(), cfg, func(ctx context.Context, attempt int) (*http.Response, error) {
attempts++
if attempt < 2 {
// First two attempts return 503
return &http.Response{
StatusCode: 503,
Body: io.NopCloser(strings.NewReader("unavailable")),
}, nil
}
// Third attempt succeeds
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(strings.NewReader("ok")),
}, nil
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if attempts != 3 {
t.Errorf("expected 3 attempts (2 retries), got %d", attempts)
}
if resp.StatusCode != 200 {
t.Errorf("expected status 200, got %d", resp.StatusCode)
}
resp.Body.Close()
}
func TestRetryExhaustion(t *testing.T) {
cfg := &RetryConfig{
MaxAttempts: 2,
InitialBackoff: 10 * time.Millisecond,
MaxBackoff: 50 * time.Millisecond,
}
attempts := 0
resp, err := DoRetry(context.Background(), cfg, func(ctx context.Context, attempt int) (*http.Response, error) {
attempts++
// Always return 503
return &http.Response{
StatusCode: 503,
Body: io.NopCloser(strings.NewReader("unavailable")),
}, nil
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if attempts != 2 {
t.Errorf("expected 2 attempts (max), got %d", attempts)
}
if resp.StatusCode != 503 {
t.Errorf("expected status 503, got %d", resp.StatusCode)
}
resp.Body.Close()
}
func TestRetryWithContext(t *testing.T) {
cfg := &RetryConfig{MaxAttempts: 10}
attempts := 0
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Cancel after a short delay
go func() {
time.Sleep(50 * time.Millisecond)
cancel()
}()
resp, err := DoRetry(ctx, cfg, func(ctx context.Context, attempt int) (*http.Response, error) {
attempts++
time.Sleep(30 * time.Millisecond)
return &http.Response{
StatusCode: 503,
Body: io.NopCloser(strings.NewReader("unavailable")),
}, nil
})
if err != context.Canceled {
t.Errorf("expected context.Canceled error, got: %v", err)
}
if resp != nil {
resp.Body.Close()
}
// Should have fewer than all attempts due to cancellation
if attempts >= 10 {
t.Errorf("expected fewer than 10 attempts due to cancellation, got %d", attempts)
}
}
func TestRetryPolicyShouldRetry(t *testing.T) {
policy := &RetryPolicy{Retryable: true}
resp503 := &http.Response{StatusCode: 503}
if !policy.ShouldRetry(resp503) {
t.Errorf("expected to retry on 503")
}
resp200 := &http.Response{StatusCode: 200}
if policy.ShouldRetry(resp200) {
t.Errorf("expected not to retry on 200")
}
resp404 := &http.Response{StatusCode: 404}
if policy.ShouldRetry(resp404) {
t.Errorf("expected not to retry on 404")
}
policyNoRetry := &RetryPolicy{Retryable: false}
if policyNoRetry.ShouldRetry(resp503) {
t.Errorf("expected not to retry when retryable=false")
}
}
+34
View File
@@ -0,0 +1,34 @@
package serviceadapter
// WorkflowAdapter handles X-Service: workflow requests.
type WorkflowAdapter struct{}
// SQSAdapter handles X-Service: sqs requests.
type SQSAdapter struct{}
// S3Adapter handles X-Service: s3 requests.
type S3Adapter struct{}
// IAMAdapter handles X-Service: iam requests.
type IAMAdapter struct{}
// MemoryAdapter handles X-Service: memory requests.
type MemoryAdapter struct{}
// AdapterFactory creates adapters by type.
func AdapterFactory(serviceName string) interface{} {
switch serviceName {
case "workflow":
return &WorkflowAdapter{}
case "sqs":
return &SQSAdapter{}
case "s3":
return &S3Adapter{}
case "iam":
return &IAMAdapter{}
case "memory":
return &MemoryAdapter{}
default:
return nil
}
}
+176
View File
@@ -0,0 +1,176 @@
package serviceadapter
import (
"fmt"
"strings"
"sync"
"time"
)
// Registry holds all loaded ServiceAdapters indexed by serviceName.
type Registry struct {
mu sync.RWMutex
adapters map[string]*ServiceAdapter
logger Logger
}
// Logger interface for flexible logging.
type Logger interface {
Infof(format string, args ...interface{})
Errorf(format string, args ...interface{})
}
// SimpleLogger logs to stdout/stderr.
type SimpleLogger struct{}
func (l *SimpleLogger) Infof(format string, args ...interface{}) {
fmt.Printf("[INFO] "+format+"\n", args...)
}
func (l *SimpleLogger) Errorf(format string, args ...interface{}) {
fmt.Printf("[ERROR] "+format+"\n", args...)
}
// NewRegistry creates a new ServiceAdapter registry.
func NewRegistry(logger Logger) *Registry {
if logger == nil {
logger = &SimpleLogger{}
}
return &Registry{
adapters: make(map[string]*ServiceAdapter),
logger: logger,
}
}
// Add adds or updates a ServiceAdapter in the registry.
// Malformed schemas are logged but don't crash the registry.
func (r *Registry) Add(adapter *ServiceAdapter) error {
r.mu.Lock()
defer r.mu.Unlock()
// Validate schemas (basic check - real validation in 8.3)
if err := r.validateSchemas(adapter); err != nil {
r.logger.Errorf("adapter %s has invalid schema: %v, skipping", adapter.Namespace+"/"+adapter.ServiceName, err)
return nil // Don't crash, just skip
}
r.logger.Infof("adding/updating ServiceAdapter %s/%s", adapter.Namespace, adapter.ServiceName)
adapter.CreatedAt = time.Now()
r.adapters[adapter.ServiceName] = adapter
return nil
}
// Update updates an existing ServiceAdapter.
func (r *Registry) Update(adapter *ServiceAdapter) error {
return r.Add(adapter)
}
// Delete removes a ServiceAdapter from the registry.
func (r *Registry) Delete(serviceName string) {
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.adapters[serviceName]; ok {
r.logger.Infof("deleting ServiceAdapter %s", serviceName)
delete(r.adapters, serviceName)
}
}
// Get returns a ServiceAdapter by name.
func (r *Registry) Get(serviceName string) *ServiceAdapter {
r.mu.RLock()
defer r.mu.RUnlock()
return r.adapters[serviceName]
}
// List returns all ServiceAdapters.
func (r *Registry) List() []*ServiceAdapter {
r.mu.RLock()
defer r.mu.RUnlock()
result := make([]*ServiceAdapter, 0, len(r.adapters))
for _, adapter := range r.adapters {
result = append(result, adapter)
}
return result
}
// Count returns the number of registered adapters.
func (r *Registry) Count() int {
r.mu.RLock()
defer r.mu.RUnlock()
return len(r.adapters)
}
// validateSchemas checks for malformed requestSchema/responseSchema.
// Real validation is in 8.3 (flat KV+type DSL parser).
func (r *Registry) validateSchemas(adapter *ServiceAdapter) error {
for _, res := range adapter.Spec.Resources {
for _, method := range res.Methods {
// Basic validation: schemas shouldn't contain obviously malformed patterns
if method.RequestSchema != "" {
if err := basicSchemaCheck(method.RequestSchema); err != nil {
return fmt.Errorf("resource %s method %s requestSchema: %w", res.Name, method.Verb, err)
}
}
if method.ResponseSchema != "" {
if err := basicSchemaCheck(method.ResponseSchema); err != nil {
return fmt.Errorf("resource %s method %s responseSchema: %w", res.Name, method.Verb, err)
}
}
}
}
return nil
}
// basicSchemaCheck does a simple sanity check on schema strings.
// Real parsing is in 8.3.
func basicSchemaCheck(schema string) error {
if schema == "" {
return nil
}
// Reject obviously invalid patterns
if strings.Contains(schema, "{{") && !strings.Contains(schema, "}}") {
return fmt.Errorf("unclosed template braces")
}
if strings.Count(schema, "(") != strings.Count(schema, ")") {
return fmt.Errorf("mismatched parentheses")
}
return nil
}
// MockLogger for testing.
type MockLogger struct {
entries []string
mu sync.Mutex
}
func (l *MockLogger) Infof(format string, args ...interface{}) {
l.mu.Lock()
defer l.mu.Unlock()
l.entries = append(l.entries, fmt.Sprintf("[INFO] "+format, args...))
}
func (l *MockLogger) Errorf(format string, args ...interface{}) {
l.mu.Lock()
defer l.mu.Unlock()
l.entries = append(l.entries, fmt.Sprintf("[ERROR] "+format, args...))
}
func (l *MockLogger) Entries() []string {
l.mu.Lock()
defer l.mu.Unlock()
result := make([]string, len(l.entries))
copy(result, l.entries)
return result
}
func (l *MockLogger) Clear() {
l.mu.Lock()
defer l.mu.Unlock()
l.entries = nil
}
+242
View File
@@ -0,0 +1,242 @@
package serviceadapter
import (
"strings"
"testing"
)
func TestRegistryAdd(t *testing.T) {
logger := &MockLogger{}
reg := NewRegistry(logger)
adapter := &ServiceAdapter{
Namespace: "api",
ServiceName: "test-service",
Spec: Spec{
ServiceName: "test-service",
Upstream: Upstream{
URL: "http://example.com",
TimeoutSeconds: 30,
},
Auth: Auth{
Required: false,
},
Resources: []Resource{
{
Name: "default",
Methods: []Method{
{
Verb: "POST",
UpstreamPath: "/api",
},
},
},
},
},
}
err := reg.Add(adapter)
if err != nil {
t.Fatalf("Add failed: %v", err)
}
retrieved := reg.Get("test-service")
if retrieved == nil {
t.Errorf("expected adapter to be retrievable")
}
if retrieved.ServiceName != "test-service" {
t.Errorf("expected service name test-service, got %s", retrieved.ServiceName)
}
}
func TestRegistryDelete(t *testing.T) {
logger := &MockLogger{}
reg := NewRegistry(logger)
adapter := &ServiceAdapter{
Namespace: "api",
ServiceName: "to-delete",
Spec: Spec{
ServiceName: "to-delete",
Upstream: Upstream{
URL: "http://example.com",
TimeoutSeconds: 30,
},
Auth: Auth{Required: false},
Resources: []Resource{
{
Name: "default",
Methods: []Method{
{Verb: "GET", UpstreamPath: "/"},
},
},
},
},
}
reg.Add(adapter)
if reg.Count() != 1 {
t.Errorf("expected count 1 after add, got %d", reg.Count())
}
reg.Delete("to-delete")
if reg.Count() != 0 {
t.Errorf("expected count 0 after delete, got %d", reg.Count())
}
if reg.Get("to-delete") != nil {
t.Errorf("expected deleted adapter to be nil")
}
}
func TestRegistryMalformedSchema(t *testing.T) {
logger := &MockLogger{}
reg := NewRegistry(logger)
adapter := &ServiceAdapter{
Namespace: "api",
ServiceName: "bad-schema",
Spec: Spec{
ServiceName: "bad-schema",
Upstream: Upstream{
URL: "http://example.com",
TimeoutSeconds: 30,
},
Auth: Auth{Required: false},
Resources: []Resource{
{
Name: "default",
Methods: []Method{
{
Verb: "POST",
UpstreamPath: "/",
RequestSchema: "{{ unclosed", // malformed
},
},
},
},
},
}
// Should not crash, should log error
err := reg.Add(adapter)
if err != nil {
t.Fatalf("Add should not return error (should skip malformed), got: %v", err)
}
// Adapter should be skipped (not added)
if reg.Get("bad-schema") != nil {
t.Errorf("expected malformed adapter to be skipped")
}
// Should have logged an error
entries := logger.Entries()
errorLogged := false
for _, entry := range entries {
if strings.Contains(entry, "invalid schema") {
errorLogged = true
break
}
}
if !errorLogged {
t.Errorf("expected error to be logged for malformed schema")
}
}
func TestRegistryList(t *testing.T) {
logger := &MockLogger{}
reg := NewRegistry(logger)
for i := 0; i < 3; i++ {
adapter := &ServiceAdapter{
Namespace: "api",
ServiceName: "service-" + string(rune('1'+i)),
Spec: Spec{
ServiceName: "service-" + string(rune('1'+i)),
Upstream: Upstream{
URL: "http://example.com",
TimeoutSeconds: 30,
},
Auth: Auth{Required: false},
Resources: []Resource{},
},
}
reg.Add(adapter)
}
list := reg.List()
if len(list) != 3 {
t.Errorf("expected 3 adapters, got %d", len(list))
}
}
func TestRegistryThreadSafety(t *testing.T) {
logger := &MockLogger{}
reg := NewRegistry(logger)
done := make(chan bool, 2)
// Writer goroutine
go func() {
for i := 0; i < 10; i++ {
adapter := &ServiceAdapter{
Namespace: "api",
ServiceName: "writer-service",
Spec: Spec{
ServiceName: "writer-service",
Upstream: Upstream{
URL: "http://example.com",
TimeoutSeconds: 30,
},
Auth: Auth{Required: false},
Resources: []Resource{},
},
}
reg.Add(adapter)
}
done <- true
}()
// Reader goroutine
go func() {
for i := 0; i < 10; i++ {
_ = reg.Get("writer-service")
_ = reg.List()
_ = reg.Count()
}
done <- true
}()
<-done
<-done
if reg.Count() != 1 {
t.Errorf("expected 1 adapter after concurrent access, got %d", reg.Count())
}
}
func TestBasicSchemaCheck(t *testing.T) {
tests := []struct {
name string
schema string
valid bool
}{
{"empty", "", true},
{"valid", "key1: string, key2: int", true},
{"unclosed braces", "{{ unclosed", false},
{"mismatched parens", "func(arg", false},
{"balanced parens", "func(arg)", true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := basicSchemaCheck(tc.schema)
if tc.valid && err != nil {
t.Errorf("expected valid schema to pass, got: %v", err)
}
if !tc.valid && err == nil {
t.Errorf("expected invalid schema to fail")
}
})
}
}
+125
View File
@@ -0,0 +1,125 @@
package serviceadapter
import (
"fmt"
"net/http"
"forgejo.riotpiao.com/rock/homelab-frontend/internal/problem"
)
// Dispatcher handles X-Service based routing to service adapters.
type Dispatcher struct {
registry *Registry
// authValidator would check capabilities if internal/auth exists
// For now, we stub it
}
// NewDispatcher creates a new service adapter dispatcher.
func NewDispatcher(registry *Registry) *Dispatcher {
return &Dispatcher{
registry: registry,
}
}
// Matches returns true if the request should be dispatched based on X-Service header.
func (d *Dispatcher) Matches(r *http.Request) bool {
return r.Header.Get("X-Service") != ""
}
// Dispatch routes a request to the appropriate adapter.
// Returns a problem document if the adapter or resource is not found.
func (d *Dispatcher) Dispatch(w http.ResponseWriter, r *http.Request) {
serviceName := r.Header.Get("X-Service")
if serviceName == "" {
// No X-Service header — this shouldn't happen if Matches() was called
d.writeError(w, problem.BadRequest("X-Service header required"))
return
}
// Look up service adapter
adapter := d.registry.Get(serviceName)
if adapter == nil {
p := problem.NotFound(fmt.Sprintf("service '%s' not found", serviceName))
_ = p.Write(w)
return
}
// Get resource and method from request
resourceName := r.Header.Get("X-Resource")
if resourceName == "" {
d.writeError(w, problem.BadRequest("X-Resource header required"))
return
}
// Find resource
var resource *Resource
for i := range adapter.Spec.Resources {
if adapter.Spec.Resources[i].Name == resourceName {
resource = &adapter.Spec.Resources[i]
break
}
}
if resource == nil {
p := problem.NotFound(fmt.Sprintf("resource '%s' not found in service '%s'", resourceName, serviceName))
_ = p.Write(w)
return
}
// Find method matching HTTP verb
var method *Method
for i := range resource.Methods {
if resource.Methods[i].Verb == r.Method {
method = &resource.Methods[i]
break
}
}
if method == nil {
p := problem.NotFound(fmt.Sprintf("method %s not defined for resource '%s'", r.Method, resourceName))
_ = p.Write(w)
return
}
// Check auth requirements (stub for now — internal/auth integration in 8.3)
// Determine required capability
requiredCapability := ""
auth := resource.Auth
if auth == nil {
auth = &adapter.Spec.Auth
}
if method.Auth != nil {
auth = method.Auth
}
if auth != nil && auth.Required && auth.Capability != "" {
requiredCapability = auth.Capability
// Would validate JWT and capability here (depends on internal/auth)
// For now, stub — just log that it would be checked
if !d.hasCapability(r, requiredCapability) {
p := problem.NewProblem(http.StatusForbidden, "about:blank#forbidden",
"Forbidden", fmt.Sprintf("capability '%s' required", requiredCapability))
_ = p.Write(w)
return
}
}
// TODO: Call upstream with method.UpstreamPath, apply retry logic, etc.
// For now, just echo that dispatch would happen
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, `{"service":"%s","resource":"%s","method":"%s","upstream":"%s"}`,
serviceName, resourceName, r.Method, adapter.Spec.Upstream.URL)
}
// hasCapability checks if the request has the required capability.
// Stub implementation — depends on internal/auth JWT validation.
func (d *Dispatcher) hasCapability(r *http.Request, capability string) bool {
// TODO: Parse JWT from Authorization header and check capabilities
// For now, assume all authenticated requests have all capabilities
return r.Header.Get("Authorization") != ""
}
func (d *Dispatcher) writeError(w http.ResponseWriter, p *problem.Problem) {
_ = p.Write(w)
}
+59
View File
@@ -0,0 +1,59 @@
package serviceadapter
import (
"time"
)
// Upstream defines an upstream target.
type Upstream struct {
URL string `json:"url"`
TimeoutSeconds int32 `json:"timeoutSeconds"`
}
// Auth defines authentication requirements.
type Auth struct {
Required bool `json:"required"`
Capability string `json:"capability,omitempty"`
}
// Method defines an HTTP method endpoint.
type Method struct {
Verb string `json:"verb"`
UpstreamPath string `json:"upstreamPath"`
RequestSchema string `json:"requestSchema,omitempty"`
ResponseSchema string `json:"responseSchema,omitempty"`
Auth *Auth `json:"auth,omitempty"`
}
// Resource defines a resource with multiple methods.
type Resource struct {
Name string `json:"name"`
Methods []Method `json:"methods"`
Auth *Auth `json:"auth,omitempty"`
}
// Spec is the ServiceAdapter spec.
type Spec struct {
ServiceName string `json:"serviceName"`
Upstream Upstream `json:"upstream"`
Auth Auth `json:"auth"`
Retryable bool `json:"retryable,omitempty"`
Resources []Resource `json:"resources"`
}
// Status is the ServiceAdapter status.
type Status struct {
Ready bool `json:"ready,omitempty"`
Error string `json:"error,omitempty"`
LastSyncTime *time.Time `json:"lastSyncTime,omitempty"`
}
// ServiceAdapter is a gateway service adapter.
type ServiceAdapter struct {
Name string // namespace/name
Namespace string
ServiceName string
Spec Spec
Status Status
CreatedAt time.Time
}
+181
View File
@@ -0,0 +1,181 @@
package serviceadapter
import (
"fmt"
"strings"
)
// FieldSchema describes validation schema for a field or request/response body.
type FieldSchema struct {
Type string `json:"type"` // string, number, boolean, array, object
Nullable bool `json:"nullable"` // accept null values
Strict bool `json:"strict"` // reject unknown fields (object only)
Required []string `json:"required"` // required field names (object only)
Fields map[string]FieldSchema `json:"fields"` // field schemas (object only)
Items *FieldSchema `json:"items"` // item schema (array only)
}
// ValidationError describes a single validation failure.
type ValidationError struct {
Field string
Reason string
}
// Validator validates bodies against a schema.
type Validator struct {
schema *FieldSchema
}
// NewValidator creates a new validator for a schema.
func NewValidator(schemaStr string) (*Validator, error) {
if schemaStr == "" {
return nil, nil // No validation
}
schema, err := parseSchema(schemaStr)
if err != nil {
return nil, err
}
return &Validator{schema: schema}, nil
}
// Validate validates a body (map or []interface{}) against the schema.
func (v *Validator) Validate(body interface{}) []ValidationError {
if v == nil || v.schema == nil {
return nil
}
return v.validateValue(body, v.schema, "")
}
func (v *Validator) validateValue(value interface{}, schema *FieldSchema, path string) []ValidationError {
var errors []ValidationError
// Handle null
if value == nil {
if !schema.Nullable {
errors = append(errors, ValidationError{
Field: path,
Reason: "null not allowed",
})
}
return errors
}
switch schema.Type {
case "object":
obj, ok := value.(map[string]interface{})
if !ok {
return []ValidationError{{
Field: path,
Reason: fmt.Sprintf("type_mismatch: want object got %T", value),
}}
}
// Check required fields
for _, required := range schema.Required {
if _, ok := obj[required]; !ok {
errors = append(errors, ValidationError{
Field: required,
Reason: "missing",
})
}
}
// Check field types
for fieldName, fieldValue := range obj {
if fieldSchema, ok := schema.Fields[fieldName]; ok {
errors = append(errors, v.validateValue(fieldValue, &fieldSchema, fieldName)...)
} else if schema.Strict {
errors = append(errors, ValidationError{
Field: fieldName,
Reason: "unknown_field",
})
}
}
case "array":
arr, ok := value.([]interface{})
if !ok {
return []ValidationError{{
Field: path,
Reason: fmt.Sprintf("type_mismatch: want array got %T", value),
}}
}
if schema.Items != nil {
for i, item := range arr {
itemPath := fmt.Sprintf("%s[%d]", path, i)
errors = append(errors, v.validateValue(item, schema.Items, itemPath)...)
}
}
case "string":
if _, ok := value.(string); !ok {
return []ValidationError{{
Field: path,
Reason: fmt.Sprintf("type_mismatch: want string got %T", value),
}}
}
case "number":
switch value.(type) {
case float64, int, int32, int64:
// OK
default:
return []ValidationError{{
Field: path,
Reason: fmt.Sprintf("type_mismatch: want number got %T", value),
}}
}
case "boolean":
if _, ok := value.(bool); !ok {
return []ValidationError{{
Field: path,
Reason: fmt.Sprintf("type_mismatch: want boolean got %T", value),
}}
}
}
return errors
}
// parseSchema parses a simple schema DSL (flat key:type format for now).
// Real DSL defined in design doc — stub implementation here.
func parseSchema(schemaStr string) (*FieldSchema, error) {
if strings.TrimSpace(schemaStr) == "" {
return nil, nil
}
// Stub: for now accept any non-empty schema and validate as permissive object
schema := &FieldSchema{
Type: "object",
Fields: make(map[string]FieldSchema),
}
// Very basic parsing: "field1: string, field2: number"
parts := strings.Split(schemaStr, ",")
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
kv := strings.Split(part, ":")
if len(kv) != 2 {
continue
}
fieldName := strings.TrimSpace(kv[0])
fieldType := strings.TrimSpace(kv[1])
schema.Fields[fieldName] = FieldSchema{
Type: fieldType,
Nullable: false,
}
}
return schema, nil
}
+216
View File
@@ -0,0 +1,216 @@
package serviceadapter
import (
"testing"
)
func TestValidateString(t *testing.T) {
schema := &FieldSchema{Type: "string"}
v := &Validator{schema: schema}
errs := v.Validate("hello")
if len(errs) != 0 {
t.Errorf("expected no errors for valid string, got %v", errs)
}
errs = v.Validate(42)
if len(errs) == 0 {
t.Errorf("expected error for non-string")
}
if len(errs) > 0 && !stringContains(errs[0].Reason, "type_mismatch") {
t.Errorf("expected type_mismatch error, got %s", errs[0].Reason)
}
}
func TestValidateNumber(t *testing.T) {
schema := &FieldSchema{Type: "number"}
v := &Validator{schema: schema}
errs := v.Validate(42.0)
if len(errs) != 0 {
t.Errorf("expected no errors for float64, got %v", errs)
}
errs = v.Validate("not a number")
if len(errs) == 0 {
t.Errorf("expected error for non-number")
}
}
func TestValidateNullable(t *testing.T) {
schemaNullable := &FieldSchema{Type: "string", Nullable: true}
vNullable := &Validator{schema: schemaNullable}
errs := vNullable.Validate(nil)
if len(errs) != 0 {
t.Errorf("expected no errors for null on nullable field, got %v", errs)
}
schemaNotNullable := &FieldSchema{Type: "string", Nullable: false}
vNotNullable := &Validator{schema: schemaNotNullable}
errs = vNotNullable.Validate(nil)
if len(errs) == 0 {
t.Errorf("expected error for null on non-nullable field")
}
}
func TestValidateObject(t *testing.T) {
schema := &FieldSchema{
Type: "object",
Required: []string{"name"},
Fields: map[string]FieldSchema{
"name": {Type: "string"},
"age": {Type: "number"},
},
}
v := &Validator{schema: schema}
// Valid object
obj := map[string]interface{}{
"name": "Alice",
"age": 30.0,
}
errs := v.Validate(obj)
if len(errs) != 0 {
t.Errorf("expected no errors for valid object, got %v", errs)
}
// Missing required field
objMissing := map[string]interface{}{
"age": 30.0,
}
errs = v.Validate(objMissing)
if len(errs) == 0 {
t.Errorf("expected error for missing required field")
}
if len(errs) > 0 && errs[0].Reason != "missing" {
t.Errorf("expected 'missing' error, got %s", errs[0].Reason)
}
// Type mismatch
objBadType := map[string]interface{}{
"name": "Alice",
"age": "thirty",
}
errs = v.Validate(objBadType)
if len(errs) == 0 {
t.Errorf("expected error for type mismatch")
}
}
func TestValidateObjectStrict(t *testing.T) {
schema := &FieldSchema{
Type: "object",
Strict: true,
Fields: map[string]FieldSchema{
"name": {Type: "string"},
},
}
v := &Validator{schema: schema}
// Unknown field rejected in strict mode
obj := map[string]interface{}{
"name": "Alice",
"unknown": "field",
}
errs := v.Validate(obj)
if len(errs) == 0 {
t.Errorf("expected error for unknown field in strict mode")
}
found := false
for _, err := range errs {
if err.Reason == "unknown_field" {
found = true
break
}
}
if !found {
t.Errorf("expected unknown_field error")
}
}
func TestValidateArray(t *testing.T) {
schema := &FieldSchema{
Type: "array",
Items: &FieldSchema{
Type: "string",
},
}
v := &Validator{schema: schema}
// Valid array
arr := []interface{}{"a", "b", "c"}
errs := v.Validate(arr)
if len(errs) != 0 {
t.Errorf("expected no errors for valid string array, got %v", errs)
}
// Invalid element type
arrBad := []interface{}{"a", 42, "c"}
errs = v.Validate(arrBad)
if len(errs) == 0 {
t.Errorf("expected error for wrong type in array")
}
}
func TestValidateArrayOfObjects(t *testing.T) {
schema := &FieldSchema{
Type: "array",
Items: &FieldSchema{
Type: "object",
Fields: map[string]FieldSchema{
"id": {Type: "number"},
"name": {Type: "string"},
},
},
}
v := &Validator{schema: schema}
arr := []interface{}{
map[string]interface{}{"id": 1.0, "name": "Alice"},
map[string]interface{}{"id": 2.0, "name": "Bob"},
}
errs := v.Validate(arr)
if len(errs) != 0 {
t.Errorf("expected no errors for valid array of objects, got %v", errs)
}
}
func TestValidateNoSchema(t *testing.T) {
// No schema means no validation
v := &Validator{schema: nil}
errs := v.Validate(map[string]interface{}{"anything": "goes"})
if len(errs) != 0 {
t.Errorf("expected no errors when schema is nil")
}
}
func TestParseSchema(t *testing.T) {
schema, err := parseSchema("name: string, age: number")
if err != nil {
t.Fatalf("parse error: %v", err)
}
if schema.Type != "object" {
t.Errorf("expected type object, got %s", schema.Type)
}
if len(schema.Fields) != 2 {
t.Errorf("expected 2 fields, got %d", len(schema.Fields))
}
if f, ok := schema.Fields["name"]; !ok || f.Type != "string" {
t.Errorf("expected name: string in parsed schema")
}
}
func stringContains(s, substr string) bool {
for i := 0; i < len(s); i++ {
if i+len(substr) <= len(s) && s[i:i+len(substr)] == substr {
return true
}
}
return false
}