encoding/protobuf/jsonpb: add encoder
Issue #606
Change-Id: I3bf6cbc1ecd5e83c94b5a7d97c845a35f4b84878
Reviewed-on: https://cue-review.googlesource.com/c/cue/+/9371
Reviewed-by: CUE cueckoo <cueckoo@gmail.com>
Reviewed-by: Marcel van Lohuizen <mpvl@golang.org>
diff --git a/encoding/protobuf/jsonpb/decoder_test.go b/encoding/protobuf/jsonpb/decoder_test.go
index 779c5ef..45326e0 100644
--- a/encoding/protobuf/jsonpb/decoder_test.go
+++ b/encoding/protobuf/jsonpb/decoder_test.go
@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
-package jsonpb
+package jsonpb_test
import (
"strings"
@@ -25,6 +25,7 @@
"cuelang.org/go/cue/format"
"cuelang.org/go/cue/parser"
"cuelang.org/go/encoding/json"
+ "cuelang.org/go/encoding/protobuf/jsonpb"
"cuelang.org/go/encoding/yaml"
"cuelang.org/go/internal/cuetest"
"cuelang.org/go/internal/cuetxtar"
@@ -85,7 +86,7 @@
}
w := t.Writer(f.Name)
- err := NewDecoder(schema).RewriteFile(file)
+ err := jsonpb.NewDecoder(schema).RewriteFile(file)
if err != nil {
errors.Print(w, err, nil)
continue
@@ -121,7 +122,7 @@
t.Fatal(err)
}
- if err := NewDecoder(inst.Value()).RewriteFile(file); err != nil {
+ if err := jsonpb.NewDecoder(inst.Value()).RewriteFile(file); err != nil {
t.Fatal(err)
}
diff --git a/encoding/protobuf/jsonpb/encoder.go b/encoding/protobuf/jsonpb/encoder.go
new file mode 100644
index 0000000..462e9bb
--- /dev/null
+++ b/encoding/protobuf/jsonpb/encoder.go
@@ -0,0 +1,168 @@
+// Copyright 2021 CUE Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package jsonpb
+
+import (
+ "strconv"
+
+ "cuelang.org/go/cue"
+ "cuelang.org/go/cue/ast"
+ "cuelang.org/go/cue/errors"
+ "cuelang.org/go/cue/literal"
+ "cuelang.org/go/cue/token"
+ "cuelang.org/go/encoding/protobuf/pbinternal"
+)
+
+// TODO: Options:
+// - Convert integer strings.
+// - URL encoder
+// - URL decoder
+
+// An Encoder rewrites CUE values according to the Protobuf to JSON mappings,
+// based on a given CUE schema.
+//
+// It bases the mapping on the underlying CUE type, without consulting Protobuf
+// attributes.
+//
+// Mappings per CUE type:
+// for any CUE type:
+// int: if the expression value is an integer and the schema value is
+// an int64, it is converted to a string.
+// {}: JSON objects representing any values will be left as is.
+// If the CUE type corresponding to the URL can be determined within
+// the module context it will be unified.
+// _: Adds a `@type` URL (TODO).
+//
+type Encoder struct {
+ schema cue.Value
+}
+
+// NewEncoder creates an Encoder for the given schema.
+func NewEncoder(schema cue.Value, options ...Option) *Encoder {
+ return &Encoder{schema: schema}
+}
+
+// RewriteFile modifies file, modifying it to conform to the Protocol buffer
+// to JSON mapping it in terms of the given schema.
+//
+// RewriteFile is idempotent, calling it multiples times on an expression gives
+// the same result.
+func (e *Encoder) RewriteFile(file *ast.File) error {
+ var enc encoder
+ enc.rewriteDecls(e.schema, file.Decls)
+ return enc.errs
+}
+
+// RewriteExpr modifies file, modifying it to conform to the Protocol buffer
+// to JSON mapping it in terms of the given schema.
+//
+// RewriteExpr is idempotent, calling it multiples times on an expression gives
+// the same result.
+func (e *Encoder) RewriteExpr(expr ast.Expr) (ast.Expr, error) {
+ var enc encoder
+ x := enc.rewrite(e.schema, expr)
+ return x, enc.errs
+}
+
+type encoder struct {
+ errs errors.Error
+}
+
+func (e *encoder) addErr(err errors.Error) {
+ e.errs = errors.Append(e.errs, err)
+}
+
+func (e *encoder) addErrf(p token.Pos, schema cue.Value, format string, args ...interface{}) {
+ format = "%s: " + format
+ args = append([]interface{}{schema.Path()}, args...)
+ e.addErr(errors.Newf(p, format, args...))
+}
+
+func (e *encoder) rewriteDecls(schema cue.Value, decls []ast.Decl) {
+ for _, f := range decls {
+ field, ok := f.(*ast.Field)
+ if !ok {
+ continue
+ }
+ sel := cue.Label(field.Label)
+ if !sel.IsString() {
+ continue
+ }
+
+ v := schema.LookupPath(cue.MakePath(sel.Optional()))
+ if !v.Exists() {
+ continue
+ }
+
+ field.Value = e.rewrite(v, field.Value)
+ }
+}
+
+func (e *encoder) rewrite(schema cue.Value, expr ast.Expr) (x ast.Expr) {
+ switch x := expr.(type) {
+ case *ast.ListLit:
+ for i, elem := range x.Elts {
+ v := schema.LookupPath(cue.MakePath(cue.Index(i).Optional()))
+ if !v.Exists() {
+ break
+ }
+ x.Elts[i] = e.rewrite(v, elem)
+ }
+ return expr
+
+ case *ast.StructLit:
+ e.rewriteDecls(schema, x.Elts)
+ return expr
+
+ case *ast.BasicLit:
+ if x.Kind != token.INT {
+ break
+ }
+
+ info, err := pbinternal.FromValue("", schema)
+ if err != nil {
+ break
+ }
+
+ switch info.Type {
+ case "int64", "fixed64", "sfixed64", "uint64":
+ b, ok := expr.(*ast.BasicLit)
+ if schema.IncompleteKind() == cue.IntKind && ok && b.Kind == token.INT {
+ b.Kind = token.STRING
+ b.Value = literal.String.Quote(b.Value)
+ }
+
+ case "int32", "fixed32", "sfixed32", "uint32", "float", "double":
+ case "varint":
+
+ default:
+ if !info.IsEnum {
+ break
+ }
+
+ i, err := strconv.ParseInt(x.Value, 10, 32)
+ if err != nil {
+ break
+ }
+
+ if s := pbinternal.MatchByInt(schema, i); s != "" {
+ x.Kind = token.STRING
+ x.Value = literal.String.Quote(s)
+ }
+ }
+ }
+
+ return expr
+}
diff --git a/encoding/protobuf/jsonpb/encoder_test.go b/encoding/protobuf/jsonpb/encoder_test.go
new file mode 100644
index 0000000..440ce50
--- /dev/null
+++ b/encoding/protobuf/jsonpb/encoder_test.go
@@ -0,0 +1,84 @@
+// Copyright 2021 CUE Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package jsonpb_test
+
+import (
+ "testing"
+
+ "cuelang.org/go/cue"
+ "cuelang.org/go/cue/ast"
+ "cuelang.org/go/cue/errors"
+ "cuelang.org/go/cue/format"
+ "cuelang.org/go/cue/parser"
+ "cuelang.org/go/encoding/protobuf/jsonpb"
+ "cuelang.org/go/internal/cuetest"
+ "cuelang.org/go/internal/cuetxtar"
+)
+
+func TestEncoder(t *testing.T) {
+ test := cuetxtar.TxTarTest{
+ Root: "./testdata/encoder",
+ Name: "jsonpb",
+ Update: cuetest.UpdateGoldenFiles,
+ }
+
+ r := cue.Runtime{}
+
+ test.Run(t, func(t *cuetxtar.Test) {
+ // TODO: use high-level API.
+
+ var schema cue.Value
+ var file *ast.File
+
+ for _, f := range t.Archive.Files {
+ switch {
+ case f.Name == "schema.cue":
+ inst, err := r.Compile(f.Name, f.Data)
+ if err != nil {
+ t.WriteErrors(errors.Promote(err, "test"))
+ return
+ }
+ schema = inst.Value()
+
+ case f.Name == "value.cue":
+ f, err := parser.ParseFile(f.Name, f.Data, parser.ParseComments)
+ if err != nil {
+ t.WriteErrors(errors.Promote(err, "test"))
+ return
+ }
+ file = f
+ }
+ }
+
+ if !schema.Exists() {
+ inst, err := r.CompileFile(file)
+ if err != nil {
+ t.WriteErrors(errors.Promote(err, "test"))
+ }
+ schema = inst.Value()
+ }
+
+ err := jsonpb.NewEncoder(schema).RewriteFile(file)
+ if err != nil {
+ errors.Print(t, err, nil)
+ }
+
+ b, err := format.Node(file)
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, _ = t.Write(b)
+ })
+}
diff --git a/encoding/protobuf/jsonpb/jsonpb.go b/encoding/protobuf/jsonpb/jsonpb.go
index df07a38..74f8424 100644
--- a/encoding/protobuf/jsonpb/jsonpb.go
+++ b/encoding/protobuf/jsonpb/jsonpb.go
@@ -14,4 +14,6 @@
// Package jsonpb rewrites a CUE expression based upon the Protobuf
// interpretation of JSON.
+//
+// API Status: DRAFT: API may change without notice.
package jsonpb
diff --git a/encoding/protobuf/jsonpb/testdata/encoder/enums.txtar b/encoding/protobuf/jsonpb/testdata/encoder/enums.txtar
new file mode 100644
index 0000000..427a3dd
--- /dev/null
+++ b/encoding/protobuf/jsonpb/testdata/encoder/enums.txtar
@@ -0,0 +1,68 @@
+-- schema.cue --
+enum: [string]:
+ { "foo", #enumValue: 1 } |
+ { "bar", #enumValue: 2 } @protobuf(1,Enum)
+
+defEnum: [string]: #foo | #bar @protobuf(2,Enum)
+
+#foo: 1
+#bar: 2
+
+typeEnum: [string]: #Enum @protobuf(3,Enum)
+
+#Enum: #foo | #bar
+
+// TODO: consider supporting @symbol(foo) or @json(,symbol=foo)
+// symbolEnum: [string]:
+// { 1, @symbol(foo) } |
+// { 2, @symbol(bar) }
+
+
+singleEnum: #single @protobuf(3,Enum)
+
+#single: 1
+
+badEnum: { string, #enumValue: 1 } | { "two", #enumValue: 2 }
+
+
+-- value.cue --
+enum: asIs: "foo"
+enum: asIsUnknown: "foobar"
+
+// Convert integers to strings
+defEnum: foo: 1
+defEnum: bar: 2
+defEnum: baz: 3
+
+
+typeEnum: foo: 1
+typeEnum: bar: 2
+typeEnum: baz: 3
+
+
+// TODO: consider supporting @symbol(foo) or @json(,symbol=foo)
+// symbolEnum: foo: "foo"
+// symbolEnum: bar: "bar"
+// symbolEnum: baz: "baz"
+
+singleEnum: 1
+
+-- out/jsonpb --
+enum: asIs: "foo"
+enum: asIsUnknown: "foobar"
+
+// Convert integers to strings
+defEnum: foo: "foo"
+defEnum: bar: "bar"
+defEnum: baz: 3
+
+typeEnum: foo: "foo"
+typeEnum: bar: "bar"
+typeEnum: baz: 3
+
+// TODO: consider supporting @symbol(foo) or @json(,symbol=foo)
+// symbolEnum: foo: "foo"
+// symbolEnum: bar: "bar"
+// symbolEnum: baz: "baz"
+
+singleEnum: 1
diff --git a/encoding/protobuf/jsonpb/testdata/encoder/list.txtar b/encoding/protobuf/jsonpb/testdata/encoder/list.txtar
new file mode 100644
index 0000000..403fcd6
--- /dev/null
+++ b/encoding/protobuf/jsonpb/testdata/encoder/list.txtar
@@ -0,0 +1,27 @@
+-- schema.cue --
+a: [...#D]
+
+#D: {
+ a: int @protobuf(1,int64)
+}
+
+b: [1, ...] // Don't include schema fields if not in value
+
+c: [{a: 1}, ...]
+c: [...#D]
+
+-- value.cue --
+// Hello
+a: [
+ {a: 1},
+]
+
+c: [{a: 1}, {a: 2}]
+
+-- out/jsonpb --
+// Hello
+a: [
+ {a: "1"},
+]
+
+c: [{a: "1"}, {a: "2"}]
diff --git a/encoding/protobuf/jsonpb/testdata/encoder/simple.txtar b/encoding/protobuf/jsonpb/testdata/encoder/simple.txtar
new file mode 100644
index 0000000..6f608ae
--- /dev/null
+++ b/encoding/protobuf/jsonpb/testdata/encoder/simple.txtar
@@ -0,0 +1,27 @@
+-- value.cue --
+a: 1 @protobuf(1, int64)
+b: 2 @protobuf(1, int32)
+c: 3.4 @protobuf(1, int64)
+
+d: "foo\u1234"
+e: '\000'
+
+f: false
+// Doc comment
+t: true
+
+notConcrete: string
+
+-- out/jsonpb --
+a: "1" @protobuf(1, int64)
+b: 2 @protobuf(1, int32)
+c: 3.4 @protobuf(1, int64)
+
+d: "foo\u1234"
+e: '\000'
+
+f: false
+// Doc comment
+t: true
+
+notConcrete: string
diff --git a/encoding/protobuf/jsonpb/testdata/encoder/struct.txtar b/encoding/protobuf/jsonpb/testdata/encoder/struct.txtar
new file mode 100644
index 0000000..4aa6d4b
--- /dev/null
+++ b/encoding/protobuf/jsonpb/testdata/encoder/struct.txtar
@@ -0,0 +1,28 @@
+-- schema.cue --
+a: {
+ {b: int @protobuf(1,int64)}
+
+ c: int @protobuf(1,int64)
+
+ {d: int @protobuf(1,int32)}
+
+ e: int @protobuf(1,int32)
+
+}
+-- value.cue --
+// Hello
+a: {
+ b: 1
+ c: 2
+ d: 3
+ e: 4
+}
+
+-- out/jsonpb --
+// Hello
+a: {
+ b: "1"
+ c: "2"
+ d: 3
+ e: 4
+}