Skip to content

Commit

Permalink
feat: reflect a schema type from a Go type (#1470)
Browse files Browse the repository at this point in the history
This will be used initally by `FSM.SendEvent()`.

@worstell I'm wondering if we shouldn't extend the `typeregistry` with
this functionality, such that it can be used to resolve any Go type to
its schema type, statically. The reason I'm thinking that is that this
reflection code could get quite tedious to maintain.
  • Loading branch information
alecthomas authored May 13, 2024
1 parent 65e89d2 commit d9f26b9
Show file tree
Hide file tree
Showing 2 changed files with 165 additions and 1 deletion.
81 changes: 80 additions & 1 deletion go-runtime/ftl/reflection.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
package ftl

import (
"context"
"fmt"
"reflect"
"runtime"
"runtime/debug"
"strings"
"time"

"github.com/TBD54566975/ftl/backend/schema"
"github.com/TBD54566975/ftl/backend/schema/strcase"
"github.com/TBD54566975/ftl/go-runtime/ftl/typeregistry"
)

// Module returns the FTL module currently being executed.
Expand Down Expand Up @@ -46,10 +50,85 @@ func FuncRef(call any) Ref {
return goRefToFTLRef(ref)
}

var allowAnyPackageForTesting = false

func goRefToFTLRef(ref string) Ref {
if !strings.HasPrefix(ref, "ftl/") {
if !allowAnyPackageForTesting && !strings.HasPrefix(ref, "ftl/") {
panic(fmt.Sprintf("invalid reference %q, must start with ftl/ ", ref))
}
parts := strings.Split(ref[strings.LastIndex(ref, "/")+1:], ".")
return Ref{parts[len(parts)-2], strcase.ToLowerCamel(parts[len(parts)-1])}
}

// Reflect returns the FTL schema for a Go type.
func reflectSchemaType(ctx context.Context, t reflect.Type) schema.Type {
switch t.Kind() {
case reflect.Struct:
// Handle well-known types.
if reflect.TypeFor[time.Time]() == t {
return &schema.Time{}
}

// User-defined types.
return refForType(t)

case reflect.Slice:
return &schema.Array{Element: reflectSchemaType(ctx, t.Elem())}

case reflect.Map:
return &schema.Map{Key: reflectSchemaType(ctx, t.Key()), Value: reflectSchemaType(ctx, t.Elem())}

case reflect.Bool:
return &schema.Bool{}

case reflect.String:
if t.PkgPath() != "" { // Enum
return &schema.Ref{
Module: moduleForType(t),
Name: strcase.ToUpperCamel(t.Name()),
}
}
return &schema.String{}

case reflect.Int:
if t.PkgPath() != "" { // Enum
return &schema.Ref{
Module: moduleForType(t),
Name: strcase.ToUpperCamel(t.Name()),
}
}
return &schema.Int{}

case reflect.Float64:
return &schema.Float{}

case reflect.Interface:
if t.NumMethod() == 0 { // any
return &schema.Any{}
}
// Check if it's a sum-type discriminator.
registry, ok := typeregistry.FromContext(ctx).Get()
if !ok || !registry.IsSumTypeDiscriminator(t) {
panic(fmt.Sprintf("unsupported interface type %s", t))
}
return refForType(t)

default:
panic(fmt.Sprintf("unsupported FTL type %s", t))
}
}

// Return the FTL module for a type or panic if it's not an FTL type.
func moduleForType(t reflect.Type) string {
module := t.PkgPath()
if !allowAnyPackageForTesting && !strings.HasPrefix(module, "ftl/") {
panic(fmt.Sprintf("invalid reference %q, must start with ftl/ ", module))
}
parts := strings.Split(module, "/")
return parts[len(parts)-1]
}

func refForType(t reflect.Type) *schema.Ref {
module := moduleForType(t)
return &schema.Ref{Module: module, Name: strcase.ToUpperCamel(t.Name())}
}
85 changes: 85 additions & 0 deletions go-runtime/ftl/reflection_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package ftl

import (
"context"
"reflect"
"testing"

"github.com/alecthomas/assert/v2"

"github.com/TBD54566975/ftl/backend/schema"
"github.com/TBD54566975/ftl/go-runtime/ftl/typeregistry"
)

type MySumType interface{ sealed() }

type Variant1 struct{ Field1 string }

func (Variant1) sealed() {}

type Variant2 struct{ Field2 int }

func (Variant2) sealed() {}

type Enum int

const (
Enum1 Enum = iota
)

type AllTypesToReflect struct {
SumType MySumType
Enum Enum
Bool bool
Int int
Float float64
String string
Any any
Array []int
Map map[string]int
}

func TestReflectSchemaType(t *testing.T) {
allowAnyPackageForTesting = true
t.Cleanup(func() { allowAnyPackageForTesting = false })

tr := typeregistry.NewTypeRegistry()
tr.RegisterSumType(reflect.TypeFor[MySumType](), map[string]reflect.Type{
"Variant1": reflect.TypeFor[Variant1](),
"Variant2": reflect.TypeFor[Variant2](),
})
ctx := context.Background()
ctx = typeregistry.ContextWithTypeRegistry(ctx, tr)

v := AllTypesToReflect{SumType: &Variant1{}}

tests := []struct {
name string
value any
expected schema.Type
}{
{"Data", &v, &schema.Ref{Module: "ftl", Name: "AllTypesToReflect"}},
{"SumType", &v.SumType, &schema.Ref{Module: "ftl", Name: "MySumType"}},
{"Enum", &v.Enum, &schema.Ref{Module: "ftl", Name: "Enum"}},
{"Int", &v.Int, &schema.Int{}},
{"String", &v.String, &schema.String{}},
{"Float", &v.Float, &schema.Float{}},
{"Any", &v.Any, &schema.Any{}},
{"Array", &v.Array, &schema.Array{Element: &schema.Int{}}},
{"Map", &v.Map, &schema.Map{Key: &schema.String{}, Value: &schema.Int{}}},
{"Bool", &v.Bool, &schema.Bool{}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
st := reflectSchemaType(ctx, reflect.TypeOf(tt.value).Elem())
assert.Equal(t, tt.expected, st)
})
}

t.Run("InvalidType", func(t *testing.T) {
var invalid uint
assert.Panics(t, func() {
reflectSchemaType(ctx, reflect.TypeOf(&invalid).Elem())
})
})
}

0 comments on commit d9f26b9

Please sign in to comment.