Files
homelab-frontend/internal/serviceadapter/validate.go
T

182 lines
4.3 KiB
Go

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
}