Skip to content

Commit

Permalink
schema: Strongly type defaults and config values (#120)
Browse files Browse the repository at this point in the history
* schema: Strongly type defaults and config values

Support config values of type other than string. Previously we just
assumed all values were strings, which is kind of crappy for things like
toggles where we were checking stuff like `config.get("X") == "true"`.

Instead, we now check against the schema and provide a value of the
correct type to Starlark. We still serialize everything as a string for
storage, but we deserialize it to the appropriate type.

* Updated documentation
  • Loading branch information
rohansingh authored Jan 14, 2022
1 parent a173940 commit b502480
Show file tree
Hide file tree
Showing 9 changed files with 140 additions and 111 deletions.
35 changes: 17 additions & 18 deletions docs/schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,34 +17,33 @@ def main():
```

This is a quick start, so let's start with the code and we'll break it down:

```starlark
load("render.star", "render")
load("schema.star", "schema")

DEFAULT = "false"

def main(config):
small = config.get("small", DEFAULT)
msg = render.Text("Hello, World!")
if small == "false":
if config.get("small"):
msg = render.Text("Hello, World!", font = "CG-pixel-3x5-mono")
else:
msg = render.Text("Hello, World!")

return render.Root(
child = msg,
)

def get_schema():
return schema.Schema(
version = "1",
fields = [
schema.Toggle(
id = "small",
name = "Display small text",
desc = "A toggle to display smaller text.",
icon = "compress",
default = DEFAULT,
),
],
version = "1",
fields = [
schema.Toggle(
id = "small",
name = "Display small text",
desc = "A toggle to display smaller text.",
icon = "compress",
default = False,
),
],
)
```

Expand All @@ -54,7 +53,7 @@ The `get_schema` method returns a `schema.Schema` object that contains _fields_.

Next up should be more familiar. We're now passing `config` into `main()`. This is the same for current pixlet scripts that take `config` today. In [Community Apps](https://github.com/tidbyt/community), we will populate the config hashmap with values configured from the mobile app. More specifically, `config` is a key/value pair where the key is the `id` of the schema field and the value is determined by the user in the mobile app.

That's about it! One final note - we use the `DEFAULT` constant in two places here. That allows the `Toggle` to have a default value when we call `get_schema` and for the app to be run without `get_schema` being called. This could happen in a few different ways, such as using `pixlet render` or when we render previews for the mobile app listing.
That's about it!

## Icons
Each schema field takes an `icon` value. We use the free icons from [Font Awesome](https://fontawesome.com/) with the names camel cased. For example [users-cog](https://fontawesome.com/v5.15/icons/users-cog?style=solid) should be `usersCog` in the `icon` value.
Expand All @@ -63,7 +62,7 @@ Each schema field takes an `icon` value. We use the free icons from [Font Awesom
These are the current fields we support through schema today. Note that any addition of a field will require changes in our mobile app before we can truly support them.

### Toggle
A toggle provides an on/off switch for your app. The values returned in `config` are either `"true"` or `"false"` strings. Note - you have to convert this to a boolean inside of your app. It's not ideal and we hope to fix it in a future version of schema.
A toggle provides an on/off switch for your app. The values returned in `config` are either `True` or `False`.

The following field will display as follows in the mobile app:
```starlark
Expand All @@ -72,7 +71,7 @@ schema.Toggle(
name = "Display weather",
desc = "A toggle to display weather or not.",
icon = "cloud",
default = "true",
default = True,
)
```
![toggle example](img/toggle.jpg)
Expand Down
10 changes: 4 additions & 6 deletions examples/schema_hello_world.star
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
load("render.star", "render")
load("schema.star", "schema")

DEFAULT = "false"

def main(config):
small = config.get("small", DEFAULT)
msg = render.Text("Hello, World!")
if small == "false":
if config.get("small"):
msg = render.Text("Hello, World!", font = "CG-pixel-3x5-mono")
else:
msg = render.Text("Hello, World!")

return render.Root(
child = msg,
Expand All @@ -22,7 +20,7 @@ def get_schema():
name = "Display small text",
desc = "A toggle to display smaller text.",
icon = "compress",
default = DEFAULT,
default = False,
),
],
)
76 changes: 50 additions & 26 deletions runtime/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ package runtime
import (
"context"
"crypto/md5"
"encoding/json"
"fmt"
"strconv"

"github.com/pkg/errors"
starlibbase64 "github.com/qri-io/starlib/encoding/base64"
Expand Down Expand Up @@ -38,15 +40,16 @@ func init() {
}

type Applet struct {
Filename string
Id string
Globals starlark.StringDict
src []byte
loader ModuleLoader
predeclared starlark.StringDict
main *starlark.Function
schema string
schemaHandler map[string]schema.SchemaHandler
Filename string
Id string
Globals starlark.StringDict
src []byte
loader ModuleLoader
predeclared starlark.StringDict
main *starlark.Function

schema *schema.Schema
schemaJSON []byte
}

func (a *Applet) thread(initializers ...ThreadInitializer) *starlark.Thread {
Expand Down Expand Up @@ -102,23 +105,23 @@ func (a *Applet) Load(filename string, src []byte, loader ModuleLoader) (err err
}
a.main = main

var s string
var handlers map[string]schema.SchemaHandler
schemaFun, _ := a.Globals[schema.SchemaFunctionName].(*starlark.Function)
if schemaFun != nil {
schemaVal, err := a.Call(schemaFun, nil)
if err != nil {
return errors.Wrap(err, "calling schema function")
}

s, handlers, err = schema.EncodeSchema(schemaVal, a.Globals)
a.schema, err = schema.FromStarlark(schemaVal, a.Globals)
if err != nil {
return errors.Wrap(err, "encode schema")
return errors.Wrap(err, "parsing Starlark schema")
}
}

a.schema = s
a.schemaHandler = handlers
a.schemaJSON, err = json.Marshal(a.schema)
if err != nil {
return errors.Wrap(err, "serializing schema to JSON")
}
}

return nil
}
Expand All @@ -130,10 +133,25 @@ func (a *Applet) Run(config map[string]string, initializers ...ThreadInitializer
if a.main.NumParams() > 0 {
starlarkConfig := starlark.NewDict(len(config))
for k, v := range config {
starlarkConfig.SetKey(
starlark.String(k),
starlark.String(v),
)
var starlarkVal starlark.Value
starlarkVal = starlark.String(v)

if a.schema != nil {
// app has a schema, so we can provide strongly typed config values
field := a.schema.Field(k)

if field == nil {
// we have a value, but it's not part of the app's schema.
// drop it entirely.
starlarkVal = starlark.String(v)
continue
} else if field.Type == "onoff" {
b, _ := strconv.ParseBool(v)
starlarkVal = starlark.Bool(b)
}
}

starlarkConfig.SetKey(starlark.String(k), starlarkVal)
}
args = starlark.Tuple{starlarkConfig}
}
Expand Down Expand Up @@ -173,7 +191,7 @@ func (a *Applet) Run(config map[string]string, initializers ...ThreadInitializer
// CallSchemaHandler calls the schema handler for a field, passing it a single
// string parameter and returning a single string value.
func (app *Applet) CallSchemaHandler(ctx context.Context, fieldId, parameter string) (result string, err error) {
handler, found := app.schemaHandler[fieldId]
handler, found := app.schema.Handlers[fieldId]
if !found {
return "", fmt.Errorf("no handler exported for field id %s", fieldId)
}
Expand All @@ -196,11 +214,17 @@ func (app *Applet) CallSchemaHandler(ctx context.Context, fieldId, parameter str
return options, nil

case schema.ReturnSchema:
schema, _, err := schema.EncodeSchema(resultVal, app.Globals)
sch, err := schema.FromStarlark(resultVal, app.Globals)
if err != nil {
return "", err
return "", errors.Wrap(err, "parsing Starlark schema")
}

s, err := json.Marshal(sch)
if err != nil {
return "", errors.Wrap(err, "serializing schema to JSON")
}
return schema, nil

return string(s), nil

case schema.ReturnString:
str, ok := starlark.AsString(resultVal)
Expand All @@ -216,9 +240,9 @@ func (app *Applet) CallSchemaHandler(ctx context.Context, fieldId, parameter str
return "", fmt.Errorf("a very unexpected error happened for field \"%s\"", fieldId)
}

// GetSchema returns the config for the applet.
// GetSchema returns the serialized schema for the applet.
func (app *Applet) GetSchema() string {
return app.schema
return string(app.schemaJSON)
}

func attachContext(ctx context.Context) ThreadInitializer {
Expand Down
4 changes: 2 additions & 2 deletions schema/module.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ func newSchema(thread *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple

s := &StarlarkSchema{}
s.Version = version.GoString()
s.Schema.Schema = []SchemaField{}
s.Schema.Fields = []SchemaField{}

var fieldVal starlark.Value
fieldIter := fields.Iterate()
Expand All @@ -87,7 +87,7 @@ func newSchema(thread *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple
)
}

s.Schema.Schema = append(s.Schema.Schema, f.AsSchemaField())
s.Schema.Fields = append(s.Schema.Fields, f.AsSchemaField())
}
s.starlarkFields = fields

Expand Down
2 changes: 1 addition & 1 deletion schema/module_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ s = schema.Schema(
name = "Display Weather",
desc = "A toggle to determine if the weather should be displayed.",
icon = "cloud",
default = "false",
default = False,
),
],
)
Expand Down
56 changes: 31 additions & 25 deletions schema/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,11 @@ const (
// Schema holds a configuration object for an applet. It holds a list of fileds
// that are exported from an applet.
type Schema struct {
Version string `json:"version" validate:"required"`
Schema []SchemaField `json:"schema" validate:"required,dive"`
Version string `json:"version" validate:"required"`
Fields []SchemaField `json:"schema" validate:"required,dive"`
Handlers map[string]SchemaHandler `json:"-"`

fieldByID map[string]*SchemaField
}

// SchemaField represents an item in the config used to confgure an applet.
Expand Down Expand Up @@ -78,46 +81,54 @@ type SchemaHandler struct {
ReturnType HandlerReturnType
}

// Encodes a starlark config schema into validated json and extracts
// all schema handlers.
func EncodeSchema(
// Field returns a pointer to the schema field with the given ID, or nil if it
// doesn't exist.
func (s *Schema) Field(id string) *SchemaField {
return s.fieldByID[id]
}

// FromStarlark creates a new Schema from a Starlark schema object.
func FromStarlark(
starlarkSchema starlark.Value,
globals starlark.StringDict) (string, map[string]SchemaHandler, error) {
globals starlark.StringDict) (*Schema, error) {

schemaTree, err := unmarshalStarlark(starlarkSchema)
if err != nil {
return "", nil, err
return nil, err
}

treeJSON, err := json.Marshal(schemaTree)
if err != nil {
return "", nil, err
return nil, err
}

schema := &Schema{Version: "1"}
if err := json.Unmarshal(treeJSON, &schema.Schema); err != nil {
return "", nil, err
schema := &Schema{Version: "1",
Handlers: make(map[string]SchemaHandler),
fieldByID: make(map[string]*SchemaField),
}
if err := json.Unmarshal(treeJSON, &schema.Fields); err != nil {
return nil, err
}

err = validateSchema(schema)
if err != nil {
return "", nil, err
return nil, err
}

handlers := map[string]SchemaHandler{}
for i, schemaField := range schema.Schema {
for i, schemaField := range schema.Fields {
schema.fieldByID[schemaField.ID] = &schemaField
if schemaField.Handler != "" {
handlerValue, found := globals[schemaField.Handler]
if !found {
return "", nil, fmt.Errorf(
return nil, fmt.Errorf(
"field %d references non-existent handler \"%s\"",
i,
schemaField.Handler)
}

handlerFun, ok := handlerValue.(*starlark.Function)
if !ok {
return "", nil, fmt.Errorf(
return nil, fmt.Errorf(
"field %d references \"%s\" which is not a function",
i, schemaField.Handler)
}
Expand All @@ -135,21 +146,16 @@ func EncodeSchema(
case "oauth1":
handlerType = ReturnString
default:
return "", nil, fmt.Errorf(
return nil, fmt.Errorf(
"field %d of type \"%s\" can't have a handler function",
i, schemaField.Type)
}

handlers[schemaField.ID] = SchemaHandler{Function: handlerFun, ReturnType: handlerType}
schema.Handlers[schemaField.ID] = SchemaHandler{Function: handlerFun, ReturnType: handlerType}
}
}

schemaJSON, err := json.Marshal(schema)
if err != nil {
return "", nil, err
}

return string(schemaJSON), handlers, nil
return schema, nil
}

// Encodes a list of schema options into validated json.
Expand Down Expand Up @@ -188,7 +194,7 @@ func unmarshalStarlark(object starlark.Value) (interface{}, error) {
switch v := object.(type) {

case *StarlarkSchema:
return v.Schema.Schema, nil
return v.Schema.Fields, nil

case starlark.String:
return v.GoString(), nil
Expand Down
Loading

0 comments on commit b502480

Please sign in to comment.