Skip to content

Commit

Permalink
feat: generate a basic JSON-schema from FTL schema data types (#333)
Browse files Browse the repository at this point in the history
This is currently restricted to a single data type, as our initial use
case is for the request editor in the Console.

Future work could/should include:

- Using the JSON schema to validate ingress requests.
- Exporting the schema so users can codegen clients.

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
  • Loading branch information
alecthomas and github-actions[bot] authored Aug 29, 2023
1 parent f5505eb commit 4c3e40c
Show file tree
Hide file tree
Showing 10 changed files with 287 additions and 2 deletions.
1 change: 1 addition & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,4 @@ issues:
- "parameter testing.TB should have name tb"
- "blank-imports"
- 'should have comment \(or a comment on this block\) or be unexported'
- caseOrder
4 changes: 2 additions & 2 deletions backend/schema/encoding.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ var _ Type = (*DataRef)(nil)

func (*DataRef) schemaChildren() []Node { return nil }
func (*DataRef) schemaType() {}
func (s *DataRef) String() string { return s.Name }
func (s DataRef) String() string { return makeRef(s.Module, s.Name) }

var _ Decl = (*Data)(nil)

Expand Down Expand Up @@ -97,7 +97,7 @@ var _ Type = (*VerbRef)(nil)

func (*VerbRef) schemaChildren() []Node { return nil }
func (*VerbRef) schemaType() {}
func (v *VerbRef) String() string { return makeRef(v.Module, v.Name) }
func (v VerbRef) String() string { return makeRef(v.Module, v.Name) }

var _ Decl = (*Verb)(nil)

Expand Down
126 changes: 126 additions & 0 deletions backend/schema/jsonschema.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package schema

import (
"fmt"
"strings"

"github.com/alecthomas/errors"
js "github.com/swaggest/jsonschema-go"
)

// DataToJSONSchema converts the schema for a Data object to a JSON Schema.
//
// It takes in the full schema in order to resolve and define references.
func DataToJSONSchema(schema *Schema, dataRef DataRef) (*js.Schema, error) {
// Collect all data types.
dataTypes := map[DataRef]*Data{}
for _, module := range schema.Modules {
for _, decl := range module.Decls {
if data, ok := decl.(*Data); ok {
dataTypes[DataRef{Module: module.Name, Name: data.Name}] = data
}
}
}

// Find the root data type.
rootData, ok := dataTypes[dataRef]
if !ok {
return nil, errors.Errorf("unknown data type %s", dataRef)
}

// Encode root, and collect all data types reachable from the root.
dataRefs := map[DataRef]bool{}
root := nodeToJSSchema(rootData, dataRefs)
if len(dataRefs) == 0 {
return root, nil
}
// Resolve and encode all data types reachable from the root.
root.Definitions = map[string]js.SchemaOrBool{}
for dataRef := range dataRefs {
data, ok := dataTypes[dataRef]
if !ok {
return nil, errors.Errorf("unknown data type %s", dataRef)
}
root.Definitions[dataRef.String()] = js.SchemaOrBool{TypeObject: nodeToJSSchema(data, dataRefs)}
}
return root, nil
}

func nodeToJSSchema(node Node, dataRefs map[DataRef]bool) *js.Schema {
switch node := node.(type) {
case *Data:
st := js.Object
schema := &js.Schema{
Description: jsComments(node.Comments),
Type: &js.Type{SimpleTypes: &st},
Properties: map[string]js.SchemaOrBool{},
AdditionalProperties: jsBool(false),
}
for _, field := range node.Fields {
jsField := nodeToJSSchema(field.Type, dataRefs)
jsField.Description = jsComments(field.Comments)
schema.Properties[field.Name] = js.SchemaOrBool{TypeObject: jsField}
}
return schema

case *Int:
st := js.Integer
return &js.Schema{Type: &js.Type{SimpleTypes: &st}}

case *Float:
st := js.Number
return &js.Schema{Type: &js.Type{SimpleTypes: &st}}

case *String:
st := js.String
return &js.Schema{Type: &js.Type{SimpleTypes: &st}}

case *Bool:
st := js.Boolean
return &js.Schema{Type: &js.Type{SimpleTypes: &st}}

case *Time:
st := js.String
dt := "date-time"
return &js.Schema{Type: &js.Type{SimpleTypes: &st}, Format: &dt}

case *Array:
st := js.Array
return &js.Schema{
Type: &js.Type{SimpleTypes: &st},
Items: &js.Items{SchemaOrBool: &js.SchemaOrBool{TypeObject: nodeToJSSchema(node.Element, dataRefs)}},
}

case *Map:
st := js.Object
// JSON schema generic map of key type to value type
return &js.Schema{
Type: &js.Type{SimpleTypes: &st},
AdditionalProperties: &js.SchemaOrBool{TypeObject: nodeToJSSchema(node.Value, dataRefs)},
PropertyNames: &js.SchemaOrBool{TypeObject: nodeToJSSchema(node.Key, dataRefs)},
}

case *DataRef:
ref := fmt.Sprintf("#/definitions/%s", node.String())
dataRefs[*node] = true
return &js.Schema{Ref: &ref}

case Decl, *Field, Metadata, *MetadataCalls, *MetadataIngress, *Module, *Schema, Type, *Verb, *VerbRef:
panic(fmt.Sprintf("unsupported node type %T", node))

default:
panic(fmt.Sprintf("unsupported node type %T", node))
}
}

func jsBool(ok bool) *js.SchemaOrBool {
return &js.SchemaOrBool{TypeBoolean: &ok}
}

func jsComments(comments []string) *string {
if len(comments) == 0 {
return nil
}
out := strings.Join(comments, "\n")
return &out
}
98 changes: 98 additions & 0 deletions backend/schema/jsonschema_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package schema

import (
"encoding/json"
"testing"

"github.com/alecthomas/assert/v2"
)

func TestDataToJSONSchema(t *testing.T) {
schema, err := DataToJSONSchema(&Schema{
Modules: []*Module{
{Name: "foo", Decls: []Decl{&Data{
Name: "Foo",
Comments: []string{"Data comment"},
Fields: []*Field{
{Name: "string", Type: &String{}, Comments: []string{"Field comment"}},
{Name: "int", Type: &Int{}},
{Name: "float", Type: &Float{}},
{Name: "bool", Type: &Bool{}},
{Name: "time", Type: &Time{}},
{Name: "array", Type: &Array{Element: &String{}}},
{Name: "arrayOfArray", Type: &Array{Element: &Array{Element: &String{}}}},
{Name: "map", Type: &Map{Key: &String{}, Value: &Int{}}},
{Name: "ref", Type: &DataRef{Module: "bar", Name: "Bar"}},
}}}},
{Name: "bar", Decls: []Decl{
&Data{Name: "Bar", Fields: []*Field{{Name: "bar", Type: &String{}}}},
}},
},
}, DataRef{Module: "foo", Name: "Foo"})
assert.NoError(t, err)
actual, err := json.MarshalIndent(schema, "", " ")
assert.NoError(t, err)
expected := `{
"description": "Data comment",
"additionalProperties": false,
"definitions": {
"bar.Bar": {
"additionalProperties": false,
"properties": {
"bar": {
"type": "string"
}
},
"type": "object"
}
},
"properties": {
"array": {
"items": {
"type": "string"
},
"type": "array"
},
"arrayOfArray": {
"items": {
"items": {
"type": "string"
},
"type": "array"
},
"type": "array"
},
"bool": {
"type": "boolean"
},
"float": {
"type": "number"
},
"int": {
"type": "integer"
},
"map": {
"additionalProperties": {
"type": "integer"
},
"propertyNames": {
"type": "string"
},
"type": "object"
},
"ref": {
"$ref": "#/definitions/bar.Bar"
},
"string": {
"description": "Field comment",
"type": "string"
},
"time": {
"type": "string",
"format": "date-time"
}
},
"type": "object"
}`
assert.Equal(t, expected, string(actual))
}
2 changes: 2 additions & 0 deletions examples/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ require (
github.com/mattn/go-isatty v0.0.17 // indirect
github.com/oklog/ulid/v2 v2.1.0 // indirect
github.com/rs/cors v1.9.0 // indirect
github.com/swaggest/jsonschema-go v0.3.59 // indirect
github.com/swaggest/refl v1.2.0 // indirect
go.opentelemetry.io/otel v1.16.0 // indirect
go.opentelemetry.io/otel/metric v1.16.0 // indirect
go.opentelemetry.io/otel/trace v1.16.0 // indirect
Expand Down
18 changes: 18 additions & 0 deletions examples/go.sum

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions examples/online-boutique/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ require (
github.com/mattn/go-isatty v0.0.17 // indirect
github.com/oklog/ulid/v2 v2.1.0 // indirect
github.com/rs/cors v1.9.0 // indirect
github.com/swaggest/jsonschema-go v0.3.59 // indirect
github.com/swaggest/refl v1.2.0 // indirect
go.opentelemetry.io/otel v1.16.0 // indirect
go.opentelemetry.io/otel/metric v1.16.0 // indirect
go.opentelemetry.io/otel/trace v1.16.0 // indirect
Expand Down
18 changes: 18 additions & 0 deletions examples/online-boutique/go.sum

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ require (
github.com/otiai10/copy v1.12.0
github.com/radovskyb/watcher v1.0.7
github.com/rs/cors v1.9.0
github.com/swaggest/jsonschema-go v0.3.59
github.com/titanous/json5 v1.0.0
go.opentelemetry.io/otel v1.16.0
go.opentelemetry.io/otel/metric v1.16.0
Expand All @@ -37,6 +38,7 @@ require (
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/puddle/v2 v2.2.0 // indirect
github.com/rogpeppe/go-internal v1.10.0 // indirect
github.com/swaggest/refl v1.2.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/internal/retry v1.16.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric v0.39.0 // indirect
go.opentelemetry.io/otel/sdk v1.16.0 // indirect
Expand Down
Loading

0 comments on commit 4c3e40c

Please sign in to comment.