From 75f252e51c7ece6f7a9d65c674ec6dbc5a8ead47 Mon Sep 17 00:00:00 2001 From: Shreyas Goenka Date: Tue, 20 Aug 2024 15:34:49 +0200 Subject: [PATCH] Improve JSON schema --- bundle/schema/schema.go | 436 ++++++++++++++++++----------------- libs/dyn/path.go | 4 +- libs/dyn/visit.go | 8 +- libs/dyn/visit_set.go | 4 +- libs/jsonschema/from_type.go | 204 ++++++++++++++++ libs/jsonschema/schema.go | 16 +- 6 files changed, 454 insertions(+), 218 deletions(-) create mode 100644 libs/jsonschema/from_type.go diff --git a/bundle/schema/schema.go b/bundle/schema/schema.go index ac0b4f2e..d5c6c1f6 100644 --- a/bundle/schema/schema.go +++ b/bundle/schema/schema.go @@ -1,26 +1,23 @@ package schema import ( - "container/list" - "fmt" "reflect" - "strings" "github.com/databricks/cli/libs/dyn/dynvar" "github.com/databricks/cli/libs/jsonschema" ) -// Fields tagged "readonly" should not be emitted in the schema as they are -// computed at runtime, and should not be assigned a value by the bundle author. -const readonlyTag = "readonly" +// // Fields tagged "readonly" should not be emitted in the schema as they are +// // computed at runtime, and should not be assigned a value by the bundle author. +// const readonlyTag = "readonly" -// Annotation for internal bundle fields that should not be exposed to customers. -// Fields can be tagged as "internal" to remove them from the generated schema. -const internalTag = "internal" +// // Annotation for internal bundle fields that should not be exposed to customers. +// // Fields can be tagged as "internal" to remove them from the generated schema. +// const internalTag = "internal" -// Annotation for bundle fields that have been deprecated. -// Fields tagged as "deprecated" are removed/omitted from the generated schema. -const deprecatedTag = "deprecated" +// // Annotation for bundle fields that have been deprecated. +// // Fields tagged as "deprecated" are removed/omitted from the generated schema. +// const deprecatedTag = "deprecated" // This function translates golang types into json schema. Here is the mapping // between json schema types and golang types @@ -44,38 +41,61 @@ const deprecatedTag = "deprecated" // - []MyStruct -> {type: object, properties: {}, additionalProperties: false} // for details visit: https://json-schema.org/understanding-json-schema/reference/object.html#properties func New(golangType reflect.Type, docs *Docs) (*jsonschema.Schema, error) { - tracker := newTracker() - schema, err := safeToSchema(golangType, docs, "", tracker) + + s, err := jsonschema.FromType(golangType, jsonschema.FromTypeOptions{ + Transform: func(s jsonschema.Schema) jsonschema.Schema { + if s.Type == jsonschema.NumberType || s.Type == jsonschema.BooleanType { + s = jsonschema.Schema{ + AnyOf: []jsonschema.Schema{ + s, + { + Type: jsonschema.StringType, + // TODO: + Pattern: dynvar.VariableRegex, + }, + }, + } + } + return s + }, + }) if err != nil { - return nil, tracker.errWithTrace(err.Error(), "root") + return nil, err } - return schema, nil + return &s, nil + + // tracker := newTracker() + // schema, err := safeToSchema(golangType, docs, "", tracker) + // if err != nil { + // return nil, tracker.errWithTrace(err.Error(), "root") + // } + // return schema, nil } -func jsonSchemaType(golangType reflect.Type) (jsonschema.Type, error) { - switch golangType.Kind() { - case reflect.Bool: - return jsonschema.BooleanType, nil - case reflect.String: - return jsonschema.StringType, nil - 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: +// func jsonSchemaType(golangType reflect.Type) (jsonschema.Type, error) { +// switch golangType.Kind() { +// case reflect.Bool: +// return jsonschema.BooleanType, nil +// case reflect.String: +// return jsonschema.StringType, nil +// 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 jsonschema.NumberType, nil - case reflect.Struct: - return jsonschema.ObjectType, nil - case reflect.Map: - if golangType.Key().Kind() != reflect.String { - return jsonschema.InvalidType, fmt.Errorf("only strings map keys are valid. key type: %v", golangType.Key().Kind()) - } - return jsonschema.ObjectType, nil - case reflect.Array, reflect.Slice: - return jsonschema.ArrayType, nil - default: - return jsonschema.InvalidType, fmt.Errorf("unhandled golang type: %s", golangType) - } -} +// return jsonschema.NumberType, nil +// case reflect.Struct: +// return jsonschema.ObjectType, nil +// case reflect.Map: +// if golangType.Key().Kind() != reflect.String { +// return jsonschema.InvalidType, fmt.Errorf("only strings map keys are valid. key type: %v", golangType.Key().Kind()) +// } +// return jsonschema.ObjectType, nil +// case reflect.Array, reflect.Slice: +// return jsonschema.ArrayType, nil +// default: +// return jsonschema.InvalidType, fmt.Errorf("unhandled golang type: %s", golangType) +// } +// } // A wrapper over toSchema function to: // 1. Detect cycles in the bundle config struct. @@ -92,196 +112,196 @@ func jsonSchemaType(golangType reflect.Type) (jsonschema.Type, error) { // like array, map or no json tags // // - tracker: Keeps track of types / traceIds seen during recursive traversal -func safeToSchema(golangType reflect.Type, docs *Docs, traceId string, tracker *tracker) (*jsonschema.Schema, error) { - // HACK to unblock CLI release (13th Feb 2024). This is temporary until proper - // support for recursive types is added to the schema generator. PR: https://github.com/databricks/cli/pull/1204 - if traceId == "for_each_task" { - return &jsonschema.Schema{ - Type: jsonschema.ObjectType, - }, nil - } +// func safeToSchema(golangType reflect.Type, docs *Docs, traceId string, tracker *tracker) (*jsonschema.Schema, error) { +// // HACK to unblock CLI release (13th Feb 2024). This is temporary until proper +// // support for recursive types is added to the schema generator. PR: https://github.com/databricks/cli/pull/1204 +// if traceId == "for_each_task" { +// return &jsonschema.Schema{ +// Type: jsonschema.ObjectType, +// }, nil +// } - // 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 where two properties (directly or indirectly) pointing to each other - // - // see: https://json-schema.org/understanding-json-schema/structuring.html#recursion - // for details - if tracker.hasCycle(golangType) { - return nil, fmt.Errorf("cycle detected") - } +// // 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 where two properties (directly or indirectly) pointing to each other +// // +// // see: https://json-schema.org/understanding-json-schema/structuring.html#recursion +// // for details +// if tracker.hasCycle(golangType) { +// return nil, fmt.Errorf("cycle detected") +// } - tracker.push(golangType, traceId) - props, err := toSchema(golangType, docs, tracker) - if err != nil { - return nil, err - } - tracker.pop(golangType) - return props, nil -} +// tracker.push(golangType, traceId) +// props, err := toSchema(golangType, docs, tracker) +// if err != nil { +// return nil, err +// } +// tracker.pop(golangType) +// return props, nil +// } // This function returns all member fields of the provided type. // If the type has embedded (aka anonymous) fields, this function traverses // those in a breadth first manner -func getStructFields(golangType reflect.Type) []reflect.StructField { - fields := []reflect.StructField{} - bfsQueue := list.New() +// func getStructFields(golangType reflect.Type) []reflect.StructField { +// fields := []reflect.StructField{} +// bfsQueue := list.New() - for i := 0; i < golangType.NumField(); i++ { - bfsQueue.PushBack(golangType.Field(i)) - } - for bfsQueue.Len() > 0 { - front := bfsQueue.Front() - field := front.Value.(reflect.StructField) - bfsQueue.Remove(front) +// for i := 0; i < golangType.NumField(); i++ { +// 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 - } +// if !field.Anonymous { +// fields = append(fields, field) +// continue +// } - fieldType := field.Type - if fieldType.Kind() == reflect.Pointer { - fieldType = fieldType.Elem() - } +// fieldType := field.Type +// if fieldType.Kind() == reflect.Pointer { +// fieldType = fieldType.Elem() +// } - for i := 0; i < fieldType.NumField(); i++ { - bfsQueue.PushBack(fieldType.Field(i)) - } - } - return fields -} +// for i := 0; i < fieldType.NumField(); i++ { +// bfsQueue.PushBack(fieldType.Field(i)) +// } +// } +// return fields +// } -func toSchema(golangType reflect.Type, docs *Docs, tracker *tracker) (*jsonschema.Schema, error) { - // *Struct and Struct generate identical json schemas - if golangType.Kind() == reflect.Pointer { - return safeToSchema(golangType.Elem(), docs, "", tracker) - } - if golangType.Kind() == reflect.Interface { - return &jsonschema.Schema{}, nil - } +// func toSchema(golangType reflect.Type, docs *Docs, tracker *tracker) (*jsonschema.Schema, error) { +// // *Struct and Struct generate identical json schemas +// if golangType.Kind() == reflect.Pointer { +// return safeToSchema(golangType.Elem(), docs, "", tracker) +// } +// if golangType.Kind() == reflect.Interface { +// return &jsonschema.Schema{}, nil +// } - rootJavascriptType, err := jsonSchemaType(golangType) - if err != nil { - return nil, err - } - jsonSchema := &jsonschema.Schema{Type: rootJavascriptType} +// rootJavascriptType, err := jsonSchemaType(golangType) +// if err != nil { +// return nil, err +// } +// jsonSchema := &jsonschema.Schema{Type: rootJavascriptType} - // If the type is a non-string primitive, then we allow it to be a string - // provided it's a pure variable reference (ie only a single variable reference). - if rootJavascriptType == jsonschema.BooleanType || rootJavascriptType == jsonschema.NumberType { - jsonSchema = &jsonschema.Schema{ - AnyOf: []*jsonschema.Schema{ - { - Type: rootJavascriptType, - }, - { - Type: jsonschema.StringType, - Pattern: dynvar.VariableRegex, - }, - }, - } - } +// // If the type is a non-string primitive, then we allow it to be a string +// // provided it's a pure variable reference (ie only a single variable reference). +// if rootJavascriptType == jsonschema.BooleanType || rootJavascriptType == jsonschema.NumberType { +// jsonSchema = &jsonschema.Schema{ +// AnyOf: []*jsonschema.Schema{ +// { +// Type: rootJavascriptType, +// }, +// { +// Type: jsonschema.StringType, +// Pattern: dynvar.VariableRegex, +// }, +// }, +// } +// } - if docs != nil { - jsonSchema.Description = docs.Description - } +// if docs != nil { +// jsonSchema.Description = docs.Description +// } - // case array/slice - if golangType.Kind() == reflect.Array || golangType.Kind() == reflect.Slice { - elemGolangType := golangType.Elem() - elemJavascriptType, err := jsonSchemaType(elemGolangType) - if err != nil { - return nil, err - } - var childDocs *Docs - if docs != nil { - childDocs = docs.Items - } - elemProps, err := safeToSchema(elemGolangType, childDocs, "", tracker) - if err != nil { - return nil, err - } - jsonSchema.Items = &jsonschema.Schema{ - Type: elemJavascriptType, - Properties: elemProps.Properties, - AdditionalProperties: elemProps.AdditionalProperties, - Items: elemProps.Items, - Required: elemProps.Required, - } - } +// // case array/slice +// if golangType.Kind() == reflect.Array || golangType.Kind() == reflect.Slice { +// elemGolangType := golangType.Elem() +// elemJavascriptType, err := jsonSchemaType(elemGolangType) +// if err != nil { +// return nil, err +// } +// var childDocs *Docs +// if docs != nil { +// childDocs = docs.Items +// } +// elemProps, err := safeToSchema(elemGolangType, childDocs, "", tracker) +// if err != nil { +// return nil, err +// } +// jsonSchema.Items = &jsonschema.Schema{ +// Type: elemJavascriptType, +// Properties: elemProps.Properties, +// AdditionalProperties: elemProps.AdditionalProperties, +// Items: elemProps.Items, +// Required: elemProps.Required, +// } +// } - // case map - if golangType.Kind() == reflect.Map { - if golangType.Key().Kind() != reflect.String { - return nil, fmt.Errorf("only string keyed maps allowed") - } - var childDocs *Docs - if docs != nil { - childDocs = docs.AdditionalProperties - } - jsonSchema.AdditionalProperties, err = safeToSchema(golangType.Elem(), childDocs, "", tracker) - if err != nil { - return nil, err - } - } +// // case map +// if golangType.Kind() == reflect.Map { +// if golangType.Key().Kind() != reflect.String { +// return nil, fmt.Errorf("only string keyed maps allowed") +// } +// var childDocs *Docs +// if docs != nil { +// childDocs = docs.AdditionalProperties +// } +// jsonSchema.AdditionalProperties, err = safeToSchema(golangType.Elem(), childDocs, "", tracker) +// if err != nil { +// return nil, err +// } +// } - // case struct - if golangType.Kind() == reflect.Struct { - children := getStructFields(golangType) - properties := map[string]*jsonschema.Schema{} - required := []string{} - for _, child := range children { - bundleTag := child.Tag.Get("bundle") - // Fields marked as "readonly", "internal" or "deprecated" are skipped - // while generating the schema - if bundleTag == readonlyTag || bundleTag == internalTag || bundleTag == deprecatedTag { - continue - } +// // case struct +// if golangType.Kind() == reflect.Struct { +// children := getStructFields(golangType) +// properties := map[string]*jsonschema.Schema{} +// required := []string{} +// for _, child := range children { +// bundleTag := child.Tag.Get("bundle") +// // Fields marked as "readonly", "internal" or "deprecated" are skipped +// // while generating the schema +// if bundleTag == readonlyTag || bundleTag == internalTag || bundleTag == deprecatedTag { +// continue +// } - // get child json tags - childJsonTag := strings.Split(child.Tag.Get("json"), ",") - childName := childJsonTag[0] +// // get child json tags +// childJsonTag := strings.Split(child.Tag.Get("json"), ",") +// childName := childJsonTag[0] - // skip children that have no json tags, the first json tag is "" - // or the first json tag is "-" - if childName == "" || childName == "-" { - continue - } +// // skip children that have no json tags, the first json tag is "" +// // or the first json tag is "-" +// if childName == "" || childName == "-" { +// continue +// } - // get docs for the child if they exist - var childDocs *Docs - if docs != nil { - if val, ok := docs.Properties[childName]; ok { - childDocs = val - } - } +// // get docs for the child if they exist +// var childDocs *Docs +// if docs != nil { +// if val, ok := docs.Properties[childName]; ok { +// childDocs = val +// } +// } - // compute if the child is a required field. Determined by the - // presence of "omitempty" in the json tags - hasOmitEmptyTag := false - for i := 1; i < len(childJsonTag); i++ { - if childJsonTag[i] == "omitempty" { - hasOmitEmptyTag = true - } - } - if !hasOmitEmptyTag { - required = append(required, childName) - } +// // compute if the child is a required field. Determined by the +// // presence of "omitempty" in the json tags +// hasOmitEmptyTag := false +// for i := 1; i < len(childJsonTag); i++ { +// if childJsonTag[i] == "omitempty" { +// hasOmitEmptyTag = true +// } +// } +// if !hasOmitEmptyTag { +// required = append(required, childName) +// } - // compute Schema.Properties for the child recursively - fieldProps, err := safeToSchema(child.Type, childDocs, childName, tracker) - if err != nil { - return nil, err - } - properties[childName] = fieldProps - } +// // compute Schema.Properties for the child recursively +// fieldProps, err := safeToSchema(child.Type, childDocs, childName, tracker) +// if err != nil { +// return nil, err +// } +// properties[childName] = fieldProps +// } - jsonSchema.AdditionalProperties = false - jsonSchema.Properties = properties - jsonSchema.Required = required - } +// jsonSchema.AdditionalProperties = false +// jsonSchema.Properties = properties +// jsonSchema.Required = required +// } - return jsonSchema, nil -} +// return jsonSchema, nil +// } diff --git a/libs/dyn/path.go b/libs/dyn/path.go index 76377e2d..1d0d4afa 100644 --- a/libs/dyn/path.go +++ b/libs/dyn/path.go @@ -18,11 +18,11 @@ func (c pathComponent) Index() int { return c.index } -func (c pathComponent) isKey() bool { +func (c pathComponent) IsKey() bool { return c.key != "" } -func (c pathComponent) isIndex() bool { +func (c pathComponent) IsIndex() bool { return c.key == "" } diff --git a/libs/dyn/visit.go b/libs/dyn/visit.go index 4d3cf501..ee30227d 100644 --- a/libs/dyn/visit.go +++ b/libs/dyn/visit.go @@ -14,9 +14,9 @@ type cannotTraverseNilError struct { func (e cannotTraverseNilError) Error() string { component := e.p[len(e.p)-1] switch { - case component.isKey(): + case component.IsKey(): return fmt.Sprintf("expected a map to index %q, found nil", e.p) - case component.isIndex(): + case component.IsIndex(): return fmt.Sprintf("expected a sequence to index %q, found nil", e.p) default: panic("invalid component") @@ -90,7 +90,7 @@ func (component pathComponent) visit(v Value, prefix Path, suffix Pattern, opts path := append(prefix, component) switch { - case component.isKey(): + case component.IsKey(): // Expect a map to be set if this is a key. switch v.Kind() { case KindMap: @@ -129,7 +129,7 @@ func (component pathComponent) visit(v Value, prefix Path, suffix Pattern, opts l: v.l, }, nil - case component.isIndex(): + case component.IsIndex(): // Expect a sequence to be set if this is an index. switch v.Kind() { case KindSequence: diff --git a/libs/dyn/visit_set.go b/libs/dyn/visit_set.go index b086fb8a..cf3b3b31 100644 --- a/libs/dyn/visit_set.go +++ b/libs/dyn/visit_set.go @@ -32,7 +32,7 @@ func SetByPath(v Value, p Path, nv Value) (Value, error) { path := append(prefix, component) switch { - case component.isKey(): + case component.IsKey(): // Expect a map to be set if this is a key. m, ok := v.AsMap() if !ok { @@ -48,7 +48,7 @@ func SetByPath(v Value, p Path, nv Value) (Value, error) { l: v.l, }, nil - case component.isIndex(): + case component.IsIndex(): // Expect a sequence to be set if this is an index. s, ok := v.AsSequence() if !ok { diff --git a/libs/jsonschema/from_type.go b/libs/jsonschema/from_type.go new file mode 100644 index 00000000..43ce9a85 --- /dev/null +++ b/libs/jsonschema/from_type.go @@ -0,0 +1,204 @@ +package jsonschema + +import ( + "container/list" + "fmt" + "reflect" + "slices" + "strings" +) + +var InvalidSchema = Schema{ + Type: InvalidType, +} + +// Fields tagged "readonly" should not be emitted in the schema as they are +// computed at runtime, and should not be assigned a value by the bundle author. +const readonlyTag = "readonly" + +// Annotation for internal bundle fields that should not be exposed to customers. +// Fields can be tagged as "internal" to remove them from the generated schema. +const internalTag = "internal" + +// Annotation for bundle fields that have been deprecated. +// Fields tagged as "deprecated" are removed/omitted from the generated schema. +const deprecatedTag = "deprecated" + +// TODO: Test what happens with invalid cycles? Do integration tests fail? +// TODO: Call out in the PR description that recursive types like "for_each_task" +// are now supported. + +type FromTypeOptions struct { + // Transformation function to apply after generating the schema. + Transform func(s Schema) Schema +} + +// TODO: Skip generating schema for interface fields. +func FromType(typ reflect.Type, opts FromTypeOptions) (Schema, error) { + // Dereference pointers if necessary. + for typ.Kind() == reflect.Ptr { + typ = typ.Elem() + } + + // An interface value can never be serialized from text, and thus is explicitly + // set to null and disallowed in the schema. + if typ.Kind() == reflect.Interface { + return Schema{Type: NullType}, nil + } + + var res Schema + var err error + + // TODO: Narrow down the number of Go types handled here. + switch typ.Kind() { + case reflect.Struct: + res, err = fromTypeStruct(typ, opts) + case reflect.Slice: + res, err = fromTypeSlice(typ, opts) + case reflect.Map: + res, err = fromTypeMap(typ, opts) + // TODO: Should the primitive functions below be inlined? + case reflect.String: + res = Schema{Type: StringType} + case reflect.Bool: + res = Schema{Type: BooleanType} + // case reflect.Int, reflect.Int32, reflect.Int64: + // res = Schema{Type: IntegerType} + 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: + res = Schema{Type: NumberType} + default: + return InvalidSchema, fmt.Errorf("unsupported type: %s", typ.Kind()) + } + if err != nil { + return InvalidSchema, err + } + + if opts.Transform != nil { + res = opts.Transform(res) + } + return res, nil +} + +// This function returns all member fields of the provided type. +// If the type has embedded (aka anonymous) fields, this function traverses +// those in a breadth first manner +func getStructFields(golangType reflect.Type) []reflect.StructField { + fields := []reflect.StructField{} + bfsQueue := list.New() + + for i := 0; i < golangType.NumField(); i++ { + 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 + } + + fieldType := field.Type + if fieldType.Kind() == reflect.Pointer { + fieldType = fieldType.Elem() + } + + for i := 0; i < fieldType.NumField(); i++ { + bfsQueue.PushBack(fieldType.Field(i)) + } + } + return fields +} + +func fromTypeStruct(typ reflect.Type, opts FromTypeOptions) (Schema, error) { + if typ.Kind() != reflect.Struct { + return InvalidSchema, fmt.Errorf("expected struct, got %s", typ.Kind()) + } + + res := Schema{ + Type: ObjectType, + + Properties: make(map[string]*Schema), + + // TODO: Confirm that empty arrays are not serialized. + Required: []string{}, + + AdditionalProperties: false, + } + + structFields := getStructFields(typ) + + for _, structField := range structFields { + bundleTags := strings.Split(structField.Tag.Get("bundle"), ",") + // Fields marked as "readonly", "internal" or "deprecated" are skipped + // while generating the schema + if slices.Contains(bundleTags, readonlyTag) || + slices.Contains(bundleTags, internalTag) || + slices.Contains(bundleTags, deprecatedTag) { + continue + } + + jsonTags := strings.Split(structField.Tag.Get("json"), ",") + // Do not include fields in the schema that will not be serialized during + // JSON marshalling. + if jsonTags[0] == "" || jsonTags[0] == "-" { + continue + } + // "omitempty" tags in the Go SDK structs represent fields that not are + // required to be present in the API payload. Thus its absence in the + // tags list indicates that the field is required. + if !slices.Contains(jsonTags, "omitempty") { + res.Required = append(res.Required, jsonTags[0]) + } + + s, err := FromType(structField.Type, opts) + if err != nil { + return InvalidSchema, err + } + res.Properties[jsonTags[0]] = &s + } + + return res, nil +} + +func fromTypeSlice(typ reflect.Type, opts FromTypeOptions) (Schema, error) { + if typ.Kind() != reflect.Slice { + return InvalidSchema, fmt.Errorf("expected slice, got %s", typ.Kind()) + } + + res := Schema{ + Type: ArrayType, + } + + items, err := FromType(typ.Elem(), opts) + if err != nil { + return InvalidSchema, err + } + + res.Items = &items + return res, nil +} + +func fromTypeMap(typ reflect.Type, opts FromTypeOptions) (Schema, error) { + if typ.Kind() != reflect.Map { + return InvalidSchema, fmt.Errorf("expected map, got %s", typ.Kind()) + } + + if typ.Key().Kind() != reflect.String { + return InvalidSchema, fmt.Errorf("found map with non-string key: %v", typ.Key()) + } + + res := Schema{ + Type: ObjectType, + } + + additionalProperties, err := FromType(typ.Elem(), opts) + if err != nil { + return InvalidSchema, err + } + res.AdditionalProperties = additionalProperties + return res, nil +} diff --git a/libs/jsonschema/schema.go b/libs/jsonschema/schema.go index f1e223ec..6c6a89c9 100644 --- a/libs/jsonschema/schema.go +++ b/libs/jsonschema/schema.go @@ -13,6 +13,13 @@ import ( ) // defines schema for a json object +// TODO: Remove pointers from properties and AnyOf. +// TODO: Can / should we emulate dyn.V here in having a readonly model for the data +// structure? Makes it easier to reason about. +// +// Any performance issues can be addressed by storing the schema +// +// as an embedded file. type Schema struct { // Type of the object Type Type `json:"type,omitempty"` @@ -23,6 +30,7 @@ type Schema struct { // Expected value for the JSON object. The object value must be equal to this // field if it's specified in the schema. + // TODO: Generics here? OR maybe a type from the reflection package. Const any `json:"const,omitempty"` // Schemas for the fields of an struct. The keys are the first json tag. @@ -38,7 +46,8 @@ type Schema struct { // A boolean type with value false. Setting false here validates that all // properties in the config have been defined in the json schema as properties // - // Its type during runtime will either be *Schema or bool + // Its type during runtime will either be Schema or bool + // TODO: Generics to represent either a Schema{} or a bool. AdditionalProperties any `json:"additionalProperties,omitempty"` // Required properties for the object. Any fields missing the "omitempty" @@ -63,7 +72,7 @@ type Schema struct { Extension // Schema that must match any of the schemas in the array - AnyOf []*Schema `json:"anyOf,omitempty"` + AnyOf []Schema `json:"anyOf,omitempty"` } // Default value defined in a JSON Schema, represented as a string. @@ -120,7 +129,10 @@ func (s *Schema) SetByPath(path string, v Schema) error { type Type string const ( + // Default zero value of a schema. This does not correspond to a type in the + // JSON schema spec and is an internal type defined for convenience. InvalidType Type = "invalid" + NullType Type = "null" BooleanType Type = "boolean" StringType Type = "string" NumberType Type = "number"