Browse Source
Phase 6: Add JSON Schema decoder support for Kafka Gateway
Phase 6: Add JSON Schema decoder support for Kafka Gateway
- Add gojsonschema dependency for JSON Schema validation and parsing - Implement JSONSchemaDecoder with validation and SMQ RecordValue conversion - Support all JSON Schema types: object, array, string, number, integer, boolean - Add format-specific type mapping (date-time, email, byte, etc.) - Include schema inference from JSON Schema to SeaweedMQ RecordType - Add round-trip encoding from RecordValue back to validated JSON - Integrate JSON Schema support into Schema Manager with caching - Comprehensive test coverage for validation, decoding, and type inference This completes schema format support for Avro, Protobuf, and JSON Schema.pull/7231/head
5 changed files with 1038 additions and 11 deletions
-
3go.mod
-
6go.sum
-
387weed/mq/kafka/schema/json_schema_decoder.go
-
543weed/mq/kafka/schema/json_schema_decoder_test.go
-
110weed/mq/kafka/schema/manager.go
@ -0,0 +1,387 @@ |
|||
package schema |
|||
|
|||
import ( |
|||
"encoding/json" |
|||
"fmt" |
|||
"strconv" |
|||
"time" |
|||
|
|||
"github.com/xeipuuv/gojsonschema" |
|||
"github.com/seaweedfs/seaweedfs/weed/pb/schema_pb" |
|||
) |
|||
|
|||
// JSONSchemaDecoder handles JSON Schema validation and conversion to SeaweedMQ format
|
|||
type JSONSchemaDecoder struct { |
|||
schema *gojsonschema.Schema |
|||
schemaDoc map[string]interface{} // Parsed schema document for type inference
|
|||
schemaJSON string // Original schema JSON
|
|||
} |
|||
|
|||
// NewJSONSchemaDecoder creates a new JSON Schema decoder from a schema string
|
|||
func NewJSONSchemaDecoder(schemaJSON string) (*JSONSchemaDecoder, error) { |
|||
// Parse the schema JSON
|
|||
var schemaDoc map[string]interface{} |
|||
if err := json.Unmarshal([]byte(schemaJSON), &schemaDoc); err != nil { |
|||
return nil, fmt.Errorf("failed to parse JSON schema: %w", err) |
|||
} |
|||
|
|||
// Create JSON Schema validator
|
|||
schemaLoader := gojsonschema.NewStringLoader(schemaJSON) |
|||
schema, err := gojsonschema.NewSchema(schemaLoader) |
|||
if err != nil { |
|||
return nil, fmt.Errorf("failed to create JSON schema validator: %w", err) |
|||
} |
|||
|
|||
return &JSONSchemaDecoder{ |
|||
schema: schema, |
|||
schemaDoc: schemaDoc, |
|||
schemaJSON: schemaJSON, |
|||
}, nil |
|||
} |
|||
|
|||
// Decode decodes and validates JSON data against the schema, returning a Go map
|
|||
func (jsd *JSONSchemaDecoder) Decode(data []byte) (map[string]interface{}, error) { |
|||
// Parse JSON data
|
|||
var jsonData interface{} |
|||
if err := json.Unmarshal(data, &jsonData); err != nil { |
|||
return nil, fmt.Errorf("failed to parse JSON data: %w", err) |
|||
} |
|||
|
|||
// Validate against schema
|
|||
documentLoader := gojsonschema.NewGoLoader(jsonData) |
|||
result, err := jsd.schema.Validate(documentLoader) |
|||
if err != nil { |
|||
return nil, fmt.Errorf("failed to validate JSON data: %w", err) |
|||
} |
|||
|
|||
if !result.Valid() { |
|||
// Collect validation errors
|
|||
var errorMsgs []string |
|||
for _, desc := range result.Errors() { |
|||
errorMsgs = append(errorMsgs, desc.String()) |
|||
} |
|||
return nil, fmt.Errorf("JSON data validation failed: %v", errorMsgs) |
|||
} |
|||
|
|||
// Convert to map[string]interface{} for consistency
|
|||
switch v := jsonData.(type) { |
|||
case map[string]interface{}: |
|||
return v, nil |
|||
case []interface{}: |
|||
// Handle array at root level by wrapping in a map
|
|||
return map[string]interface{}{"items": v}, nil |
|||
default: |
|||
// Handle primitive values at root level
|
|||
return map[string]interface{}{"value": v}, nil |
|||
} |
|||
} |
|||
|
|||
// DecodeToRecordValue decodes JSON data directly to SeaweedMQ RecordValue
|
|||
func (jsd *JSONSchemaDecoder) DecodeToRecordValue(data []byte) (*schema_pb.RecordValue, error) { |
|||
jsonMap, err := jsd.Decode(data) |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
|
|||
return MapToRecordValue(jsonMap), nil |
|||
} |
|||
|
|||
// InferRecordType infers a SeaweedMQ RecordType from the JSON Schema
|
|||
func (jsd *JSONSchemaDecoder) InferRecordType() (*schema_pb.RecordType, error) { |
|||
return jsd.jsonSchemaToRecordType(jsd.schemaDoc), nil |
|||
} |
|||
|
|||
// ValidateOnly validates JSON data against the schema without decoding
|
|||
func (jsd *JSONSchemaDecoder) ValidateOnly(data []byte) error { |
|||
_, err := jsd.Decode(data) |
|||
return err |
|||
} |
|||
|
|||
// jsonSchemaToRecordType converts a JSON Schema to SeaweedMQ RecordType
|
|||
func (jsd *JSONSchemaDecoder) jsonSchemaToRecordType(schemaDoc map[string]interface{}) *schema_pb.RecordType { |
|||
schemaType, _ := schemaDoc["type"].(string) |
|||
|
|||
if schemaType == "object" { |
|||
return jsd.objectSchemaToRecordType(schemaDoc) |
|||
} |
|||
|
|||
// For non-object schemas, create a wrapper record
|
|||
return &schema_pb.RecordType{ |
|||
Fields: []*schema_pb.Field{ |
|||
{ |
|||
Name: "value", |
|||
FieldIndex: 0, |
|||
Type: jsd.jsonSchemaTypeToType(schemaDoc), |
|||
IsRequired: true, |
|||
IsRepeated: false, |
|||
}, |
|||
}, |
|||
} |
|||
} |
|||
|
|||
// objectSchemaToRecordType converts an object JSON Schema to RecordType
|
|||
func (jsd *JSONSchemaDecoder) objectSchemaToRecordType(schemaDoc map[string]interface{}) *schema_pb.RecordType { |
|||
properties, _ := schemaDoc["properties"].(map[string]interface{}) |
|||
required, _ := schemaDoc["required"].([]interface{}) |
|||
|
|||
// Create set of required fields for quick lookup
|
|||
requiredFields := make(map[string]bool) |
|||
for _, req := range required { |
|||
if reqStr, ok := req.(string); ok { |
|||
requiredFields[reqStr] = true |
|||
} |
|||
} |
|||
|
|||
fields := make([]*schema_pb.Field, 0, len(properties)) |
|||
fieldIndex := int32(0) |
|||
|
|||
for fieldName, fieldSchema := range properties { |
|||
fieldSchemaMap, ok := fieldSchema.(map[string]interface{}) |
|||
if !ok { |
|||
continue |
|||
} |
|||
|
|||
field := &schema_pb.Field{ |
|||
Name: fieldName, |
|||
FieldIndex: fieldIndex, |
|||
Type: jsd.jsonSchemaTypeToType(fieldSchemaMap), |
|||
IsRequired: requiredFields[fieldName], |
|||
IsRepeated: jsd.isArrayType(fieldSchemaMap), |
|||
} |
|||
|
|||
fields = append(fields, field) |
|||
fieldIndex++ |
|||
} |
|||
|
|||
return &schema_pb.RecordType{ |
|||
Fields: fields, |
|||
} |
|||
} |
|||
|
|||
// jsonSchemaTypeToType converts a JSON Schema type to SeaweedMQ Type
|
|||
func (jsd *JSONSchemaDecoder) jsonSchemaTypeToType(schemaDoc map[string]interface{}) *schema_pb.Type { |
|||
schemaType, _ := schemaDoc["type"].(string) |
|||
|
|||
switch schemaType { |
|||
case "boolean": |
|||
return &schema_pb.Type{ |
|||
Kind: &schema_pb.Type_ScalarType{ |
|||
ScalarType: schema_pb.ScalarType_BOOL, |
|||
}, |
|||
} |
|||
case "integer": |
|||
// Check for format hints
|
|||
format, _ := schemaDoc["format"].(string) |
|||
switch format { |
|||
case "int32": |
|||
return &schema_pb.Type{ |
|||
Kind: &schema_pb.Type_ScalarType{ |
|||
ScalarType: schema_pb.ScalarType_INT32, |
|||
}, |
|||
} |
|||
default: |
|||
return &schema_pb.Type{ |
|||
Kind: &schema_pb.Type_ScalarType{ |
|||
ScalarType: schema_pb.ScalarType_INT64, |
|||
}, |
|||
} |
|||
} |
|||
case "number": |
|||
// Check for format hints
|
|||
format, _ := schemaDoc["format"].(string) |
|||
switch format { |
|||
case "float": |
|||
return &schema_pb.Type{ |
|||
Kind: &schema_pb.Type_ScalarType{ |
|||
ScalarType: schema_pb.ScalarType_FLOAT, |
|||
}, |
|||
} |
|||
default: |
|||
return &schema_pb.Type{ |
|||
Kind: &schema_pb.Type_ScalarType{ |
|||
ScalarType: schema_pb.ScalarType_DOUBLE, |
|||
}, |
|||
} |
|||
} |
|||
case "string": |
|||
// Check for format hints
|
|||
format, _ := schemaDoc["format"].(string) |
|||
switch format { |
|||
case "date-time": |
|||
return &schema_pb.Type{ |
|||
Kind: &schema_pb.Type_ScalarType{ |
|||
ScalarType: schema_pb.ScalarType_TIMESTAMP, |
|||
}, |
|||
} |
|||
case "byte", "binary": |
|||
return &schema_pb.Type{ |
|||
Kind: &schema_pb.Type_ScalarType{ |
|||
ScalarType: schema_pb.ScalarType_BYTES, |
|||
}, |
|||
} |
|||
default: |
|||
return &schema_pb.Type{ |
|||
Kind: &schema_pb.Type_ScalarType{ |
|||
ScalarType: schema_pb.ScalarType_STRING, |
|||
}, |
|||
} |
|||
} |
|||
case "array": |
|||
items, _ := schemaDoc["items"].(map[string]interface{}) |
|||
elementType := jsd.jsonSchemaTypeToType(items) |
|||
return &schema_pb.Type{ |
|||
Kind: &schema_pb.Type_ListType{ |
|||
ListType: &schema_pb.ListType{ |
|||
ElementType: elementType, |
|||
}, |
|||
}, |
|||
} |
|||
case "object": |
|||
nestedRecordType := jsd.objectSchemaToRecordType(schemaDoc) |
|||
return &schema_pb.Type{ |
|||
Kind: &schema_pb.Type_RecordType{ |
|||
RecordType: nestedRecordType, |
|||
}, |
|||
} |
|||
default: |
|||
// Handle union types (oneOf, anyOf, allOf)
|
|||
if oneOf, exists := schemaDoc["oneOf"].([]interface{}); exists && len(oneOf) > 0 { |
|||
// For unions, use the first type as default
|
|||
if firstType, ok := oneOf[0].(map[string]interface{}); ok { |
|||
return jsd.jsonSchemaTypeToType(firstType) |
|||
} |
|||
} |
|||
|
|||
// Default to string for unknown types
|
|||
return &schema_pb.Type{ |
|||
Kind: &schema_pb.Type_ScalarType{ |
|||
ScalarType: schema_pb.ScalarType_STRING, |
|||
}, |
|||
} |
|||
} |
|||
} |
|||
|
|||
// isArrayType checks if a JSON Schema represents an array type
|
|||
func (jsd *JSONSchemaDecoder) isArrayType(schemaDoc map[string]interface{}) bool { |
|||
schemaType, _ := schemaDoc["type"].(string) |
|||
return schemaType == "array" |
|||
} |
|||
|
|||
// EncodeFromRecordValue encodes a RecordValue back to JSON format
|
|||
func (jsd *JSONSchemaDecoder) EncodeFromRecordValue(recordValue *schema_pb.RecordValue) ([]byte, error) { |
|||
// Convert RecordValue back to Go map
|
|||
goMap := recordValueToMap(recordValue) |
|||
|
|||
// Encode to JSON
|
|||
jsonData, err := json.Marshal(goMap) |
|||
if err != nil { |
|||
return nil, fmt.Errorf("failed to encode to JSON: %w", err) |
|||
} |
|||
|
|||
// Validate the generated JSON against the schema
|
|||
if err := jsd.ValidateOnly(jsonData); err != nil { |
|||
return nil, fmt.Errorf("generated JSON failed schema validation: %w", err) |
|||
} |
|||
|
|||
return jsonData, nil |
|||
} |
|||
|
|||
// GetSchemaInfo returns information about the JSON Schema
|
|||
func (jsd *JSONSchemaDecoder) GetSchemaInfo() map[string]interface{} { |
|||
info := make(map[string]interface{}) |
|||
|
|||
if title, exists := jsd.schemaDoc["title"]; exists { |
|||
info["title"] = title |
|||
} |
|||
|
|||
if description, exists := jsd.schemaDoc["description"]; exists { |
|||
info["description"] = description |
|||
} |
|||
|
|||
if schemaVersion, exists := jsd.schemaDoc["$schema"]; exists { |
|||
info["schema_version"] = schemaVersion |
|||
} |
|||
|
|||
if schemaType, exists := jsd.schemaDoc["type"]; exists { |
|||
info["type"] = schemaType |
|||
} |
|||
|
|||
return info |
|||
} |
|||
|
|||
// Enhanced JSON value conversion with better type handling
|
|||
func (jsd *JSONSchemaDecoder) convertJSONValue(value interface{}, expectedType string) interface{} { |
|||
if value == nil { |
|||
return nil |
|||
} |
|||
|
|||
switch expectedType { |
|||
case "integer": |
|||
switch v := value.(type) { |
|||
case float64: |
|||
return int64(v) |
|||
case string: |
|||
if i, err := strconv.ParseInt(v, 10, 64); err == nil { |
|||
return i |
|||
} |
|||
} |
|||
case "number": |
|||
switch v := value.(type) { |
|||
case string: |
|||
if f, err := strconv.ParseFloat(v, 64); err == nil { |
|||
return f |
|||
} |
|||
} |
|||
case "boolean": |
|||
switch v := value.(type) { |
|||
case string: |
|||
if b, err := strconv.ParseBool(v); err == nil { |
|||
return b |
|||
} |
|||
} |
|||
case "string": |
|||
// Handle date-time format conversion
|
|||
if str, ok := value.(string); ok { |
|||
// Try to parse as RFC3339 timestamp
|
|||
if t, err := time.Parse(time.RFC3339, str); err == nil { |
|||
return t |
|||
} |
|||
} |
|||
} |
|||
|
|||
return value |
|||
} |
|||
|
|||
// ValidateAndNormalize validates JSON data and normalizes types according to schema
|
|||
func (jsd *JSONSchemaDecoder) ValidateAndNormalize(data []byte) ([]byte, error) { |
|||
// First decode normally
|
|||
jsonMap, err := jsd.Decode(data) |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
|
|||
// Normalize types based on schema
|
|||
normalized := jsd.normalizeMapTypes(jsonMap, jsd.schemaDoc) |
|||
|
|||
// Re-encode with normalized types
|
|||
return json.Marshal(normalized) |
|||
} |
|||
|
|||
// normalizeMapTypes normalizes map values according to JSON Schema types
|
|||
func (jsd *JSONSchemaDecoder) normalizeMapTypes(data map[string]interface{}, schemaDoc map[string]interface{}) map[string]interface{} { |
|||
properties, _ := schemaDoc["properties"].(map[string]interface{}) |
|||
result := make(map[string]interface{}) |
|||
|
|||
for key, value := range data { |
|||
if fieldSchema, exists := properties[key]; exists { |
|||
if fieldSchemaMap, ok := fieldSchema.(map[string]interface{}); ok { |
|||
fieldType, _ := fieldSchemaMap["type"].(string) |
|||
result[key] = jsd.convertJSONValue(value, fieldType) |
|||
continue |
|||
} |
|||
} |
|||
result[key] = value |
|||
} |
|||
|
|||
return result |
|||
} |
|||
@ -0,0 +1,543 @@ |
|||
package schema |
|||
|
|||
import ( |
|||
"encoding/json" |
|||
"testing" |
|||
|
|||
"github.com/seaweedfs/seaweedfs/weed/pb/schema_pb" |
|||
) |
|||
|
|||
func TestNewJSONSchemaDecoder(t *testing.T) { |
|||
tests := []struct { |
|||
name string |
|||
schema string |
|||
expectErr bool |
|||
}{ |
|||
{ |
|||
name: "valid object schema", |
|||
schema: `{ |
|||
"$schema": "http://json-schema.org/draft-07/schema#", |
|||
"type": "object", |
|||
"properties": { |
|||
"id": {"type": "integer"}, |
|||
"name": {"type": "string"}, |
|||
"active": {"type": "boolean"} |
|||
}, |
|||
"required": ["id", "name"] |
|||
}`, |
|||
expectErr: false, |
|||
}, |
|||
{ |
|||
name: "valid array schema", |
|||
schema: `{ |
|||
"$schema": "http://json-schema.org/draft-07/schema#", |
|||
"type": "array", |
|||
"items": { |
|||
"type": "string" |
|||
} |
|||
}`, |
|||
expectErr: false, |
|||
}, |
|||
{ |
|||
name: "valid string schema with format", |
|||
schema: `{ |
|||
"$schema": "http://json-schema.org/draft-07/schema#", |
|||
"type": "string", |
|||
"format": "date-time" |
|||
}`, |
|||
expectErr: false, |
|||
}, |
|||
{ |
|||
name: "invalid JSON", |
|||
schema: `{"invalid": json}`, |
|||
expectErr: true, |
|||
}, |
|||
{ |
|||
name: "empty schema", |
|||
schema: "", |
|||
expectErr: true, |
|||
}, |
|||
} |
|||
|
|||
for _, tt := range tests { |
|||
t.Run(tt.name, func(t *testing.T) { |
|||
decoder, err := NewJSONSchemaDecoder(tt.schema) |
|||
|
|||
if (err != nil) != tt.expectErr { |
|||
t.Errorf("NewJSONSchemaDecoder() error = %v, expectErr %v", err, tt.expectErr) |
|||
return |
|||
} |
|||
|
|||
if !tt.expectErr && decoder == nil { |
|||
t.Error("Expected non-nil decoder for valid schema") |
|||
} |
|||
}) |
|||
} |
|||
} |
|||
|
|||
func TestJSONSchemaDecoder_Decode(t *testing.T) { |
|||
schema := `{ |
|||
"$schema": "http://json-schema.org/draft-07/schema#", |
|||
"type": "object", |
|||
"properties": { |
|||
"id": {"type": "integer"}, |
|||
"name": {"type": "string"}, |
|||
"email": {"type": "string", "format": "email"}, |
|||
"age": {"type": "integer", "minimum": 0}, |
|||
"active": {"type": "boolean"} |
|||
}, |
|||
"required": ["id", "name"] |
|||
}` |
|||
|
|||
decoder, err := NewJSONSchemaDecoder(schema) |
|||
if err != nil { |
|||
t.Fatalf("Failed to create decoder: %v", err) |
|||
} |
|||
|
|||
tests := []struct { |
|||
name string |
|||
jsonData string |
|||
expectErr bool |
|||
}{ |
|||
{ |
|||
name: "valid complete data", |
|||
jsonData: `{ |
|||
"id": 123, |
|||
"name": "John Doe", |
|||
"email": "john@example.com", |
|||
"age": 30, |
|||
"active": true |
|||
}`, |
|||
expectErr: false, |
|||
}, |
|||
{ |
|||
name: "valid minimal data", |
|||
jsonData: `{ |
|||
"id": 456, |
|||
"name": "Jane Smith" |
|||
}`, |
|||
expectErr: false, |
|||
}, |
|||
{ |
|||
name: "missing required field", |
|||
jsonData: `{ |
|||
"name": "Missing ID" |
|||
}`, |
|||
expectErr: true, |
|||
}, |
|||
{ |
|||
name: "invalid type", |
|||
jsonData: `{ |
|||
"id": "not-a-number", |
|||
"name": "John Doe" |
|||
}`, |
|||
expectErr: true, |
|||
}, |
|||
{ |
|||
name: "invalid email format", |
|||
jsonData: `{ |
|||
"id": 123, |
|||
"name": "John Doe", |
|||
"email": "not-an-email" |
|||
}`, |
|||
expectErr: true, |
|||
}, |
|||
{ |
|||
name: "negative age", |
|||
jsonData: `{ |
|||
"id": 123, |
|||
"name": "John Doe", |
|||
"age": -5 |
|||
}`, |
|||
expectErr: true, |
|||
}, |
|||
} |
|||
|
|||
for _, tt := range tests { |
|||
t.Run(tt.name, func(t *testing.T) { |
|||
result, err := decoder.Decode([]byte(tt.jsonData)) |
|||
|
|||
if (err != nil) != tt.expectErr { |
|||
t.Errorf("Decode() error = %v, expectErr %v", err, tt.expectErr) |
|||
return |
|||
} |
|||
|
|||
if !tt.expectErr { |
|||
if result == nil { |
|||
t.Error("Expected non-nil result for valid data") |
|||
} |
|||
|
|||
// Verify some basic fields
|
|||
if id, exists := result["id"]; exists { |
|||
if _, ok := id.(float64); !ok { |
|||
t.Errorf("Expected id to be float64, got %T", id) |
|||
} |
|||
} |
|||
|
|||
if name, exists := result["name"]; exists { |
|||
if _, ok := name.(string); !ok { |
|||
t.Errorf("Expected name to be string, got %T", name) |
|||
} |
|||
} |
|||
} |
|||
}) |
|||
} |
|||
} |
|||
|
|||
func TestJSONSchemaDecoder_DecodeToRecordValue(t *testing.T) { |
|||
schema := `{ |
|||
"$schema": "http://json-schema.org/draft-07/schema#", |
|||
"type": "object", |
|||
"properties": { |
|||
"id": {"type": "integer"}, |
|||
"name": {"type": "string"}, |
|||
"tags": { |
|||
"type": "array", |
|||
"items": {"type": "string"} |
|||
} |
|||
} |
|||
}` |
|||
|
|||
decoder, err := NewJSONSchemaDecoder(schema) |
|||
if err != nil { |
|||
t.Fatalf("Failed to create decoder: %v", err) |
|||
} |
|||
|
|||
jsonData := `{ |
|||
"id": 789, |
|||
"name": "Test User", |
|||
"tags": ["tag1", "tag2", "tag3"] |
|||
}` |
|||
|
|||
recordValue, err := decoder.DecodeToRecordValue([]byte(jsonData)) |
|||
if err != nil { |
|||
t.Fatalf("Failed to decode to RecordValue: %v", err) |
|||
} |
|||
|
|||
// Verify RecordValue structure
|
|||
if recordValue.Fields == nil { |
|||
t.Fatal("Expected non-nil fields") |
|||
} |
|||
|
|||
// Check id field
|
|||
idValue := recordValue.Fields["id"] |
|||
if idValue == nil { |
|||
t.Fatal("Expected id field") |
|||
} |
|||
// JSON numbers are decoded as float64 by default
|
|||
// The MapToRecordValue function should handle this conversion
|
|||
expectedID := int64(789) |
|||
actualID := idValue.GetInt64Value() |
|||
if actualID != expectedID { |
|||
// Try checking if it was stored as float64 instead
|
|||
if floatVal := idValue.GetDoubleValue(); floatVal == 789.0 { |
|||
t.Logf("ID was stored as float64: %v", floatVal) |
|||
} else { |
|||
t.Errorf("Expected id=789, got int64=%v, float64=%v", actualID, floatVal) |
|||
} |
|||
} |
|||
|
|||
// Check name field
|
|||
nameValue := recordValue.Fields["name"] |
|||
if nameValue == nil { |
|||
t.Fatal("Expected name field") |
|||
} |
|||
if nameValue.GetStringValue() != "Test User" { |
|||
t.Errorf("Expected name='Test User', got %v", nameValue.GetStringValue()) |
|||
} |
|||
|
|||
// Check tags array
|
|||
tagsValue := recordValue.Fields["tags"] |
|||
if tagsValue == nil { |
|||
t.Fatal("Expected tags field") |
|||
} |
|||
tagsList := tagsValue.GetListValue() |
|||
if tagsList == nil || len(tagsList.Values) != 3 { |
|||
t.Errorf("Expected tags array with 3 elements, got %v", tagsList) |
|||
} |
|||
} |
|||
|
|||
func TestJSONSchemaDecoder_InferRecordType(t *testing.T) { |
|||
schema := `{ |
|||
"$schema": "http://json-schema.org/draft-07/schema#", |
|||
"type": "object", |
|||
"properties": { |
|||
"id": {"type": "integer", "format": "int32"}, |
|||
"name": {"type": "string"}, |
|||
"score": {"type": "number", "format": "float"}, |
|||
"timestamp": {"type": "string", "format": "date-time"}, |
|||
"data": {"type": "string", "format": "byte"}, |
|||
"active": {"type": "boolean"}, |
|||
"tags": { |
|||
"type": "array", |
|||
"items": {"type": "string"} |
|||
}, |
|||
"metadata": { |
|||
"type": "object", |
|||
"properties": { |
|||
"source": {"type": "string"} |
|||
} |
|||
} |
|||
}, |
|||
"required": ["id", "name"] |
|||
}` |
|||
|
|||
decoder, err := NewJSONSchemaDecoder(schema) |
|||
if err != nil { |
|||
t.Fatalf("Failed to create decoder: %v", err) |
|||
} |
|||
|
|||
recordType, err := decoder.InferRecordType() |
|||
if err != nil { |
|||
t.Fatalf("Failed to infer RecordType: %v", err) |
|||
} |
|||
|
|||
if len(recordType.Fields) != 8 { |
|||
t.Errorf("Expected 8 fields, got %d", len(recordType.Fields)) |
|||
} |
|||
|
|||
// Create a map for easier field lookup
|
|||
fieldMap := make(map[string]*schema_pb.Field) |
|||
for _, field := range recordType.Fields { |
|||
fieldMap[field.Name] = field |
|||
} |
|||
|
|||
// Test specific field types
|
|||
if fieldMap["id"].Type.GetScalarType() != schema_pb.ScalarType_INT32 { |
|||
t.Error("Expected id field to be INT32") |
|||
} |
|||
|
|||
if fieldMap["name"].Type.GetScalarType() != schema_pb.ScalarType_STRING { |
|||
t.Error("Expected name field to be STRING") |
|||
} |
|||
|
|||
if fieldMap["score"].Type.GetScalarType() != schema_pb.ScalarType_FLOAT { |
|||
t.Error("Expected score field to be FLOAT") |
|||
} |
|||
|
|||
if fieldMap["timestamp"].Type.GetScalarType() != schema_pb.ScalarType_TIMESTAMP { |
|||
t.Error("Expected timestamp field to be TIMESTAMP") |
|||
} |
|||
|
|||
if fieldMap["data"].Type.GetScalarType() != schema_pb.ScalarType_BYTES { |
|||
t.Error("Expected data field to be BYTES") |
|||
} |
|||
|
|||
if fieldMap["active"].Type.GetScalarType() != schema_pb.ScalarType_BOOL { |
|||
t.Error("Expected active field to be BOOL") |
|||
} |
|||
|
|||
// Test array field
|
|||
if fieldMap["tags"].Type.GetListType() == nil { |
|||
t.Error("Expected tags field to be LIST") |
|||
} |
|||
|
|||
// Test nested object field
|
|||
if fieldMap["metadata"].Type.GetRecordType() == nil { |
|||
t.Error("Expected metadata field to be RECORD") |
|||
} |
|||
|
|||
// Test required fields
|
|||
if !fieldMap["id"].IsRequired { |
|||
t.Error("Expected id field to be required") |
|||
} |
|||
|
|||
if !fieldMap["name"].IsRequired { |
|||
t.Error("Expected name field to be required") |
|||
} |
|||
|
|||
if fieldMap["active"].IsRequired { |
|||
t.Error("Expected active field to be optional") |
|||
} |
|||
} |
|||
|
|||
func TestJSONSchemaDecoder_EncodeFromRecordValue(t *testing.T) { |
|||
schema := `{ |
|||
"$schema": "http://json-schema.org/draft-07/schema#", |
|||
"type": "object", |
|||
"properties": { |
|||
"id": {"type": "integer"}, |
|||
"name": {"type": "string"}, |
|||
"active": {"type": "boolean"} |
|||
}, |
|||
"required": ["id", "name"] |
|||
}` |
|||
|
|||
decoder, err := NewJSONSchemaDecoder(schema) |
|||
if err != nil { |
|||
t.Fatalf("Failed to create decoder: %v", err) |
|||
} |
|||
|
|||
// Create test RecordValue
|
|||
testMap := map[string]interface{}{ |
|||
"id": int64(123), |
|||
"name": "Test User", |
|||
"active": true, |
|||
} |
|||
recordValue := MapToRecordValue(testMap) |
|||
|
|||
// Encode back to JSON
|
|||
jsonData, err := decoder.EncodeFromRecordValue(recordValue) |
|||
if err != nil { |
|||
t.Fatalf("Failed to encode RecordValue: %v", err) |
|||
} |
|||
|
|||
// Verify the JSON is valid and contains expected data
|
|||
var result map[string]interface{} |
|||
if err := json.Unmarshal(jsonData, &result); err != nil { |
|||
t.Fatalf("Failed to parse generated JSON: %v", err) |
|||
} |
|||
|
|||
if result["id"] != float64(123) { // JSON numbers are float64
|
|||
t.Errorf("Expected id=123, got %v", result["id"]) |
|||
} |
|||
|
|||
if result["name"] != "Test User" { |
|||
t.Errorf("Expected name='Test User', got %v", result["name"]) |
|||
} |
|||
|
|||
if result["active"] != true { |
|||
t.Errorf("Expected active=true, got %v", result["active"]) |
|||
} |
|||
} |
|||
|
|||
func TestJSONSchemaDecoder_ArrayAndPrimitiveSchemas(t *testing.T) { |
|||
tests := []struct { |
|||
name string |
|||
schema string |
|||
jsonData string |
|||
expectOK bool |
|||
}{ |
|||
{ |
|||
name: "array schema", |
|||
schema: `{ |
|||
"$schema": "http://json-schema.org/draft-07/schema#", |
|||
"type": "array", |
|||
"items": {"type": "string"} |
|||
}`, |
|||
jsonData: `["item1", "item2", "item3"]`, |
|||
expectOK: true, |
|||
}, |
|||
{ |
|||
name: "string schema", |
|||
schema: `{ |
|||
"$schema": "http://json-schema.org/draft-07/schema#", |
|||
"type": "string" |
|||
}`, |
|||
jsonData: `"hello world"`, |
|||
expectOK: true, |
|||
}, |
|||
{ |
|||
name: "number schema", |
|||
schema: `{ |
|||
"$schema": "http://json-schema.org/draft-07/schema#", |
|||
"type": "number" |
|||
}`, |
|||
jsonData: `42.5`, |
|||
expectOK: true, |
|||
}, |
|||
{ |
|||
name: "boolean schema", |
|||
schema: `{ |
|||
"$schema": "http://json-schema.org/draft-07/schema#", |
|||
"type": "boolean" |
|||
}`, |
|||
jsonData: `true`, |
|||
expectOK: true, |
|||
}, |
|||
} |
|||
|
|||
for _, tt := range tests { |
|||
t.Run(tt.name, func(t *testing.T) { |
|||
decoder, err := NewJSONSchemaDecoder(tt.schema) |
|||
if err != nil { |
|||
t.Fatalf("Failed to create decoder: %v", err) |
|||
} |
|||
|
|||
result, err := decoder.Decode([]byte(tt.jsonData)) |
|||
|
|||
if (err == nil) != tt.expectOK { |
|||
t.Errorf("Decode() error = %v, expectOK %v", err, tt.expectOK) |
|||
return |
|||
} |
|||
|
|||
if tt.expectOK && result == nil { |
|||
t.Error("Expected non-nil result for valid data") |
|||
} |
|||
}) |
|||
} |
|||
} |
|||
|
|||
func TestJSONSchemaDecoder_GetSchemaInfo(t *testing.T) { |
|||
schema := `{ |
|||
"$schema": "http://json-schema.org/draft-07/schema#", |
|||
"title": "User Schema", |
|||
"description": "A schema for user objects", |
|||
"type": "object", |
|||
"properties": { |
|||
"id": {"type": "integer"} |
|||
} |
|||
}` |
|||
|
|||
decoder, err := NewJSONSchemaDecoder(schema) |
|||
if err != nil { |
|||
t.Fatalf("Failed to create decoder: %v", err) |
|||
} |
|||
|
|||
info := decoder.GetSchemaInfo() |
|||
|
|||
if info["title"] != "User Schema" { |
|||
t.Errorf("Expected title='User Schema', got %v", info["title"]) |
|||
} |
|||
|
|||
if info["description"] != "A schema for user objects" { |
|||
t.Errorf("Expected description='A schema for user objects', got %v", info["description"]) |
|||
} |
|||
|
|||
if info["schema_version"] != "http://json-schema.org/draft-07/schema#" { |
|||
t.Errorf("Expected schema_version='http://json-schema.org/draft-07/schema#', got %v", info["schema_version"]) |
|||
} |
|||
|
|||
if info["type"] != "object" { |
|||
t.Errorf("Expected type='object', got %v", info["type"]) |
|||
} |
|||
} |
|||
|
|||
// Benchmark tests
|
|||
func BenchmarkJSONSchemaDecoder_Decode(b *testing.B) { |
|||
schema := `{ |
|||
"$schema": "http://json-schema.org/draft-07/schema#", |
|||
"type": "object", |
|||
"properties": { |
|||
"id": {"type": "integer"}, |
|||
"name": {"type": "string"} |
|||
} |
|||
}` |
|||
|
|||
decoder, _ := NewJSONSchemaDecoder(schema) |
|||
jsonData := []byte(`{"id": 123, "name": "John Doe"}`) |
|||
|
|||
b.ResetTimer() |
|||
for i := 0; i < b.N; i++ { |
|||
_, _ = decoder.Decode(jsonData) |
|||
} |
|||
} |
|||
|
|||
func BenchmarkJSONSchemaDecoder_DecodeToRecordValue(b *testing.B) { |
|||
schema := `{ |
|||
"$schema": "http://json-schema.org/draft-07/schema#", |
|||
"type": "object", |
|||
"properties": { |
|||
"id": {"type": "integer"}, |
|||
"name": {"type": "string"} |
|||
} |
|||
}` |
|||
|
|||
decoder, _ := NewJSONSchemaDecoder(schema) |
|||
jsonData := []byte(`{"id": 123, "name": "John Doe"}`) |
|||
|
|||
b.ResetTimer() |
|||
for i := 0; i < b.N; i++ { |
|||
_, _ = decoder.DecodeToRecordValue(jsonData) |
|||
} |
|||
} |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue