2023-01-13 03:52:25 +00:00
|
|
|
package schema
|
|
|
|
|
|
|
|
import (
|
2023-01-13 18:08:21 +00:00
|
|
|
"container/list"
|
2023-01-13 03:52:25 +00:00
|
|
|
"fmt"
|
|
|
|
"reflect"
|
|
|
|
"strings"
|
|
|
|
)
|
|
|
|
|
2023-01-17 10:26:46 +00:00
|
|
|
// TODO: Add tests for the error cases, forcefully triggering them
|
|
|
|
// TODO: Add example documentation
|
|
|
|
// TODO: Do final checks for more validation that can be added to json schema
|
2023-01-17 12:47:31 +00:00
|
|
|
// TODO: Run all tests to see code coverage and add tests for missing assertions
|
2023-01-17 12:59:56 +00:00
|
|
|
|
|
|
|
// defines schema for a json object
|
|
|
|
type Schema struct {
|
|
|
|
// Type of the object
|
|
|
|
Type JavascriptType `json:"type"`
|
|
|
|
|
2023-01-18 15:02:38 +00:00
|
|
|
// Description of the object. This is rendered as inline documentation in the
|
|
|
|
// IDE
|
|
|
|
Description string `json:"description,omitempty"`
|
|
|
|
|
2023-01-17 12:59:56 +00:00
|
|
|
// keys are named properties of the object
|
|
|
|
// values are json schema for the values of the named properties
|
|
|
|
Properties map[string]*Schema `json:"properties,omitempty"`
|
|
|
|
|
|
|
|
// the schema for all values of the array
|
|
|
|
Items *Schema `json:"items,omitempty"`
|
|
|
|
|
|
|
|
// the schema for any properties not mentioned in the Schema.Properties field.
|
|
|
|
// we leverage this to validate Maps in bundle configuration
|
2023-01-17 16:53:58 +00:00
|
|
|
// OR
|
|
|
|
// a boolean type with value false
|
|
|
|
//
|
|
|
|
// Its type during runtime will either be *Schema or bool
|
|
|
|
AdditionalProperties interface{} `json:"additionalProperties,omitempty"`
|
2023-01-17 12:59:56 +00:00
|
|
|
|
|
|
|
// required properties for the object. Any propertites listed here should
|
|
|
|
// also be listed in Schema.Properties
|
|
|
|
Required []string `json:"required,omitempty"`
|
|
|
|
}
|
|
|
|
|
2023-01-17 11:25:25 +00:00
|
|
|
/*
|
2023-01-17 12:59:56 +00:00
|
|
|
This function translates golang types into json schema. Here is the mapping
|
2023-01-17 11:25:25 +00:00
|
|
|
between json schema types and golang types
|
|
|
|
|
2023-01-17 12:47:31 +00:00
|
|
|
- GolangType -> Javascript type / Json Schema2
|
2023-01-17 11:25:25 +00:00
|
|
|
|
|
|
|
Javascript Primitives:
|
|
|
|
- bool -> boolean
|
|
|
|
- string -> string
|
|
|
|
- int (all variants) -> number
|
|
|
|
- float (all variants) -> number
|
|
|
|
|
2023-01-17 12:47:31 +00:00
|
|
|
Json Schema2 Fields:
|
2023-01-17 11:25:25 +00:00
|
|
|
- map[string]MyStruct -> {
|
|
|
|
type: object
|
|
|
|
additionalProperties: {}
|
|
|
|
}
|
|
|
|
for details visit: https://json-schema.org/understanding-json-schema/reference/object.html#additional-properties
|
|
|
|
|
|
|
|
- []MyStruct -> {
|
|
|
|
type: array
|
|
|
|
items: {}
|
|
|
|
}
|
|
|
|
|
|
|
|
for details visit: https://json-schema.org/understanding-json-schema/reference/array.html#items
|
|
|
|
|
|
|
|
|
|
|
|
- []MyStruct -> {
|
|
|
|
type: object
|
|
|
|
properties: {}
|
2023-01-17 16:53:58 +00:00
|
|
|
additionalProperties: false
|
2023-01-17 11:25:25 +00:00
|
|
|
}
|
2023-01-17 12:43:50 +00:00
|
|
|
for details visit: https://json-schema.org/understanding-json-schema/reference/object.html#properties
|
2023-01-17 11:25:25 +00:00
|
|
|
*/
|
2023-01-18 15:02:38 +00:00
|
|
|
func NewSchema(golangType reflect.Type, docs *Docs) (*Schema, error) {
|
2023-01-13 19:03:24 +00:00
|
|
|
seenTypes := map[reflect.Type]struct{}{}
|
|
|
|
debugTrace := list.New()
|
2023-01-18 15:02:38 +00:00
|
|
|
schema, err := toSchema(golangType, docs, seenTypes, debugTrace)
|
2023-01-13 03:52:25 +00:00
|
|
|
if err != nil {
|
2023-01-13 19:03:24 +00:00
|
|
|
return nil, errWithTrace(err.Error(), debugTrace)
|
2023-01-13 03:52:25 +00:00
|
|
|
}
|
2023-01-18 15:02:38 +00:00
|
|
|
return schema, nil
|
2023-01-13 03:52:25 +00:00
|
|
|
}
|
|
|
|
|
2023-01-13 19:03:24 +00:00
|
|
|
type JavascriptType string
|
2023-01-13 03:52:25 +00:00
|
|
|
|
|
|
|
const (
|
2023-01-13 19:03:24 +00:00
|
|
|
Invalid JavascriptType = "invalid"
|
|
|
|
Boolean JavascriptType = "boolean"
|
|
|
|
String JavascriptType = "string"
|
|
|
|
Number JavascriptType = "number"
|
|
|
|
Object JavascriptType = "object"
|
|
|
|
Array JavascriptType = "array"
|
2023-01-13 03:52:25 +00:00
|
|
|
)
|
|
|
|
|
2023-01-17 11:25:25 +00:00
|
|
|
// TODO: document that only string keys allowed in maps
|
2023-01-13 18:40:09 +00:00
|
|
|
func javascriptType(golangType reflect.Type) (JavascriptType, error) {
|
2023-01-13 15:45:01 +00:00
|
|
|
switch golangType.Kind() {
|
2023-01-13 03:52:25 +00:00
|
|
|
case reflect.Bool:
|
|
|
|
return Boolean, nil
|
|
|
|
case reflect.String:
|
|
|
|
return String, nil
|
2023-01-13 15:45:01 +00:00
|
|
|
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:
|
|
|
|
|
2023-01-13 03:52:25 +00:00
|
|
|
return Number, nil
|
|
|
|
case reflect.Struct:
|
|
|
|
return Object, nil
|
|
|
|
case reflect.Map:
|
2023-01-13 15:45:01 +00:00
|
|
|
if golangType.Key().Kind() != reflect.String {
|
|
|
|
return Invalid, fmt.Errorf("only strings map keys are valid. key type: %v", golangType.Key().Kind())
|
2023-01-13 03:52:25 +00:00
|
|
|
}
|
|
|
|
return Object, nil
|
|
|
|
case reflect.Array, reflect.Slice:
|
|
|
|
return Array, nil
|
|
|
|
default:
|
2023-01-13 15:45:01 +00:00
|
|
|
return Invalid, fmt.Errorf("unhandled golang type: %s", golangType)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-01-13 18:37:35 +00:00
|
|
|
func errWithTrace(prefix string, trace *list.List) error {
|
2023-01-13 19:03:24 +00:00
|
|
|
traceString := "root"
|
2023-01-13 18:37:35 +00:00
|
|
|
curr := trace.Front()
|
2023-01-16 12:18:56 +00:00
|
|
|
for curr != nil {
|
2023-01-13 19:03:24 +00:00
|
|
|
traceString += " -> " + curr.Value.(string)
|
2023-01-13 18:37:35 +00:00
|
|
|
curr = curr.Next()
|
2023-01-13 03:52:25 +00:00
|
|
|
}
|
2023-01-16 12:18:56 +00:00
|
|
|
return fmt.Errorf("[ERROR] " + prefix + ". traversal trace: " + traceString)
|
2023-01-13 03:52:25 +00:00
|
|
|
}
|
|
|
|
|
2023-01-17 16:53:58 +00:00
|
|
|
// A wrapper over toSchema function to detect cycles in the bundle config struct
|
2023-01-18 15:02:38 +00:00
|
|
|
func safeToSchema(golangType reflect.Type, docs *Docs, seenTypes map[reflect.Type]struct{}, debugTrace *list.List) (*Schema, error) {
|
2023-01-17 16:53:58 +00:00
|
|
|
// WE ERROR OUT IF THERE ARE CYCLES IN THE JSON SCHEMA
|
|
|
|
// There are mechanisms to deal with cycles though recursive identifiers in json
|
|
|
|
// schema. However if we use them, we would need to make sure we are able to detect
|
|
|
|
// cycles two properties (directly or indirectly) pointing to each other
|
|
|
|
//
|
|
|
|
// see: https://json-schema.org/understanding-json-schema/structuring.html#recursion
|
|
|
|
// for details
|
2023-01-13 19:03:24 +00:00
|
|
|
_, ok := seenTypes[golangType]
|
2023-01-13 18:08:21 +00:00
|
|
|
if ok {
|
2023-01-13 19:03:24 +00:00
|
|
|
fmt.Println("[DEBUG] traceSet: ", seenTypes)
|
|
|
|
return nil, fmt.Errorf("cycle detected")
|
2023-01-13 18:08:21 +00:00
|
|
|
}
|
2023-01-16 01:27:49 +00:00
|
|
|
// Update set of types in current path
|
2023-01-13 19:03:24 +00:00
|
|
|
seenTypes[golangType] = struct{}{}
|
2023-01-18 15:02:38 +00:00
|
|
|
props, err := toSchema(golangType, docs, seenTypes, debugTrace)
|
2023-01-13 18:08:21 +00:00
|
|
|
if err != nil {
|
2023-01-13 19:03:24 +00:00
|
|
|
return nil, err
|
2023-01-13 18:08:21 +00:00
|
|
|
}
|
2023-01-13 19:03:24 +00:00
|
|
|
delete(seenTypes, golangType)
|
2023-01-13 18:08:21 +00:00
|
|
|
return props, nil
|
|
|
|
}
|
|
|
|
|
2023-01-16 01:23:35 +00:00
|
|
|
// Adds the member fields of golangType to the passed slice. Needed because
|
|
|
|
// golangType can contain embedded fields (aka anonymous)
|
2023-01-16 01:30:41 +00:00
|
|
|
//
|
2023-01-16 01:23:35 +00:00
|
|
|
// The function traverses the embedded fields in a breadth first manner
|
|
|
|
//
|
|
|
|
// params:
|
|
|
|
// fields: slice to which member fields of golangType will be added to
|
2023-01-13 18:08:21 +00:00
|
|
|
func addStructFields(fields []reflect.StructField, golangType reflect.Type) []reflect.StructField {
|
|
|
|
bfsQueue := list.New()
|
|
|
|
|
2023-01-18 11:54:51 +00:00
|
|
|
for i := 0; i < golangType.NumField(); i++ {
|
2023-01-13 18:08:21 +00:00
|
|
|
bfsQueue.PushBack(golangType.Field(i))
|
|
|
|
}
|
|
|
|
for bfsQueue.Len() > 0 {
|
|
|
|
front := bfsQueue.Front()
|
|
|
|
field := front.Value.(reflect.StructField)
|
|
|
|
bfsQueue.Remove(front)
|
|
|
|
|
|
|
|
if !field.Anonymous {
|
|
|
|
fields = append(fields, field)
|
|
|
|
continue
|
|
|
|
}
|
2023-01-13 18:25:42 +00:00
|
|
|
|
|
|
|
fieldType := field.Type
|
|
|
|
if fieldType.Kind() == reflect.Pointer {
|
|
|
|
fieldType = fieldType.Elem()
|
|
|
|
}
|
|
|
|
|
|
|
|
for i := 0; i < fieldType.NumField(); i++ {
|
|
|
|
bfsQueue.PushBack(fieldType.Field(i))
|
2023-01-13 18:08:21 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
return fields
|
|
|
|
}
|
|
|
|
|
2023-01-17 12:43:50 +00:00
|
|
|
// TODO: see what kind of schema will be generated for interface{}
|
|
|
|
|
2023-01-16 01:23:35 +00:00
|
|
|
// params:
|
|
|
|
// golangType: golang type for which json schema properties to generate
|
2023-01-16 01:30:41 +00:00
|
|
|
// seenTypes : set of golang types already seen in path during recursion.
|
2023-01-16 01:23:35 +00:00
|
|
|
// Used to identify cycles.
|
2023-01-16 01:30:41 +00:00
|
|
|
// debugTrace: linked list of golang types encounted. In case of errors this
|
|
|
|
// helps log where the error originated from
|
2023-01-18 15:02:38 +00:00
|
|
|
func toSchema(golangType reflect.Type, docs *Docs, seenTypes map[reflect.Type]struct{}, debugTrace *list.List) (*Schema, error) {
|
2023-01-13 03:52:25 +00:00
|
|
|
// *Struct and Struct generate identical json schemas
|
2023-01-13 15:45:01 +00:00
|
|
|
if golangType.Kind() == reflect.Pointer {
|
2023-01-18 15:02:38 +00:00
|
|
|
return toSchema(golangType.Elem(), docs, seenTypes, debugTrace)
|
2023-01-13 19:03:24 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// TODO: add test case for interfaces
|
|
|
|
if golangType.Kind() == reflect.Interface {
|
|
|
|
return nil, nil
|
2023-01-13 03:52:25 +00:00
|
|
|
}
|
|
|
|
|
2023-01-13 15:45:01 +00:00
|
|
|
rootJavascriptType, err := javascriptType(golangType)
|
2023-01-13 03:52:25 +00:00
|
|
|
if err != nil {
|
2023-01-13 18:08:21 +00:00
|
|
|
return nil, err
|
2023-01-13 03:52:25 +00:00
|
|
|
}
|
|
|
|
|
2023-01-18 15:02:38 +00:00
|
|
|
var description string
|
|
|
|
if docs != nil {
|
|
|
|
description = docs.Documentation
|
|
|
|
}
|
|
|
|
|
2023-01-13 18:08:21 +00:00
|
|
|
// case array/slice
|
2023-01-17 12:47:31 +00:00
|
|
|
var items *Schema
|
2023-01-13 15:45:01 +00:00
|
|
|
if golangType.Kind() == reflect.Array || golangType.Kind() == reflect.Slice {
|
2023-01-13 17:01:53 +00:00
|
|
|
elemGolangType := golangType.Elem()
|
|
|
|
elemJavascriptType, err := javascriptType(elemGolangType)
|
2023-01-13 03:52:25 +00:00
|
|
|
if err != nil {
|
2023-01-13 18:08:21 +00:00
|
|
|
return nil, err
|
2023-01-13 17:01:53 +00:00
|
|
|
}
|
2023-01-18 15:02:38 +00:00
|
|
|
elemProps, err := safeToSchema(elemGolangType, docs, seenTypes, debugTrace)
|
2023-01-13 17:01:53 +00:00
|
|
|
if err != nil {
|
2023-01-13 18:08:21 +00:00
|
|
|
return nil, err
|
2023-01-13 17:01:53 +00:00
|
|
|
}
|
2023-01-17 12:47:31 +00:00
|
|
|
items = &Schema{
|
2023-01-13 17:01:53 +00:00
|
|
|
// TODO: Add a test for slice of object
|
2023-01-17 18:31:18 +00:00
|
|
|
Type: elemJavascriptType,
|
|
|
|
Properties: elemProps.Properties,
|
|
|
|
AdditionalProperties: elemProps.AdditionalProperties,
|
|
|
|
Items: elemProps.Items,
|
2023-01-18 11:54:51 +00:00
|
|
|
Required: elemProps.Required,
|
2023-01-13 03:52:25 +00:00
|
|
|
}
|
2023-01-17 11:25:25 +00:00
|
|
|
// TODO: what if there is an array of maps. Add additional properties to
|
|
|
|
// TODO: what if there are maps of maps
|
2023-01-13 03:52:25 +00:00
|
|
|
}
|
|
|
|
|
2023-01-13 18:08:21 +00:00
|
|
|
// case map
|
2023-01-17 16:53:58 +00:00
|
|
|
var additionalProperties interface{}
|
2023-01-13 18:08:21 +00:00
|
|
|
if golangType.Kind() == reflect.Map {
|
|
|
|
if golangType.Key().Kind() != reflect.String {
|
2023-01-13 19:03:24 +00:00
|
|
|
return nil, fmt.Errorf("only string keyed maps allowed")
|
2023-01-13 18:08:21 +00:00
|
|
|
}
|
2023-01-17 12:43:50 +00:00
|
|
|
// TODO: Add a test for map of maps, and map of slices. Check that there
|
|
|
|
// is already a test for map of objects and map of primites
|
2023-01-18 15:02:38 +00:00
|
|
|
additionalProperties, err = safeToSchema(golangType.Elem(), docs, seenTypes, debugTrace)
|
2023-01-13 18:08:21 +00:00
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
}
|
2023-01-13 11:09:19 +00:00
|
|
|
|
2023-01-13 18:08:21 +00:00
|
|
|
// case struct
|
2023-01-17 12:47:31 +00:00
|
|
|
properties := map[string]*Schema{}
|
2023-01-18 11:54:51 +00:00
|
|
|
required := []string{}
|
2023-01-13 15:45:01 +00:00
|
|
|
if golangType.Kind() == reflect.Struct {
|
2023-01-13 18:08:21 +00:00
|
|
|
children := []reflect.StructField{}
|
|
|
|
children = addStructFields(children, golangType)
|
|
|
|
for _, child := range children {
|
2023-01-13 11:09:19 +00:00
|
|
|
// compute child properties
|
2023-01-18 11:54:51 +00:00
|
|
|
childJsonTag := strings.Split(child.Tag.Get("json"), ",")
|
|
|
|
childName := childJsonTag[0]
|
2023-01-13 19:03:24 +00:00
|
|
|
|
2023-01-17 10:58:32 +00:00
|
|
|
// skip fields that are not annotated or annotated with "-"
|
|
|
|
if childName == "" || childName == "-" {
|
2023-01-13 15:45:01 +00:00
|
|
|
continue
|
2023-01-13 11:09:19 +00:00
|
|
|
}
|
2023-01-13 15:45:01 +00:00
|
|
|
|
2023-01-18 15:02:38 +00:00
|
|
|
// get docs for the child if they exist
|
|
|
|
var childDocs *Docs
|
|
|
|
if docs != nil {
|
|
|
|
if val, ok := docs.Children[childName]; ok {
|
|
|
|
childDocs = &val
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-01-18 11:54:51 +00:00
|
|
|
// TODO: Add test for omitempty
|
|
|
|
hasOmitEmptyTag := false
|
|
|
|
for i := 1; i < len(childJsonTag); i++ {
|
|
|
|
if childJsonTag[i] == "omitempty" {
|
|
|
|
hasOmitEmptyTag = true
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if !hasOmitEmptyTag {
|
|
|
|
required = append(required, childName)
|
|
|
|
}
|
|
|
|
|
|
|
|
// add current field to debug trace
|
|
|
|
// TODO: Add tests testing correct traversal and adding of strings
|
|
|
|
// into debugTrace
|
|
|
|
debugTrace.PushBack(childName)
|
|
|
|
|
2023-01-13 15:45:01 +00:00
|
|
|
// recursively compute properties for this child field
|
2023-01-18 15:02:38 +00:00
|
|
|
fieldProps, err := safeToSchema(child.Type, childDocs, seenTypes, debugTrace)
|
2023-01-13 03:52:25 +00:00
|
|
|
if err != nil {
|
2023-01-13 19:03:24 +00:00
|
|
|
return nil, err
|
2023-01-13 03:52:25 +00:00
|
|
|
}
|
2023-01-16 20:53:20 +00:00
|
|
|
properties[childName] = fieldProps
|
2023-01-13 19:03:24 +00:00
|
|
|
|
|
|
|
// remove current field from debug trace
|
|
|
|
back := debugTrace.Back()
|
|
|
|
debugTrace.Remove(back)
|
2023-01-17 16:53:58 +00:00
|
|
|
|
|
|
|
// set Schema.AdditionalProperties to false
|
|
|
|
additionalProperties = false
|
2023-01-13 03:52:25 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-01-17 12:47:31 +00:00
|
|
|
return &Schema{
|
2023-01-17 10:26:46 +00:00
|
|
|
Type: rootJavascriptType,
|
2023-01-18 15:02:38 +00:00
|
|
|
Description: description,
|
2023-01-17 10:26:46 +00:00
|
|
|
Items: items,
|
2023-01-16 20:53:20 +00:00
|
|
|
Properties: properties,
|
|
|
|
AdditionalProperties: additionalProperties,
|
2023-01-18 11:54:51 +00:00
|
|
|
Required: required,
|
2023-01-13 03:52:25 +00:00
|
|
|
}, nil
|
|
|
|
}
|