more progress on the internal functionality

This commit is contained in:
Shreyas Goenka 2024-08-20 18:40:30 +02:00
parent 43325fdd0a
commit 6d2f88282f
No known key found for this signature in database
GPG Key ID: 92A07DF49CCB0622
5 changed files with 275 additions and 48 deletions

View File

@ -42,23 +42,21 @@ import (
// for details visit: https://json-schema.org/understanding-json-schema/reference/object.html#properties // for details visit: https://json-schema.org/understanding-json-schema/reference/object.html#properties
func New(golangType reflect.Type, docs *Docs) (*jsonschema.Schema, error) { func New(golangType reflect.Type, docs *Docs) (*jsonschema.Schema, error) {
s, err := jsonschema.FromType(golangType, jsonschema.FromTypeOptions{ s, err := jsonschema.FromType(golangType, func(s jsonschema.Schema) jsonschema.Schema {
Transform: func(s jsonschema.Schema) jsonschema.Schema { if s.Type == jsonschema.NumberType || s.Type == jsonschema.BooleanType {
if s.Type == jsonschema.NumberType || s.Type == jsonschema.BooleanType { s = jsonschema.Schema{
s = jsonschema.Schema{ AnyOf: []jsonschema.Schema{
AnyOf: []jsonschema.Schema{ s,
s, {
{ Type: jsonschema.StringType,
Type: jsonschema.StringType, // TODO: Narrow down the scope of the regex match.
// TODO: Narrow down the scope of the regex match. // Also likely need to rename this variable.
// Also likely need to rename this variable. Pattern: dynvar.VariableRegex,
Pattern: dynvar.VariableRegex,
},
}, },
} },
} }
return s }
}, return s
}) })
if err != nil { if err != nil {
return nil, err return nil, err

View File

@ -2,6 +2,7 @@ package schema
import ( import (
"encoding/json" "encoding/json"
"fmt"
"reflect" "reflect"
"testing" "testing"
@ -12,6 +13,11 @@ import (
func TestIntSchema(t *testing.T) { func TestIntSchema(t *testing.T) {
var elemInt int var elemInt int
type Bae struct{}
typ := reflect.TypeOf(Bae{})
fmt.Println(typ.PkgPath())
expected := expected :=
`{ `{
"anyOf": [ "anyOf": [

View File

@ -3,11 +3,13 @@ package jsonschema
import ( import (
"container/list" "container/list"
"fmt" "fmt"
"path"
"reflect" "reflect"
"slices" "slices"
"strings" "strings"
) )
// TODO: Maybe can be removed?
var InvalidSchema = Schema{ var InvalidSchema = Schema{
Type: InvalidType, Type: InvalidType,
} }
@ -28,56 +30,130 @@ const deprecatedTag = "deprecated"
// TODO: Call out in the PR description that recursive types like "for_each_task" // TODO: Call out in the PR description that recursive types like "for_each_task"
// are now supported. // are now supported.
type FromTypeOptions struct { type constructor struct {
// Transformation function to apply after generating the schema. // Map of typ.PkgPath() + "." + typ.Name() to the schema for that type.
Transform func(s Schema) Schema // Example key: github.com/databricks/databricks-sdk-go/service/jobs.JobSettings
definitions map[string]Schema
// Transformation function to apply after generating a node in the schema.
fn func(s Schema) Schema
}
// The $defs block in a JSON schema cannot contain "/", otherwise it will not be
// correctly parsed by a JSON schema validator. So we replace "/" with an additional
// level of nesting in the output map.
//
// For example:
// {"a/b/c": "value"} is converted to {"a": {"b": {"c": "value"}}}
func (c *constructor) nestedDefinitions() any {
if len(c.definitions) == 0 {
return nil
}
res := make(map[string]any)
for k, v := range c.definitions {
parts := strings.Split(k, "/")
cur := res
for i, p := range parts {
if i == len(parts)-1 {
cur[p] = v
break
}
if _, ok := cur[p]; !ok {
cur[p] = make(map[string]any)
}
cur = cur[p].(map[string]any)
}
}
return res
} }
// TODO: Skip generating schema for interface fields. // TODO: Skip generating schema for interface fields.
func FromType(typ reflect.Type, opts FromTypeOptions) (Schema, error) { func FromType(typ reflect.Type, fn func(s Schema) Schema) (Schema, error) {
c := constructor{
definitions: make(map[string]Schema),
fn: fn,
}
err := c.walk(typ)
if err != nil {
return InvalidSchema, err
}
res := c.definitions[typePath(typ)]
// No need to include the root type in the definitions.
delete(c.definitions, typePath(typ))
res.Definitions = c.nestedDefinitions()
return res, nil
}
func typePath(typ reflect.Type) string {
// For built-in types, return the type name directly.
if typ.PkgPath() == "" {
return typ.Name()
}
return strings.Join([]string{typ.PkgPath(), typ.Name()}, ".")
}
// TODO: would a worked based model fit better here? Is this internal API not
// the right fit?
func (c *constructor) walk(typ reflect.Type) error {
// Dereference pointers if necessary. // Dereference pointers if necessary.
for typ.Kind() == reflect.Ptr { for typ.Kind() == reflect.Ptr {
typ = typ.Elem() typ = typ.Elem()
} }
// An interface value can never be serialized from text, and thus is explicitly typPath := typePath(typ)
// set to null and disallowed in the schema.
if typ.Kind() == reflect.Interface { // Return value directly if it's already been processed.
return Schema{Type: NullType}, nil if _, ok := c.definitions[typPath]; ok {
return nil
} }
var res Schema var s Schema
var err error var err error
// TODO: Narrow down the number of Go types handled here. // TODO: Narrow / widen down the number of Go types handled here.
switch typ.Kind() { switch typ.Kind() {
case reflect.Struct: case reflect.Struct:
res, err = fromTypeStruct(typ, opts) s, err = c.fromTypeStruct(typ)
case reflect.Slice: case reflect.Slice:
res, err = fromTypeSlice(typ, opts) s, err = c.fromTypeSlice(typ)
case reflect.Map: case reflect.Map:
res, err = fromTypeMap(typ, opts) s, err = c.fromTypeMap(typ)
// TODO: Should the primitive functions below be inlined? // TODO: Should the primitive functions below be inlined?
case reflect.String: case reflect.String:
res = Schema{Type: StringType} s = Schema{Type: StringType}
case reflect.Bool: case reflect.Bool:
res = Schema{Type: BooleanType} s = Schema{Type: BooleanType}
// TODO: Add comment about reduced coverage of primitive Go types in the code paths here. // TODO: Add comment about reduced coverage of primitive Go types in the code paths here.
case reflect.Int: case reflect.Int:
res = Schema{Type: IntegerType} s = Schema{Type: IntegerType}
case reflect.Float32, reflect.Float64: case reflect.Float32, reflect.Float64:
res = Schema{Type: NumberType} s = Schema{Type: NumberType}
case reflect.Interface:
// An interface value can never be serialized from text, and thus is explicitly
// set to null and disallowed in the schema.
s = Schema{Type: NullType}
default: default:
return InvalidSchema, fmt.Errorf("unsupported type: %s", typ.Kind()) return fmt.Errorf("unsupported type: %s", typ.Kind())
} }
if err != nil { if err != nil {
return InvalidSchema, err return err
} }
if opts.Transform != nil { if c.fn != nil {
res = opts.Transform(res) s = c.fn(s)
} }
return res, nil
// Store definition for the type if it's part of a Go package and not a built-in type.
// TODO: Apply transformation at the end, to all definitions instead of
// during recursive traversal?
c.definitions[typPath] = s
return nil
} }
// This function returns all member fields of the provided type. // This function returns all member fields of the provided type.
@ -112,7 +188,7 @@ func getStructFields(golangType reflect.Type) []reflect.StructField {
return fields return fields
} }
func fromTypeStruct(typ reflect.Type, opts FromTypeOptions) (Schema, error) { func (c *constructor) fromTypeStruct(typ reflect.Type) (Schema, error) {
if typ.Kind() != reflect.Struct { if typ.Kind() != reflect.Struct {
return InvalidSchema, fmt.Errorf("expected struct, got %s", typ.Kind()) return InvalidSchema, fmt.Errorf("expected struct, got %s", typ.Kind())
} }
@ -129,7 +205,6 @@ func fromTypeStruct(typ reflect.Type, opts FromTypeOptions) (Schema, error) {
} }
structFields := getStructFields(typ) structFields := getStructFields(typ)
for _, structField := range structFields { for _, structField := range structFields {
bundleTags := strings.Split(structField.Tag.Get("bundle"), ",") bundleTags := strings.Split(structField.Tag.Get("bundle"), ",")
// Fields marked as "readonly", "internal" or "deprecated" are skipped // Fields marked as "readonly", "internal" or "deprecated" are skipped
@ -143,7 +218,7 @@ func fromTypeStruct(typ reflect.Type, opts FromTypeOptions) (Schema, error) {
jsonTags := strings.Split(structField.Tag.Get("json"), ",") jsonTags := strings.Split(structField.Tag.Get("json"), ",")
// Do not include fields in the schema that will not be serialized during // Do not include fields in the schema that will not be serialized during
// JSON marshalling. // JSON marshalling.
if jsonTags[0] == "" || jsonTags[0] == "-" { if jsonTags[0] == "" || jsonTags[0] == "-" || !structField.IsExported() {
continue continue
} }
// "omitempty" tags in the Go SDK structs represent fields that not are // "omitempty" tags in the Go SDK structs represent fields that not are
@ -153,11 +228,19 @@ func fromTypeStruct(typ reflect.Type, opts FromTypeOptions) (Schema, error) {
res.Required = append(res.Required, jsonTags[0]) res.Required = append(res.Required, jsonTags[0])
} }
s, err := FromType(structField.Type, opts) // Trigger call to fromType, to recursively generate definitions for
// the struct field.
err := c.walk(structField.Type)
if err != nil { if err != nil {
return InvalidSchema, err return InvalidSchema, err
} }
res.Properties[jsonTags[0]] = &s
typPath := typePath(structField.Type)
refPath := path.Join("#/$defs", typPath)
// For non-built-in types, refer to the definition.
res.Properties[jsonTags[0]] = &Schema{
Reference: &refPath,
}
} }
return res, nil return res, nil
@ -165,7 +248,7 @@ func fromTypeStruct(typ reflect.Type, opts FromTypeOptions) (Schema, error) {
// TODO: Add comments explaining the translation between struct, map, slice and // TODO: Add comments explaining the translation between struct, map, slice and
// the JSON schema representation. // the JSON schema representation.
func fromTypeSlice(typ reflect.Type, opts FromTypeOptions) (Schema, error) { func (c *constructor) fromTypeSlice(typ reflect.Type) (Schema, error) {
if typ.Kind() != reflect.Slice { if typ.Kind() != reflect.Slice {
return InvalidSchema, fmt.Errorf("expected slice, got %s", typ.Kind()) return InvalidSchema, fmt.Errorf("expected slice, got %s", typ.Kind())
} }
@ -174,16 +257,24 @@ func fromTypeSlice(typ reflect.Type, opts FromTypeOptions) (Schema, error) {
Type: ArrayType, Type: ArrayType,
} }
items, err := FromType(typ.Elem(), opts) // Trigger call to fromType, to recursively generate definitions for
// the slice element.
err := c.walk(typ.Elem())
if err != nil { if err != nil {
return InvalidSchema, err return InvalidSchema, err
} }
res.Items = &items typPath := typePath(typ.Elem())
refPath := path.Join("#/$defs", typPath)
// For non-built-in types, refer to the definition
res.Items = &Schema{
Reference: &refPath,
}
return res, nil return res, nil
} }
func fromTypeMap(typ reflect.Type, opts FromTypeOptions) (Schema, error) { func (c *constructor) fromTypeMap(typ reflect.Type) (Schema, error) {
if typ.Kind() != reflect.Map { if typ.Kind() != reflect.Map {
return InvalidSchema, fmt.Errorf("expected map, got %s", typ.Kind()) return InvalidSchema, fmt.Errorf("expected map, got %s", typ.Kind())
} }
@ -196,10 +287,19 @@ func fromTypeMap(typ reflect.Type, opts FromTypeOptions) (Schema, error) {
Type: ObjectType, Type: ObjectType,
} }
additionalProperties, err := FromType(typ.Elem(), opts) // Trigger call to fromType, to recursively generate definitions for
// the map value.
err := c.walk(typ.Elem())
if err != nil { if err != nil {
return InvalidSchema, err return InvalidSchema, err
} }
res.AdditionalProperties = additionalProperties
typPath := typePath(typ.Elem())
refPath := path.Join("#/$defs", typPath)
// For non-built-in types, refer to the definition
res.AdditionalProperties = &Schema{
Reference: &refPath,
}
return res, nil return res, nil
} }

View File

@ -0,0 +1,119 @@
package jsonschema
import (
"reflect"
"testing"
"github.com/stretchr/testify/assert"
)
func TestFromTypeBasic(t *testing.T) {
type myStruct struct {
V string `json:"v"`
}
strRef := "#/$defs/string"
boolRef := "#/$defs/bool"
intRef := "#/$defs/int"
tcases := []struct {
name string
typ reflect.Type
expected Schema
}{
{
name: "int",
typ: reflect.TypeOf(int(0)),
expected: Schema{
Type: "integer",
},
},
{
name: "string",
typ: reflect.TypeOf(""),
expected: Schema{
Type: "string",
},
},
{
name: "bool",
typ: reflect.TypeOf(true),
expected: Schema{
Type: "boolean",
},
},
{
name: "float64",
typ: reflect.TypeOf(float64(0)),
expected: Schema{
Type: "number",
},
},
{
name: "struct",
typ: reflect.TypeOf(myStruct{}),
expected: Schema{
Type: "object",
Definitions: map[string]any{
"string": Schema{
Type: "string",
},
},
Properties: map[string]*Schema{
"v": {
Reference: &strRef,
},
},
AdditionalProperties: false,
Required: []string{"v"},
},
},
{
name: "slice",
typ: reflect.TypeOf([]bool{}),
expected: Schema{
Type: "array",
Definitions: map[string]any{
"bool": Schema{
Type: "boolean",
},
},
Items: &Schema{
Reference: &boolRef,
},
},
},
{
name: "map",
typ: reflect.TypeOf(map[string]int{}),
expected: Schema{
Type: "object",
Definitions: map[string]any{
"int": Schema{
Type: "integer",
},
},
AdditionalProperties: &Schema{
Reference: &intRef,
},
},
},
}
for _, tc := range tcases {
t.Run(tc.name, func(t *testing.T) {
s, err := FromType(tc.typ, nil)
assert.NoError(t, err)
assert.Equal(t, tc.expected, s)
// jsonSchema, err := json.MarshalIndent(s, " ", " ")
// assert.NoError(t, err)
// expectedJson, err := json.MarshalIndent(tc.expected, " ", " ")
// assert.NoError(t, err)
// t.Log("[DEBUG] actual: ", string(jsonSchema))
// t.Log("[DEBUG] expected: ", string(expectedJson))
})
}
}

View File

@ -21,6 +21,9 @@ import (
// //
// as an embedded file. // as an embedded file.
type Schema struct { type Schema struct {
// TODO: Comments for this field
Definitions any `json:"$defs,omitempty"`
// Type of the object // Type of the object
Type Type `json:"type,omitempty"` Type Type `json:"type,omitempty"`
@ -55,6 +58,7 @@ type Schema struct {
Required []string `json:"required,omitempty"` Required []string `json:"required,omitempty"`
// URI to a json schema // URI to a json schema
// TODO: Would be nice to make items as well as this a non-pointer.
Reference *string `json:"$ref,omitempty"` Reference *string `json:"$ref,omitempty"`
// Default value for the property / object // Default value for the property / object