diff --git a/bundle/schema/schema.go b/bundle/schema/schema.go index 4d3e490a..1b31119b 100644 --- a/bundle/schema/schema.go +++ b/bundle/schema/schema.go @@ -16,19 +16,22 @@ type Schema struct { } type Property struct { - // TODO: Add a enum for json types Type JsType `json:"type"` Items *Item `json:"item,omitempty"` Properities map[string]*Property `json:"properities,omitempty"` AdditionalProperities *Property `json:"additionalProperities,omitempty"` } +// TODO: panic for now, add support for adding schemas to $defs in case of cycles + type Item struct { Type JsType `json:"type"` } -func NewSchema(goType reflect.Type) (*Schema, error) { - rootProp, err := properity(goType) +func NewSchema(golangType reflect.Type) (*Schema, error) { + traceSet := map[reflect.Type]struct{}{} + traceSlice := []reflect.Type{} + rootProp, err := toProperity(golangType, traceSet, traceSlice) if err != nil { return nil, err } @@ -50,39 +53,52 @@ const ( Array = "array" ) -func jsType(goType reflect.Type) (JsType, error) { - switch goType.Kind() { +func javascriptType(golangType reflect.Type) (JsType, error) { + switch golangType.Kind() { case reflect.Bool: return Boolean, nil case reflect.String: return String, nil - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Float32, reflect.Float64: + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, + reflect.Float32, reflect.Float64: + return Number, nil case reflect.Struct: return Object, nil // TODO: add support for pattern properities to account for maps case reflect.Map: - if goType.Key().Kind() != reflect.String { - return Invalid, fmt.Errorf("only strings map keys are valid. key type: ", goType.Key().Kind()) + if golangType.Key().Kind() != reflect.String { + return Invalid, fmt.Errorf("only strings map keys are valid. key type: %v", golangType.Key().Kind()) } return Object, nil case reflect.Array, reflect.Slice: return Array, nil default: - return Invalid, fmt.Errorf("unhandled golang type: %s", goType) + return Invalid, fmt.Errorf("unhandled golang type: %s", golangType) } } +func errWithTrace(prefix string, trace []reflect.Type) error { + traceString := "" + for _, golangType := range trace { + traceString += " -> " + golangType.Name() + } + return fmt.Errorf("[ERROR] " + prefix + " type traveral trace: " + traceString) +} + // TODO: handle case of self referential pointers in structs // TODO: add doc string explaining numHistoryOccurances -func properity(goType reflect.Type, numHistoryOccurances map[string]int) (*Property, error) { +func toProperity(golangType reflect.Type, traceSet map[reflect.Type]struct{}, traceSlice []reflect.Type) (*Property, error) { + traceSlice = append(traceSlice, golangType) + // *Struct and Struct generate identical json schemas - if goType.Kind() == reflect.Pointer { - return properity(goType.Elem()) + if golangType.Kind() == reflect.Pointer { + return toProperity(golangType.Elem(), traceSet, traceSlice) } - rootJsType, err := jsType(goType) + rootJavascriptType, err := javascriptType(golangType) // TODO: recursive debugging can be a pain. Make sure the error localtion // floats up @@ -91,8 +107,8 @@ func properity(goType reflect.Type, numHistoryOccurances map[string]int) (*Prope } var items *Item - if goType.Kind() == reflect.Array || goType.Kind() == reflect.Slice { - elemJsType, err := jsType(goType.Elem()) + if golangType.Kind() == reflect.Array || golangType.Kind() == reflect.Slice { + elemJsType, err := javascriptType(golangType.Elem()) if err != nil { // TODO: float up error in case of deep recursion return nil, err @@ -104,48 +120,52 @@ func properity(goType reflect.Type, numHistoryOccurances map[string]int) (*Prope } properities := map[string]*Property{} - var additionalProperities *Property // TODO: for reflect.Map case for prop computation - - if goType.Kind() == reflect.Struct { - for i := 0; i < goType.NumField(); i++ { - field := goType.Field(i) + if golangType.Kind() == reflect.Struct { + for i := 0; i < golangType.NumField(); i++ { + child := golangType.Field(i) // compute child properties - fieldJsonTag := field.Tag.Get("json") - fieldName := strings.Split(fieldJsonTag, ",")[0] + childJsonTag := child.Tag.Get("json") + childName := strings.Split(childJsonTag, ",")[0] - // stopgap infinite recursion - numHistoryOccurances[fieldName] += 1 - if numHistoryOccurances[fieldName] > MaxHistoryOccurances { - return nil + // skip non json annotated fields + if childName == "" { + continue } - fieldProps, err := properity(field.Type) - numHistoryOccurances[fieldName] -= 1 + + // detect cycles. Fail if a cycle is detected + // TODO: Add references here for cycles + _, ok := traceSet[child.Type] + if ok { + fmt.Println("[DEBUG] traceSet: ", traceSet) + return nil, errWithTrace("cycle detected", traceSlice) + } + + // add current child field to history + traceSet[child.Type] = struct{}{} + + // recursively compute properties for this child field + fieldProps, err := toProperity(child.Type, traceSet, traceSlice) // TODO: make sure this error floats up with context if err != nil { return nil, err } - if fieldJsonTag != "" { - properities[fieldName] = fieldProps - } else if additionalProperities == nil { - // TODO: add error disallowing self referenincing without json tags - additionalProperities = fieldProps - } else { - // TODO: float error up with context - return nil, fmt.Errorf("only one non json annotated field allowed") - } + // traversal complete, delete child from history + delete(traceSet, child.Type) + + properities[childName] = fieldProps } } - return &Property{ - Type: rootJsType, - Items: items, - Properities: properities, - AdditionalProperities: additionalProperities, - }, nil + traceSlice = traceSlice[:len(traceSlice)-1] + return &Property{ + Type: rootJavascriptType, + Items: items, + Properities: properities, + }, nil } diff --git a/bundle/schema/schema_test.go b/bundle/schema/schema_test.go new file mode 100644 index 00000000..d981831f --- /dev/null +++ b/bundle/schema/schema_test.go @@ -0,0 +1,162 @@ +package schema + +import ( + "encoding/json" + "fmt" + "reflect" + "testing" + + "github.com/stretchr/testify/assert" +) + +// TODO: add tests to assert that these are valid json schemas. Maybe validate some +// json/yaml documents againts them, by unmarshalling a value + +func TestNumberStringBooleanSchema(t *testing.T) { + type Foo struct { + IntVal int `json:"int_val"` + Int8Val int8 `json:"int8_val"` + Int16Val int16 `json:"int16_val"` + Int32Val int32 `json:"int32_val"` + Int64Val int64 `json:"int64_val"` + + Uint8Val int8 `json:"uint8_val"` + Uint16Val int16 `json:"uint16_val"` + Uint32Val int32 `json:"uint32_val"` + Uint64Val int64 `json:"uint64_val"` + + Float32Val int64 `json:"float32_val"` + Float64Val int64 `json:"float64_val"` + + StringVal string `json:"string_val"` + + BoolVal string `json:"bool_val"` + } + + elem := Foo{} + + schema, err := NewSchema(reflect.TypeOf(elem)) + assert.NoError(t, err) + + jsonSchema, err := json.MarshalIndent(schema, " ", " ") + assert.NoError(t, err) + + expected := + `{ + "type": "object", + "properities": { + "bool_val": { + "type": "string" + }, + "float32_val": { + "type": "number" + }, + "float64_val": { + "type": "number" + }, + "int16_val": { + "type": "number" + }, + "int32_val": { + "type": "number" + }, + "int64_val": { + "type": "number" + }, + "int8_val": { + "type": "number" + }, + "int_val": { + "type": "number" + }, + "string_val": { + "type": "string" + }, + "uint16_val": { + "type": "number" + }, + "uint32_val": { + "type": "number" + }, + "uint64_val": { + "type": "number" + }, + "uint8_val": { + "type": "number" + } + } + }` + + fmt.Println("[DEBUG] actual: ", string(jsonSchema)) + fmt.Println("[DEBUG] expected: ", expected) + assert.Equal(t, expected, string(jsonSchema)) +} + +func TestObjectSchema(t *testing.T) { + type Person struct { + Name string `json:"name"` + Age int `json:"age,omitempty"` + } + + type Plot struct { + Stakes []string `json:"stakes"` + } + + type Story struct { + Hero Person `json:"hero"` + Villian Person `json:"villian"` + Plot Plot `json:"plot"` + } + + elem := Story{} + + schema, err := NewSchema(reflect.TypeOf(elem)) + assert.NoError(t, err) + + jsonSchema, err := json.MarshalIndent(schema, " ", " ") + assert.NoError(t, err) + + expected := + `{ + "type": "object", + "properities": { + "hero": { + "type": "object", + "properities": { + "age": { + "type": "number" + }, + "name": { + "type": "string" + } + } + }, + "plot": { + "type": "object", + "properities": { + "stakes": { + "type": "array", + "item": { + "type": "string" + } + } + } + }, + "villian": { + "type": "object", + "properities": { + "age": { + "type": "number" + }, + "name": { + "type": "string" + } + } + } + } + }` + + fmt.Println("[DEBUG] actual: ", string(jsonSchema)) + fmt.Println("[DEBUG] expected: ", expected) + assert.Equal(t, expected, string(jsonSchema)) +}