feat: phase 8 serviceadapter crd rollout (32/33 tasks)
This commit is contained in:
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user