Skip to content

Commit

Permalink
Enhance simpleschema package to support nested complex types
Browse files Browse the repository at this point in the history
This commit significantly improves the `simpleschema` package ability to handle
complex, nested data structures. The `parseMapType` function has been refactored
to correctly parse nested map types and a new `findMatchingBracket` function has
been added to ensure robust bracket parsing. The Transformer.BuildOpenAPISchema
method has been restructured allowing for more flexible and accurate schema
generation for nested slices and maps.
  • Loading branch information
a-hilaly committed Sep 27, 2024
1 parent 343a43e commit b30a38a
Show file tree
Hide file tree
Showing 5 changed files with 295 additions and 153 deletions.
45 changes: 39 additions & 6 deletions internal/typesystem/simpleschema/atomic.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,21 +80,54 @@ func isSliceType(s string) bool {

// parseMapType parses a map type string and returns the key and value types.
func parseMapType(s string) (string, string, error) {
// Remove the "map[" prefix and "]" suffix.
s = strings.TrimPrefix(s, "map[")
s = strings.TrimSuffix(s, "]")
parts := strings.Split(s, "]")
if len(parts) != 2 {
if !strings.HasPrefix(s, "map[") {
return "", "", fmt.Errorf("invalid map type: %s", s)
}
return parts[0], parts[1], nil

// remove the "map[" prefix
s = s[4:]

keyEndIndex := findMatchingBracket(s)
if keyEndIndex == -1 {
return "", "", fmt.Errorf("invalid map key type: %s", s)
}

keyType := s[:keyEndIndex]
valueType := s[keyEndIndex+1:]

valueType = strings.TrimSuffix(valueType, "]")
if keyType == "" {
return "", "", fmt.Errorf("empty map key type")
}
if valueType == "" {
return "", "", fmt.Errorf("empty map value type")
}

return keyType, valueType, nil
}
func findMatchingBracket(s string) int {
depth := 1
for i, char := range s {
switch char {
case '[':
depth++
case ']':
depth--
if depth == 0 {
return i
}
}
}
// no matching bracket found
return -1
}

// parseSliceType parses a slice type string and returns the element type.
func parseSliceType(s string) (string, error) {
if !strings.HasPrefix(s, "[]") {
return "", fmt.Errorf("invalid slice type: %s", s)
}

// Remove the "[]" prefix.
s = strings.TrimPrefix(s, "[]")
if s == "" {
Expand Down
9 changes: 5 additions & 4 deletions internal/typesystem/simpleschema/atomic_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,9 @@ func TestParseMapType(t *testing.T) {
wantErr bool
}{
{"valid map", "map[string]integer", "string", "integer", false},
// not supported yet... do we need to support this?
// {"Valid Complex Map", "map[string]map[int]bool", "string", "map[int]bool", false},
{"Valid Complex Map", "map[string]map[int]bool", "string", "map[int]bool", false},
{"Nested Map", "map[string]map[string]map[string]integer", "string", "map[string]map[string]integer", false},
{"invalid map", "map[]", "", "", true},
{"invalid map", "map[string]", "", "", true},
{"not a map", "something", "", "", true},
}
Expand Down Expand Up @@ -143,8 +144,8 @@ func TestParseSliceType(t *testing.T) {
wantErr bool
}{
{"valid slice", "[]string", "string", false},
// not supported yet
// {"Valid Complex Slice", "[]map[string]int", "map[string]int", false},
{"Valid Complex Slice", "[]map[string]int", "map[string]int", false},
{"Nested Slice", "[][][]int", "[][]int", false},
{"invalid slice", "[]", "", true},
{"Not a slice", "string", "", true},
}
Expand Down
7 changes: 6 additions & 1 deletion internal/typesystem/simpleschema/field_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
package simpleschema

import (
"fmt"
"reflect"
"testing"
)
Expand All @@ -32,7 +33,8 @@ func TestParseFieldSchema(t *testing.T) {
wantType: "string",
wantMarkers: []*Marker{
{MarkerType: MarkerTypeRequired, Key: "required", Value: "true"},
{MarkerType: MarkerTypeDescription, Key: "description", Value: "A test field"},
{MarkerType: MarkerTypeDescription, Key: "description", Value: "A-test-field"},
{MarkerType: MarkerTypeDefault, Key: "default", Value: "kubernetes-is-very-nice!"},
},
wantErr: false,
},
Expand Down Expand Up @@ -63,6 +65,9 @@ func TestParseFieldSchema(t *testing.T) {
t.Errorf("parseFieldSchema() gotType = %v, want %v", gotType, tt.wantType)
}
if !reflect.DeepEqual(gotMarkers, tt.wantMarkers) {
for index := range gotMarkers {
fmt.Printf("gotMarkers %+v = %+v\n", tt.wantMarkers[index].Value, gotMarkers[index].Value)
}
t.Errorf("parseFieldSchema() gotMarkers = %+v, want %+v", gotMarkers, tt.wantMarkers)
}
})
Expand Down
260 changes: 134 additions & 126 deletions internal/typesystem/simpleschema/transform.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,144 +51,152 @@ func (tf *Transformer) BuildOpenAPISchema(obj map[string]interface{}) (*extv1.JS
}

for key, value := range obj {
switch v := value.(type) {
case map[interface{}]interface{}:
fieldSchema, err := tf.transformField(key, value, schema)
if err != nil {
return nil, err
}
schema.Properties[key] = *fieldSchema
}

// we have a nested object
nMap := transformMap(v)
return schema, nil
}

fieldSchemaProps, err := tf.BuildOpenAPISchema(nMap)
if err != nil {
return nil, err
}
schema.Properties[key] = *fieldSchemaProps
case map[string]interface{}:
// transform the map to a map[inteface{}]interface{}
newMap := make(map[interface{}]interface{})
for k, v := range v {
newMap[k] = v
}
func (tf *Transformer) transformField(key string, value interface{}, parentSchema *extv1.JSONSchemaProps) (*extv1.JSONSchemaProps, error) {
switch v := value.(type) {
case map[interface{}]interface{}:
nMap := transformMap(v)
return tf.BuildOpenAPISchema(nMap)
case map[string]interface{}:
return tf.BuildOpenAPISchema(v)
case string:
return tf.parseFieldSchema(key, v, parentSchema)
default:
return nil, fmt.Errorf("unknown type in schema: key: %s, value: %v", key, value)
}
}

// we have a nested object
nMap := transformMap(newMap)
func (tf *Transformer) parseFieldSchema(key, fieldValue string, parentSchema *extv1.JSONSchemaProps) (*extv1.JSONSchemaProps, error) {
fieldType, markers, err := parseFieldSchema(fieldValue)
if err != nil {
return nil, fmt.Errorf("failed to parse field schema for %s: %v", key, err)
}

fieldSchemaProps, err := tf.BuildOpenAPISchema(nMap)
if err != nil {
return nil, err
}
schema.Properties[key] = *fieldSchemaProps
case string:
// we have a string. Meaning it's an atomic type, a reference to another type, or a collection type.
// It could also contain markers like `required=true` or `description="some description"`
// We need to parse the string to determine the type and any markers.
fieldType, markers, err := parseFieldSchema(value.(string))
if err != nil {
return nil, fmt.Errorf("failed to parse field schema for %s: %v", key, err)
}
fieldJSONSchemaProps := &extv1.JSONSchemaProps{}

if isAtomicType(fieldType) {
fieldJSONSchemaProps.Type = string(fieldType)
} else if isCollectionType(fieldType) {
if isMapType(fieldType) {
fieldJSONSchemaProps, err = tf.handleMapType(key, fieldType)
} else if isSliceType(fieldType) {
fieldJSONSchemaProps, err = tf.handleSliceType(key, fieldType)
} else {
return nil, fmt.Errorf("unknown collection type: %s", fieldType)
}
if err != nil {
return nil, err
}
} else {
preDefinedType, ok := tf.preDefinedTypes[fieldType]
if !ok {
return nil, fmt.Errorf("unknown type: %s", fieldType)
}
fieldJSONSchemaProps = &preDefinedType
}

fieldJSONSchemaProps := extv1.JSONSchemaProps{}

if isAtomicType(fieldType) {
// this is an atomic type
fieldJSONSchemaProps.Type = string(fieldType)
} else if isCollectionType(fieldType) {
// this is a collection type, either an array or a map
if isMapType(fieldType) {
keyType, valueType, err := parseMapType(fieldType)
if err != nil {
return nil, fmt.Errorf("failed to parse map type for %s: %w", key, err)
}
fieldJSONSchemaProps.Type = "object"
fieldJSONSchemaProps.AdditionalProperties = &extv1.JSONSchemaPropsOrBool{
Schema: &extv1.JSONSchemaProps{
Type: keyType,
},
}

if preDefinedType, ok := tf.preDefinedTypes[valueType]; ok {
fieldJSONSchemaProps.AdditionalProperties.Schema = &preDefinedType
} else if isAtomicType(valueType) {
fieldJSONSchemaProps.AdditionalProperties.Schema = &extv1.JSONSchemaProps{
Type: valueType,
}
} else {
return nil, fmt.Errorf("unknown type: %s", fieldType)
}
} else if isSliceType(fieldType) {
elementType, err := parseSliceType(fieldType)
if err != nil {
return nil, fmt.Errorf("failed to parse slice type for %s: %w", key, err)
}

fieldJSONSchemaProps.Type = "array"
fieldJSONSchemaProps.Items = &extv1.JSONSchemaPropsOrArray{
Schema: &extv1.JSONSchemaProps{
Type: elementType,
},
}

if preDefinedType, ok := tf.preDefinedTypes[elementType]; ok {
fieldJSONSchemaProps.Items.Schema = &preDefinedType
} else if isAtomicType(elementType) {
fieldJSONSchemaProps.Items.Schema = &extv1.JSONSchemaProps{
Type: elementType,
}
} else {
return nil, fmt.Errorf("unknown type: %s", fieldType)
}
} else {
return nil, fmt.Errorf("unknown collection type: %s", fieldType)
}
} else {
// this is a reference to pre defined type.. we should look it up
preDefinedType, ok := tf.preDefinedTypes[fieldType]
if !ok {
return nil, fmt.Errorf("unknown type: %s", fieldType)
}
fieldJSONSchemaProps = preDefinedType
}
tf.applyMarkers(fieldJSONSchemaProps, markers, key, parentSchema)

// apply markers
for _, marker := range markers {
switch marker.MarkerType {
case MarkerTypeRequired:
schema.Required = append(fieldJSONSchemaProps.Required, key)
case MarkerTypeDefault:
// depending on the type, we need to set the default value accordingly
var defaultValue []byte
switch fieldJSONSchemaProps.Type {
case "string":
defaultValue = []byte(fmt.Sprintf("\"%s\"", marker.Value))
case "integer", "number":
defaultValue = []byte(marker.Value)
case "boolean":
defaultValue = []byte(marker.Value)
default:
// probably an object, array, or a map type. We can just
// set the raw value as the default
defaultValue = []byte(marker.Value)
}

fieldJSONSchemaProps.Default = &extv1.JSON{
Raw: defaultValue,
}
case MarkerTypeDescription:
fieldJSONSchemaProps.Description = marker.Value
default:
return nil, fmt.Errorf("unknown marker: %s", marker.MarkerType)
}
}
return fieldJSONSchemaProps, nil
}

schema.Properties[key] = fieldJSONSchemaProps
default:
// arrays and maps are only supported using the `[]` and `map[]` prefixes
return nil, fmt.Errorf("unknown type in schema: key: %s, value: %s", key, value)
func (tf *Transformer) handleMapType(key, fieldType string) (*extv1.JSONSchemaProps, error) {
keyType, valueType, err := parseMapType(fieldType)
if err != nil {
return nil, fmt.Errorf("failed to parse map type for %s: %w", key, err)
}
if keyType != "string" {
return nil, fmt.Errorf("unsupported key type: %s", keyType)
}

fieldJSONSchemaProps := &extv1.JSONSchemaProps{
Type: "object",
AdditionalProperties: &extv1.JSONSchemaPropsOrBool{
Schema: &extv1.JSONSchemaProps{},
},
}

if isCollectionType(valueType) {
valueSchema, err := tf.parseFieldSchema(key, valueType, fieldJSONSchemaProps)
if err != nil {
return nil, err
}
fieldJSONSchemaProps.AdditionalProperties.Schema = valueSchema
} else if preDefinedType, ok := tf.preDefinedTypes[valueType]; ok {
fieldJSONSchemaProps.AdditionalProperties.Schema = &preDefinedType
} else if isAtomicType(valueType) {
fieldJSONSchemaProps.AdditionalProperties.Schema.Type = valueType
} else {
return nil, fmt.Errorf("unknown type: %s", valueType)
}

return fieldJSONSchemaProps, nil
}

func (tf *Transformer) handleSliceType(key, fieldType string) (*extv1.JSONSchemaProps, error) {
elementType, err := parseSliceType(fieldType)
if err != nil {
return nil, fmt.Errorf("failed to parse slice type for %s: %w", key, err)
}

fieldJSONSchemaProps := &extv1.JSONSchemaProps{
Type: "array",
Items: &extv1.JSONSchemaPropsOrArray{
Schema: &extv1.JSONSchemaProps{},
},
}

if isCollectionType(elementType) {
elementSchema, err := tf.parseFieldSchema(key, elementType, fieldJSONSchemaProps)
if err != nil {
return nil, err
}
fieldJSONSchemaProps.Items.Schema = elementSchema
} else if isAtomicType(elementType) {
fieldJSONSchemaProps.Items.Schema.Type = elementType
} else if preDefinedType, ok := tf.preDefinedTypes[elementType]; ok {
fieldJSONSchemaProps.Items.Schema = &preDefinedType
} else {
return nil, fmt.Errorf("unknown type: %s", elementType)
}

return fieldJSONSchemaProps, nil
}

func (tf *Transformer) applyMarkers(schema *extv1.JSONSchemaProps, markers []*Marker, key string, parentSchema *extv1.JSONSchemaProps) {
for _, marker := range markers {
switch marker.MarkerType {
case MarkerTypeRequired:
if parentSchema != nil {
parentSchema.Required = append(parentSchema.Required, key)
}
case MarkerTypeDefault:
var defaultValue []byte
switch schema.Type {
case "string":
defaultValue = []byte(fmt.Sprintf("\"%s\"", marker.Value))
case "integer", "number", "boolean":
defaultValue = []byte(marker.Value)
default:
defaultValue = []byte(marker.Value)
}
schema.Default = &extv1.JSON{Raw: defaultValue}
case MarkerTypeDescription:
schema.Description = marker.Value
}
}
return schema, nil
}

// Other functions (LoadPreDefinedTypes, transformMap) remain unchanged
func transformMap(original map[interface{}]interface{}) map[string]interface{} {
result := make(map[string]interface{})
for key, value := range original {
Expand Down
Loading

0 comments on commit b30a38a

Please sign in to comment.