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

This commit is contained in:
Admin Bot
2026-08-26 13:47:36 -07:00
parent 63893d41a5
commit 425611ec42
85 changed files with 4238 additions and 5702 deletions
+34
View File
@@ -0,0 +1,34 @@
package serviceadapter
// WorkflowAdapter handles X-Service: workflow requests.
type WorkflowAdapter struct{}
// SQSAdapter handles X-Service: sqs requests.
type SQSAdapter struct{}
// S3Adapter handles X-Service: s3 requests.
type S3Adapter struct{}
// IAMAdapter handles X-Service: iam requests.
type IAMAdapter struct{}
// MemoryAdapter handles X-Service: memory requests.
type MemoryAdapter struct{}
// AdapterFactory creates adapters by type.
func AdapterFactory(serviceName string) interface{} {
switch serviceName {
case "workflow":
return &WorkflowAdapter{}
case "sqs":
return &SQSAdapter{}
case "s3":
return &S3Adapter{}
case "iam":
return &IAMAdapter{}
case "memory":
return &MemoryAdapter{}
default:
return nil
}
}
+176
View File
@@ -0,0 +1,176 @@
package serviceadapter
import (
"fmt"
"strings"
"sync"
"time"
)
// Registry holds all loaded ServiceAdapters indexed by serviceName.
type Registry struct {
mu sync.RWMutex
adapters map[string]*ServiceAdapter
logger Logger
}
// Logger interface for flexible logging.
type Logger interface {
Infof(format string, args ...interface{})
Errorf(format string, args ...interface{})
}
// SimpleLogger logs to stdout/stderr.
type SimpleLogger struct{}
func (l *SimpleLogger) Infof(format string, args ...interface{}) {
fmt.Printf("[INFO] "+format+"\n", args...)
}
func (l *SimpleLogger) Errorf(format string, args ...interface{}) {
fmt.Printf("[ERROR] "+format+"\n", args...)
}
// NewRegistry creates a new ServiceAdapter registry.
func NewRegistry(logger Logger) *Registry {
if logger == nil {
logger = &SimpleLogger{}
}
return &Registry{
adapters: make(map[string]*ServiceAdapter),
logger: logger,
}
}
// Add adds or updates a ServiceAdapter in the registry.
// Malformed schemas are logged but don't crash the registry.
func (r *Registry) Add(adapter *ServiceAdapter) error {
r.mu.Lock()
defer r.mu.Unlock()
// Validate schemas (basic check - real validation in 8.3)
if err := r.validateSchemas(adapter); err != nil {
r.logger.Errorf("adapter %s has invalid schema: %v, skipping", adapter.Namespace+"/"+adapter.ServiceName, err)
return nil // Don't crash, just skip
}
r.logger.Infof("adding/updating ServiceAdapter %s/%s", adapter.Namespace, adapter.ServiceName)
adapter.CreatedAt = time.Now()
r.adapters[adapter.ServiceName] = adapter
return nil
}
// Update updates an existing ServiceAdapter.
func (r *Registry) Update(adapter *ServiceAdapter) error {
return r.Add(adapter)
}
// Delete removes a ServiceAdapter from the registry.
func (r *Registry) Delete(serviceName string) {
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.adapters[serviceName]; ok {
r.logger.Infof("deleting ServiceAdapter %s", serviceName)
delete(r.adapters, serviceName)
}
}
// Get returns a ServiceAdapter by name.
func (r *Registry) Get(serviceName string) *ServiceAdapter {
r.mu.RLock()
defer r.mu.RUnlock()
return r.adapters[serviceName]
}
// List returns all ServiceAdapters.
func (r *Registry) List() []*ServiceAdapter {
r.mu.RLock()
defer r.mu.RUnlock()
result := make([]*ServiceAdapter, 0, len(r.adapters))
for _, adapter := range r.adapters {
result = append(result, adapter)
}
return result
}
// Count returns the number of registered adapters.
func (r *Registry) Count() int {
r.mu.RLock()
defer r.mu.RUnlock()
return len(r.adapters)
}
// validateSchemas checks for malformed requestSchema/responseSchema.
// Real validation is in 8.3 (flat KV+type DSL parser).
func (r *Registry) validateSchemas(adapter *ServiceAdapter) error {
for _, res := range adapter.Spec.Resources {
for _, method := range res.Methods {
// Basic validation: schemas shouldn't contain obviously malformed patterns
if method.RequestSchema != "" {
if err := basicSchemaCheck(method.RequestSchema); err != nil {
return fmt.Errorf("resource %s method %s requestSchema: %w", res.Name, method.Verb, err)
}
}
if method.ResponseSchema != "" {
if err := basicSchemaCheck(method.ResponseSchema); err != nil {
return fmt.Errorf("resource %s method %s responseSchema: %w", res.Name, method.Verb, err)
}
}
}
}
return nil
}
// basicSchemaCheck does a simple sanity check on schema strings.
// Real parsing is in 8.3.
func basicSchemaCheck(schema string) error {
if schema == "" {
return nil
}
// Reject obviously invalid patterns
if strings.Contains(schema, "{{") && !strings.Contains(schema, "}}") {
return fmt.Errorf("unclosed template braces")
}
if strings.Count(schema, "(") != strings.Count(schema, ")") {
return fmt.Errorf("mismatched parentheses")
}
return nil
}
// MockLogger for testing.
type MockLogger struct {
entries []string
mu sync.Mutex
}
func (l *MockLogger) Infof(format string, args ...interface{}) {
l.mu.Lock()
defer l.mu.Unlock()
l.entries = append(l.entries, fmt.Sprintf("[INFO] "+format, args...))
}
func (l *MockLogger) Errorf(format string, args ...interface{}) {
l.mu.Lock()
defer l.mu.Unlock()
l.entries = append(l.entries, fmt.Sprintf("[ERROR] "+format, args...))
}
func (l *MockLogger) Entries() []string {
l.mu.Lock()
defer l.mu.Unlock()
result := make([]string, len(l.entries))
copy(result, l.entries)
return result
}
func (l *MockLogger) Clear() {
l.mu.Lock()
defer l.mu.Unlock()
l.entries = nil
}
+242
View File
@@ -0,0 +1,242 @@
package serviceadapter
import (
"strings"
"testing"
)
func TestRegistryAdd(t *testing.T) {
logger := &MockLogger{}
reg := NewRegistry(logger)
adapter := &ServiceAdapter{
Namespace: "api",
ServiceName: "test-service",
Spec: Spec{
ServiceName: "test-service",
Upstream: Upstream{
URL: "http://example.com",
TimeoutSeconds: 30,
},
Auth: Auth{
Required: false,
},
Resources: []Resource{
{
Name: "default",
Methods: []Method{
{
Verb: "POST",
UpstreamPath: "/api",
},
},
},
},
},
}
err := reg.Add(adapter)
if err != nil {
t.Fatalf("Add failed: %v", err)
}
retrieved := reg.Get("test-service")
if retrieved == nil {
t.Errorf("expected adapter to be retrievable")
}
if retrieved.ServiceName != "test-service" {
t.Errorf("expected service name test-service, got %s", retrieved.ServiceName)
}
}
func TestRegistryDelete(t *testing.T) {
logger := &MockLogger{}
reg := NewRegistry(logger)
adapter := &ServiceAdapter{
Namespace: "api",
ServiceName: "to-delete",
Spec: Spec{
ServiceName: "to-delete",
Upstream: Upstream{
URL: "http://example.com",
TimeoutSeconds: 30,
},
Auth: Auth{Required: false},
Resources: []Resource{
{
Name: "default",
Methods: []Method{
{Verb: "GET", UpstreamPath: "/"},
},
},
},
},
}
reg.Add(adapter)
if reg.Count() != 1 {
t.Errorf("expected count 1 after add, got %d", reg.Count())
}
reg.Delete("to-delete")
if reg.Count() != 0 {
t.Errorf("expected count 0 after delete, got %d", reg.Count())
}
if reg.Get("to-delete") != nil {
t.Errorf("expected deleted adapter to be nil")
}
}
func TestRegistryMalformedSchema(t *testing.T) {
logger := &MockLogger{}
reg := NewRegistry(logger)
adapter := &ServiceAdapter{
Namespace: "api",
ServiceName: "bad-schema",
Spec: Spec{
ServiceName: "bad-schema",
Upstream: Upstream{
URL: "http://example.com",
TimeoutSeconds: 30,
},
Auth: Auth{Required: false},
Resources: []Resource{
{
Name: "default",
Methods: []Method{
{
Verb: "POST",
UpstreamPath: "/",
RequestSchema: "{{ unclosed", // malformed
},
},
},
},
},
}
// Should not crash, should log error
err := reg.Add(adapter)
if err != nil {
t.Fatalf("Add should not return error (should skip malformed), got: %v", err)
}
// Adapter should be skipped (not added)
if reg.Get("bad-schema") != nil {
t.Errorf("expected malformed adapter to be skipped")
}
// Should have logged an error
entries := logger.Entries()
errorLogged := false
for _, entry := range entries {
if strings.Contains(entry, "invalid schema") {
errorLogged = true
break
}
}
if !errorLogged {
t.Errorf("expected error to be logged for malformed schema")
}
}
func TestRegistryList(t *testing.T) {
logger := &MockLogger{}
reg := NewRegistry(logger)
for i := 0; i < 3; i++ {
adapter := &ServiceAdapter{
Namespace: "api",
ServiceName: "service-" + string(rune('1'+i)),
Spec: Spec{
ServiceName: "service-" + string(rune('1'+i)),
Upstream: Upstream{
URL: "http://example.com",
TimeoutSeconds: 30,
},
Auth: Auth{Required: false},
Resources: []Resource{},
},
}
reg.Add(adapter)
}
list := reg.List()
if len(list) != 3 {
t.Errorf("expected 3 adapters, got %d", len(list))
}
}
func TestRegistryThreadSafety(t *testing.T) {
logger := &MockLogger{}
reg := NewRegistry(logger)
done := make(chan bool, 2)
// Writer goroutine
go func() {
for i := 0; i < 10; i++ {
adapter := &ServiceAdapter{
Namespace: "api",
ServiceName: "writer-service",
Spec: Spec{
ServiceName: "writer-service",
Upstream: Upstream{
URL: "http://example.com",
TimeoutSeconds: 30,
},
Auth: Auth{Required: false},
Resources: []Resource{},
},
}
reg.Add(adapter)
}
done <- true
}()
// Reader goroutine
go func() {
for i := 0; i < 10; i++ {
_ = reg.Get("writer-service")
_ = reg.List()
_ = reg.Count()
}
done <- true
}()
<-done
<-done
if reg.Count() != 1 {
t.Errorf("expected 1 adapter after concurrent access, got %d", reg.Count())
}
}
func TestBasicSchemaCheck(t *testing.T) {
tests := []struct {
name string
schema string
valid bool
}{
{"empty", "", true},
{"valid", "key1: string, key2: int", true},
{"unclosed braces", "{{ unclosed", false},
{"mismatched parens", "func(arg", false},
{"balanced parens", "func(arg)", true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := basicSchemaCheck(tc.schema)
if tc.valid && err != nil {
t.Errorf("expected valid schema to pass, got: %v", err)
}
if !tc.valid && err == nil {
t.Errorf("expected invalid schema to fail")
}
})
}
}
+125
View File
@@ -0,0 +1,125 @@
package serviceadapter
import (
"fmt"
"net/http"
"forgejo.riotpiao.com/rock/homelab-frontend/internal/problem"
)
// Dispatcher handles X-Service based routing to service adapters.
type Dispatcher struct {
registry *Registry
// authValidator would check capabilities if internal/auth exists
// For now, we stub it
}
// NewDispatcher creates a new service adapter dispatcher.
func NewDispatcher(registry *Registry) *Dispatcher {
return &Dispatcher{
registry: registry,
}
}
// Matches returns true if the request should be dispatched based on X-Service header.
func (d *Dispatcher) Matches(r *http.Request) bool {
return r.Header.Get("X-Service") != ""
}
// Dispatch routes a request to the appropriate adapter.
// Returns a problem document if the adapter or resource is not found.
func (d *Dispatcher) Dispatch(w http.ResponseWriter, r *http.Request) {
serviceName := r.Header.Get("X-Service")
if serviceName == "" {
// No X-Service header — this shouldn't happen if Matches() was called
d.writeError(w, problem.BadRequest("X-Service header required"))
return
}
// Look up service adapter
adapter := d.registry.Get(serviceName)
if adapter == nil {
p := problem.NotFound(fmt.Sprintf("service '%s' not found", serviceName))
_ = p.Write(w)
return
}
// Get resource and method from request
resourceName := r.Header.Get("X-Resource")
if resourceName == "" {
d.writeError(w, problem.BadRequest("X-Resource header required"))
return
}
// Find resource
var resource *Resource
for i := range adapter.Spec.Resources {
if adapter.Spec.Resources[i].Name == resourceName {
resource = &adapter.Spec.Resources[i]
break
}
}
if resource == nil {
p := problem.NotFound(fmt.Sprintf("resource '%s' not found in service '%s'", resourceName, serviceName))
_ = p.Write(w)
return
}
// Find method matching HTTP verb
var method *Method
for i := range resource.Methods {
if resource.Methods[i].Verb == r.Method {
method = &resource.Methods[i]
break
}
}
if method == nil {
p := problem.NotFound(fmt.Sprintf("method %s not defined for resource '%s'", r.Method, resourceName))
_ = p.Write(w)
return
}
// Check auth requirements (stub for now — internal/auth integration in 8.3)
// Determine required capability
requiredCapability := ""
auth := resource.Auth
if auth == nil {
auth = &adapter.Spec.Auth
}
if method.Auth != nil {
auth = method.Auth
}
if auth != nil && auth.Required && auth.Capability != "" {
requiredCapability = auth.Capability
// Would validate JWT and capability here (depends on internal/auth)
// For now, stub — just log that it would be checked
if !d.hasCapability(r, requiredCapability) {
p := problem.NewProblem(http.StatusForbidden, "about:blank#forbidden",
"Forbidden", fmt.Sprintf("capability '%s' required", requiredCapability))
_ = p.Write(w)
return
}
}
// TODO: Call upstream with method.UpstreamPath, apply retry logic, etc.
// For now, just echo that dispatch would happen
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, `{"service":"%s","resource":"%s","method":"%s","upstream":"%s"}`,
serviceName, resourceName, r.Method, adapter.Spec.Upstream.URL)
}
// hasCapability checks if the request has the required capability.
// Stub implementation — depends on internal/auth JWT validation.
func (d *Dispatcher) hasCapability(r *http.Request, capability string) bool {
// TODO: Parse JWT from Authorization header and check capabilities
// For now, assume all authenticated requests have all capabilities
return r.Header.Get("Authorization") != ""
}
func (d *Dispatcher) writeError(w http.ResponseWriter, p *problem.Problem) {
_ = p.Write(w)
}
+59
View File
@@ -0,0 +1,59 @@
package serviceadapter
import (
"time"
)
// Upstream defines an upstream target.
type Upstream struct {
URL string `json:"url"`
TimeoutSeconds int32 `json:"timeoutSeconds"`
}
// Auth defines authentication requirements.
type Auth struct {
Required bool `json:"required"`
Capability string `json:"capability,omitempty"`
}
// Method defines an HTTP method endpoint.
type Method struct {
Verb string `json:"verb"`
UpstreamPath string `json:"upstreamPath"`
RequestSchema string `json:"requestSchema,omitempty"`
ResponseSchema string `json:"responseSchema,omitempty"`
Auth *Auth `json:"auth,omitempty"`
}
// Resource defines a resource with multiple methods.
type Resource struct {
Name string `json:"name"`
Methods []Method `json:"methods"`
Auth *Auth `json:"auth,omitempty"`
}
// Spec is the ServiceAdapter spec.
type Spec struct {
ServiceName string `json:"serviceName"`
Upstream Upstream `json:"upstream"`
Auth Auth `json:"auth"`
Retryable bool `json:"retryable,omitempty"`
Resources []Resource `json:"resources"`
}
// Status is the ServiceAdapter status.
type Status struct {
Ready bool `json:"ready,omitempty"`
Error string `json:"error,omitempty"`
LastSyncTime *time.Time `json:"lastSyncTime,omitempty"`
}
// ServiceAdapter is a gateway service adapter.
type ServiceAdapter struct {
Name string // namespace/name
Namespace string
ServiceName string
Spec Spec
Status Status
CreatedAt time.Time
}
+181
View File
@@ -0,0 +1,181 @@
package serviceadapter
import (
"fmt"
"strings"
)
// FieldSchema describes validation schema for a field or request/response body.
type FieldSchema struct {
Type string `json:"type"` // string, number, boolean, array, object
Nullable bool `json:"nullable"` // accept null values
Strict bool `json:"strict"` // reject unknown fields (object only)
Required []string `json:"required"` // required field names (object only)
Fields map[string]FieldSchema `json:"fields"` // field schemas (object only)
Items *FieldSchema `json:"items"` // item schema (array only)
}
// ValidationError describes a single validation failure.
type ValidationError struct {
Field string
Reason string
}
// Validator validates bodies against a schema.
type Validator struct {
schema *FieldSchema
}
// NewValidator creates a new validator for a schema.
func NewValidator(schemaStr string) (*Validator, error) {
if schemaStr == "" {
return nil, nil // No validation
}
schema, err := parseSchema(schemaStr)
if err != nil {
return nil, err
}
return &Validator{schema: schema}, nil
}
// Validate validates a body (map or []interface{}) against the schema.
func (v *Validator) Validate(body interface{}) []ValidationError {
if v == nil || v.schema == nil {
return nil
}
return v.validateValue(body, v.schema, "")
}
func (v *Validator) validateValue(value interface{}, schema *FieldSchema, path string) []ValidationError {
var errors []ValidationError
// Handle null
if value == nil {
if !schema.Nullable {
errors = append(errors, ValidationError{
Field: path,
Reason: "null not allowed",
})
}
return errors
}
switch schema.Type {
case "object":
obj, ok := value.(map[string]interface{})
if !ok {
return []ValidationError{{
Field: path,
Reason: fmt.Sprintf("type_mismatch: want object got %T", value),
}}
}
// Check required fields
for _, required := range schema.Required {
if _, ok := obj[required]; !ok {
errors = append(errors, ValidationError{
Field: required,
Reason: "missing",
})
}
}
// Check field types
for fieldName, fieldValue := range obj {
if fieldSchema, ok := schema.Fields[fieldName]; ok {
errors = append(errors, v.validateValue(fieldValue, &fieldSchema, fieldName)...)
} else if schema.Strict {
errors = append(errors, ValidationError{
Field: fieldName,
Reason: "unknown_field",
})
}
}
case "array":
arr, ok := value.([]interface{})
if !ok {
return []ValidationError{{
Field: path,
Reason: fmt.Sprintf("type_mismatch: want array got %T", value),
}}
}
if schema.Items != nil {
for i, item := range arr {
itemPath := fmt.Sprintf("%s[%d]", path, i)
errors = append(errors, v.validateValue(item, schema.Items, itemPath)...)
}
}
case "string":
if _, ok := value.(string); !ok {
return []ValidationError{{
Field: path,
Reason: fmt.Sprintf("type_mismatch: want string got %T", value),
}}
}
case "number":
switch value.(type) {
case float64, int, int32, int64:
// OK
default:
return []ValidationError{{
Field: path,
Reason: fmt.Sprintf("type_mismatch: want number got %T", value),
}}
}
case "boolean":
if _, ok := value.(bool); !ok {
return []ValidationError{{
Field: path,
Reason: fmt.Sprintf("type_mismatch: want boolean got %T", value),
}}
}
}
return errors
}
// parseSchema parses a simple schema DSL (flat key:type format for now).
// Real DSL defined in design doc — stub implementation here.
func parseSchema(schemaStr string) (*FieldSchema, error) {
if strings.TrimSpace(schemaStr) == "" {
return nil, nil
}
// Stub: for now accept any non-empty schema and validate as permissive object
schema := &FieldSchema{
Type: "object",
Fields: make(map[string]FieldSchema),
}
// Very basic parsing: "field1: string, field2: number"
parts := strings.Split(schemaStr, ",")
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
kv := strings.Split(part, ":")
if len(kv) != 2 {
continue
}
fieldName := strings.TrimSpace(kv[0])
fieldType := strings.TrimSpace(kv[1])
schema.Fields[fieldName] = FieldSchema{
Type: fieldType,
Nullable: false,
}
}
return schema, nil
}
+216
View File
@@ -0,0 +1,216 @@
package serviceadapter
import (
"testing"
)
func TestValidateString(t *testing.T) {
schema := &FieldSchema{Type: "string"}
v := &Validator{schema: schema}
errs := v.Validate("hello")
if len(errs) != 0 {
t.Errorf("expected no errors for valid string, got %v", errs)
}
errs = v.Validate(42)
if len(errs) == 0 {
t.Errorf("expected error for non-string")
}
if len(errs) > 0 && !stringContains(errs[0].Reason, "type_mismatch") {
t.Errorf("expected type_mismatch error, got %s", errs[0].Reason)
}
}
func TestValidateNumber(t *testing.T) {
schema := &FieldSchema{Type: "number"}
v := &Validator{schema: schema}
errs := v.Validate(42.0)
if len(errs) != 0 {
t.Errorf("expected no errors for float64, got %v", errs)
}
errs = v.Validate("not a number")
if len(errs) == 0 {
t.Errorf("expected error for non-number")
}
}
func TestValidateNullable(t *testing.T) {
schemaNullable := &FieldSchema{Type: "string", Nullable: true}
vNullable := &Validator{schema: schemaNullable}
errs := vNullable.Validate(nil)
if len(errs) != 0 {
t.Errorf("expected no errors for null on nullable field, got %v", errs)
}
schemaNotNullable := &FieldSchema{Type: "string", Nullable: false}
vNotNullable := &Validator{schema: schemaNotNullable}
errs = vNotNullable.Validate(nil)
if len(errs) == 0 {
t.Errorf("expected error for null on non-nullable field")
}
}
func TestValidateObject(t *testing.T) {
schema := &FieldSchema{
Type: "object",
Required: []string{"name"},
Fields: map[string]FieldSchema{
"name": {Type: "string"},
"age": {Type: "number"},
},
}
v := &Validator{schema: schema}
// Valid object
obj := map[string]interface{}{
"name": "Alice",
"age": 30.0,
}
errs := v.Validate(obj)
if len(errs) != 0 {
t.Errorf("expected no errors for valid object, got %v", errs)
}
// Missing required field
objMissing := map[string]interface{}{
"age": 30.0,
}
errs = v.Validate(objMissing)
if len(errs) == 0 {
t.Errorf("expected error for missing required field")
}
if len(errs) > 0 && errs[0].Reason != "missing" {
t.Errorf("expected 'missing' error, got %s", errs[0].Reason)
}
// Type mismatch
objBadType := map[string]interface{}{
"name": "Alice",
"age": "thirty",
}
errs = v.Validate(objBadType)
if len(errs) == 0 {
t.Errorf("expected error for type mismatch")
}
}
func TestValidateObjectStrict(t *testing.T) {
schema := &FieldSchema{
Type: "object",
Strict: true,
Fields: map[string]FieldSchema{
"name": {Type: "string"},
},
}
v := &Validator{schema: schema}
// Unknown field rejected in strict mode
obj := map[string]interface{}{
"name": "Alice",
"unknown": "field",
}
errs := v.Validate(obj)
if len(errs) == 0 {
t.Errorf("expected error for unknown field in strict mode")
}
found := false
for _, err := range errs {
if err.Reason == "unknown_field" {
found = true
break
}
}
if !found {
t.Errorf("expected unknown_field error")
}
}
func TestValidateArray(t *testing.T) {
schema := &FieldSchema{
Type: "array",
Items: &FieldSchema{
Type: "string",
},
}
v := &Validator{schema: schema}
// Valid array
arr := []interface{}{"a", "b", "c"}
errs := v.Validate(arr)
if len(errs) != 0 {
t.Errorf("expected no errors for valid string array, got %v", errs)
}
// Invalid element type
arrBad := []interface{}{"a", 42, "c"}
errs = v.Validate(arrBad)
if len(errs) == 0 {
t.Errorf("expected error for wrong type in array")
}
}
func TestValidateArrayOfObjects(t *testing.T) {
schema := &FieldSchema{
Type: "array",
Items: &FieldSchema{
Type: "object",
Fields: map[string]FieldSchema{
"id": {Type: "number"},
"name": {Type: "string"},
},
},
}
v := &Validator{schema: schema}
arr := []interface{}{
map[string]interface{}{"id": 1.0, "name": "Alice"},
map[string]interface{}{"id": 2.0, "name": "Bob"},
}
errs := v.Validate(arr)
if len(errs) != 0 {
t.Errorf("expected no errors for valid array of objects, got %v", errs)
}
}
func TestValidateNoSchema(t *testing.T) {
// No schema means no validation
v := &Validator{schema: nil}
errs := v.Validate(map[string]interface{}{"anything": "goes"})
if len(errs) != 0 {
t.Errorf("expected no errors when schema is nil")
}
}
func TestParseSchema(t *testing.T) {
schema, err := parseSchema("name: string, age: number")
if err != nil {
t.Fatalf("parse error: %v", err)
}
if schema.Type != "object" {
t.Errorf("expected type object, got %s", schema.Type)
}
if len(schema.Fields) != 2 {
t.Errorf("expected 2 fields, got %d", len(schema.Fields))
}
if f, ok := schema.Fields["name"]; !ok || f.Type != "string" {
t.Errorf("expected name: string in parsed schema")
}
}
func stringContains(s, substr string) bool {
for i := 0; i < len(s); i++ {
if i+len(substr) <= len(s) && s[i:i+len(substr)] == substr {
return true
}
}
return false
}