feat: phase 8 serviceadapter crd rollout (32/33 tasks)
This commit is contained in:
@@ -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