diff --git a/encoding/protobuf/jsonpb/decoder_test.go b/encoding/protobuf/jsonpb/decoder_test.go index 779c5efc3..45326e0bf 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 @@ import ( "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 @@ func TestParse(t *testing.T) { } 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 @@ func TestX(t *testing.T) { 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 000000000..462e9bbdc --- /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 000000000..440ce5009 --- /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 df07a380d..74f8424c8 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 000000000..427a3dd43 --- /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 000000000..403fcd6fe --- /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 000000000..6f608ae36 --- /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 000000000..4aa6d4ba1 --- /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 +}