Skip to content

Commit

Permalink
feat: handle enums in jsonschema (#1107)
Browse files Browse the repository at this point in the history
fixes #1099
  • Loading branch information
worstell authored Mar 18, 2024
1 parent 1a461c6 commit ac7953b
Show file tree
Hide file tree
Showing 6 changed files with 121 additions and 54 deletions.
2 changes: 2 additions & 0 deletions backend/schema/intvalue.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,6 @@ func (i *IntValue) String() string {
return fmt.Sprintf("%d", i.Value)
}

func (i *IntValue) GetValue() any { return i.Value }

func (*IntValue) schemaValueType() Type { return &Int{} }
79 changes: 39 additions & 40 deletions backend/schema/jsonschema.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,42 +19,40 @@ func DataToJSONSchema(sch *Schema, ref Ref) (*jsonschema.Schema, error) {
return nil, fmt.Errorf("unknown data type %s", ref)
}

// Collect all data types.
dataTypes := sch.DataMap()

// Encode root, and collect all data types reachable from the root.
refs := map[RefKey]*Ref{}
root := nodeToJSSchema(sch, data, refs)
root := nodeToJSSchema(data, refs)
if len(refs) == 0 {
return root, nil
}

// Resolve and encode all data types reachable from the root.
// Resolve and encode all types reachable from the root.
root.Definitions = map[string]jsonschema.SchemaOrBool{}
for key, r := range refs {
data, ok := dataTypes[RefKey{Module: key.Module, Name: key.Name}]
if !ok {
return nil, fmt.Errorf("unknown data type %s", key)
}

if len(r.TypeParameters) > 0 {
monomorphisedData, err := data.Monomorphise(r)
if err != nil {
return nil, err
for _, r := range refs {
decl := sch.ResolveRef(r)
switch n := decl.(type) {
case *Data:
if len(r.TypeParameters) > 0 {
monomorphisedData, err := n.Monomorphise(r)
if err != nil {
return nil, err
}

ref := fmt.Sprintf("%s.%s", r.Module, refName(r))
root.Definitions[ref] = jsonschema.SchemaOrBool{TypeObject: nodeToJSSchema(monomorphisedData, refs)}
} else {
root.Definitions[r.String()] = jsonschema.SchemaOrBool{TypeObject: nodeToJSSchema(n, refs)}
}
data = monomorphisedData

ref := fmt.Sprintf("%s.%s", r.Module, refName(r))
root.Definitions[ref] = jsonschema.SchemaOrBool{TypeObject: nodeToJSSchema(sch, data, refs)}
} else {
root.Definitions[r.String()] = jsonschema.SchemaOrBool{TypeObject: nodeToJSSchema(sch, data, refs)}
case *Enum:
root.Definitions[r.String()] = jsonschema.SchemaOrBool{TypeObject: nodeToJSSchema(n, refs)}
case *Config, *Database, *Secret, *Verb:
return nil, fmt.Errorf("reference to unsupported node type %T", decl)
}

}
return root, nil
}

func nodeToJSSchema(sch *Schema, node Node, dataRefs map[RefKey]*Ref) *jsonschema.Schema {
func nodeToJSSchema(node Node, refs map[RefKey]*Ref) *jsonschema.Schema {
switch node := node.(type) {
case *Any:
return &jsonschema.Schema{}
Expand All @@ -72,7 +70,7 @@ func nodeToJSSchema(sch *Schema, node Node, dataRefs map[RefKey]*Ref) *jsonschem
AdditionalProperties: jsBool(false),
}
for _, field := range node.Fields {
jsField := nodeToJSSchema(sch, field.Type, dataRefs)
jsField := nodeToJSSchema(field.Type, refs)
jsField.Description = jsComments(field.Comments)
if _, ok := field.Type.(*Optional); !ok {
schema.Required = append(schema.Required, field.Name)
Expand All @@ -81,6 +79,16 @@ func nodeToJSSchema(sch *Schema, node Node, dataRefs map[RefKey]*Ref) *jsonschem
}
return schema

case *Enum:
schema := &jsonschema.Schema{
Description: jsComments(node.Comments),
}
values := make([]any, len(node.Variants))
for i, v := range node.Variants {
values[i] = v.Value.GetValue()
}
return schema.WithEnum(values...)

case *Int:
st := jsonschema.Integer
return &jsonschema.Schema{Type: &jsonschema.Type{SimpleTypes: &st}}
Expand Down Expand Up @@ -118,7 +126,7 @@ func nodeToJSSchema(sch *Schema, node Node, dataRefs map[RefKey]*Ref) *jsonschem
Type: &jsonschema.Type{SimpleTypes: &st},
Items: &jsonschema.Items{
SchemaOrBool: &jsonschema.SchemaOrBool{
TypeObject: nodeToJSSchema(sch, node.Element, dataRefs),
TypeObject: nodeToJSSchema(node.Element, refs),
},
},
}
Expand All @@ -128,8 +136,8 @@ func nodeToJSSchema(sch *Schema, node Node, dataRefs map[RefKey]*Ref) *jsonschem
// JSON schema generic map of key type to value type
return &jsonschema.Schema{
Type: &jsonschema.Type{SimpleTypes: &st},
PropertyNames: &jsonschema.SchemaOrBool{TypeObject: nodeToJSSchema(sch, node.Key, dataRefs)},
AdditionalProperties: &jsonschema.SchemaOrBool{TypeObject: nodeToJSSchema(sch, node.Value, dataRefs)},
PropertyNames: &jsonschema.SchemaOrBool{TypeObject: nodeToJSSchema(node.Key, refs)},
AdditionalProperties: &jsonschema.SchemaOrBool{TypeObject: nodeToJSSchema(node.Value, refs)},
}

case *Ref:
Expand All @@ -140,22 +148,13 @@ func nodeToJSSchema(sch *Schema, node Node, dataRefs map[RefKey]*Ref) *jsonschem
} else {
ref = fmt.Sprintf("#/definitions/%s", node.String())
}

decl := sch.ResolveRef(node)
if decl != nil {
if _, ok := decl.(*Data); ok {
dataRefs[node.ToRefKey()] = node
}
}

schema := &jsonschema.Schema{Ref: &ref}

return schema
refs[node.ToRefKey()] = node
return &jsonschema.Schema{Ref: &ref}

case *Optional:
null := jsonschema.Null
return &jsonschema.Schema{AnyOf: []jsonschema.SchemaOrBool{
{TypeObject: nodeToJSSchema(sch, node.Type, dataRefs)},
{TypeObject: nodeToJSSchema(node.Type, refs)},
{TypeObject: &jsonschema.Schema{Type: &jsonschema.Type{SimpleTypes: &null}}},
}}

Expand All @@ -164,7 +163,7 @@ func nodeToJSSchema(sch *Schema, node Node, dataRefs map[RefKey]*Ref) *jsonschem

case Decl, *Field, Metadata, *MetadataCalls, *MetadataDatabases, *MetadataIngress,
*MetadataAlias, IngressPathComponent, *IngressPathLiteral, *IngressPathParameter, *Module,
*Schema, Type, *Database, *Verb, *Enum, *EnumVariant,
*Schema, Type, *Database, *Verb, *EnumVariant,
Value, *StringValue, *IntValue, *Config, *Secret, Symbol:
panic(fmt.Sprintf("unsupported node type %T", node))

Expand Down
79 changes: 77 additions & 2 deletions backend/schema/jsonschema_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ var jsonSchemaSample = &Schema{
{Name: "ref", Type: &Ref{Module: "bar", Name: "Bar"}},
{Name: "any", Type: &Any{}},
{Name: "keyValue", Type: &Ref{Module: "foo", Name: "Generic", TypeParameters: []Type{&String{}, &Int{}}}},
{Name: "stringEnumRef", Type: &Ref{Module: "foo", Name: "StringEnum"}},
{Name: "intEnumRef", Type: &Ref{Module: "foo", Name: "IntEnum"}},
},
},
&Data{
Expand All @@ -43,6 +45,20 @@ var jsonSchemaSample = &Schema{
{Name: "value", Type: &Ref{Name: "V"}},
},
},
&Enum{
Name: "StringEnum",
Variants: []*EnumVariant{
{Name: "A", Value: &StringValue{Value: "A"}},
{Name: "B", Value: &StringValue{Value: "B"}},
},
},
&Enum{
Name: "IntEnum",
Variants: []*EnumVariant{
{Name: "Zero", Value: &IntValue{Value: 0}},
{Name: "One", Value: &IntValue{Value: 1}},
},
},
}},
{Name: "bar", Decls: []Decl{
&Data{Name: "Bar", Fields: []*Field{{Name: "bar", Type: &String{}}}},
Expand Down Expand Up @@ -71,7 +87,9 @@ func TestDataToJSONSchema(t *testing.T) {
"optionalMap",
"ref",
"any",
"keyValue"
"keyValue",
"stringEnumRef",
"intEnumRef"
],
"additionalProperties": false,
"definitions": {
Expand Down Expand Up @@ -103,6 +121,12 @@ func TestDataToJSONSchema(t *testing.T) {
},
"type": "object"
},
"foo.IntEnum": {
"enum": [
0,
1
]
},
"foo.Item": {
"required": [
"name"
Expand All @@ -114,6 +138,12 @@ func TestDataToJSONSchema(t *testing.T) {
}
},
"type": "object"
},
"foo.StringEnum": {
"enum": [
"A",
"B"
]
}
},
"properties": {
Expand Down Expand Up @@ -148,6 +178,9 @@ func TestDataToJSONSchema(t *testing.T) {
"int": {
"type": "integer"
},
"intEnumRef": {
"$ref": "#/definitions/foo.IntEnum"
},
"keyValue": {
"$ref": "#/definitions/foo.Generic[String, Int]"
},
Expand Down Expand Up @@ -206,6 +239,9 @@ func TestDataToJSONSchema(t *testing.T) {
"description": "Field comment",
"type": "string"
},
"stringEnumRef": {
"$ref": "#/definitions/foo.StringEnum"
},
"time": {
"type": "string",
"format": "date-time"
Expand All @@ -232,7 +268,9 @@ func TestJSONSchemaValidation(t *testing.T) {
"optionalMap": {"one": 2, "two": null},
"ref": {"bar": "Name"},
"any": [{"name": "Name"}, "string", 1, 1.23, true, "2018-11-13T20:20:39+00:00", ["one"], {"one": 2}, null],
"keyValue": {"key": "string", "value": 1}
"keyValue": {"key": "string", "value": 1},
"stringEnumRef": "A",
"intEnumRef": 0
}
`

Expand All @@ -250,3 +288,40 @@ func TestJSONSchemaValidation(t *testing.T) {
err = jsonschema.Validate(v)
assert.NoError(t, err)
}

func TestInvalidEnumValidation(t *testing.T) {
input := `
{
"string": "string",
"int": 1,
"float": 1.23,
"bool": true,
"time": "2018-11-13T20:20:39+00:00",
"array": ["one"],
"arrayOfRefs": [{"name": "Name"}],
"arrayOfArray": [[]],
"optionalArray": [null, "foo"],
"map": {"one": 2},
"optionalMap": {"one": 2, "two": null},
"ref": {"bar": "Name"},
"any": [{"name": "Name"}, "string", 1, 1.23, true, "2018-11-13T20:20:39+00:00", ["one"], {"one": 2}, null],
"keyValue": {"key": "string", "value": 1},
"stringEnumRef": "B",
"intEnumRef": 3
}
`

schema, err := DataToJSONSchema(jsonSchemaSample, Ref{Module: "foo", Name: "Foo"})
assert.NoError(t, err)
schemaJSON, err := json.MarshalIndent(schema, "", " ")
assert.NoError(t, err)
jsonschema, err := jsonschema.CompileString("http://ftl.block.xyz/schema.json", string(schemaJSON))
assert.NoError(t, err)

var v interface{}
err = json.Unmarshal([]byte(input), &v)
assert.NoError(t, err)

err = jsonschema.Validate(v)
assert.Contains(t, err.Error(), "value must be one of \"0\", \"1\"")
}
1 change: 1 addition & 0 deletions backend/schema/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ type Metadata interface {
//sumtype:decl
type Value interface {
Node
GetValue() any
schemaValueType() Type
}

Expand Down
12 changes: 0 additions & 12 deletions backend/schema/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,18 +103,6 @@ func (s *Schema) Module(name string) optional.Option[*Module] {
return optional.None[*Module]()
}

func (s *Schema) DataMap() map[RefKey]*Data {
dataTypes := map[RefKey]*Data{}
for _, module := range s.Modules {
for _, decl := range module.Decls {
if data, ok := decl.(*Data); ok {
dataTypes[RefKey{Module: module.Name, Name: data.Name}] = data
}
}
}
return dataTypes
}

// Upsert inserts or replaces a module.
func (s *Schema) Upsert(module *Module) {
for i, m := range s.Modules {
Expand Down
2 changes: 2 additions & 0 deletions backend/schema/stringvalue.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,6 @@ func (s *StringValue) String() string {
return fmt.Sprintf("\"%s\"", s.Value)
}

func (s *StringValue) GetValue() any { return s.Value }

func (*StringValue) schemaValueType() Type { return &String{} }

0 comments on commit ac7953b

Please sign in to comment.