From e7d9d4789bcd8c0cc3519d183ffba309281d78ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Mart=C3=AD?= Date: Sat, 28 Sep 2024 00:09:34 +0200 Subject: [PATCH] encoding/openapi: marshal JSON via cue.Value.MarshalJSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rather than via internal/encoding/json.Encode, which was an entirely separate implementation of a CUE to JSON marshaler working on cue/ast rather than cue.Value. We can use the cue.Value API by building the AST, which should not be an expensive operation given how the syntax trees describe concrete CUE values which can be encoded as JSON. There are several reasons to want to remove the cue/ast json encoder: * We do not want to maintain two JSON encoding implementations * cue.Value.MarshalJSON is more widely used, e.g. when exporting to JSON * We are converging towards Encode/Marshal APIs taking cue.Value rather than AST nodes, given that the former is much more flexible Signed-off-by: Daniel Martí Change-Id: Id2f5f02579f4cbc1e94a263035624bc5781b5587 Reviewed-on: https://review.gerrithub.io/c/cue-lang/cue/+/1201935 Unity-Result: CUE porcuepine Reviewed-by: Roger Peppe TryBot-Result: CUEcueckoo --- encoding/openapi/openapi.go | 6 +- encoding/openapi/orderedmap.go | 10 +- internal/encoding/json/encode.go | 288 ----------------------- internal/encoding/json/encode_test.go | 315 -------------------------- 4 files changed, 6 insertions(+), 613 deletions(-) delete mode 100644 internal/encoding/json/encode_test.go diff --git a/encoding/openapi/openapi.go b/encoding/openapi/openapi.go index 9d7d5e7a4..c714d2126 100644 --- a/encoding/openapi/openapi.go +++ b/encoding/openapi/openapi.go @@ -99,7 +99,11 @@ func Gen(inst cue.InstanceOrValue, c *Config) ([]byte, error) { if err != nil { return nil, err } - return internaljson.Marshal((*OrderedMap)(top)) + topValue := inst.Value().Context().BuildExpr(top) + if err := topValue.Err(); err != nil { + return nil, err + } + return internaljson.Marshal(topValue) } // Generate generates the set of OpenAPI schema for all top-level types of the diff --git a/encoding/openapi/orderedmap.go b/encoding/openapi/orderedmap.go index 774625a86..18bf01332 100644 --- a/encoding/openapi/orderedmap.go +++ b/encoding/openapi/orderedmap.go @@ -16,11 +16,10 @@ package openapi import ( "cuelang.org/go/cue/ast" - internaljson "cuelang.org/go/internal/encoding/json" ) // An OrderedMap is a set of key-value pairs that preserves the order in which -// items were added. It marshals to JSON as an object. +// items were added. // // Deprecated: the API now returns an ast.File. This allows OpenAPI to be // represented as JSON, YAML, or CUE data, in addition to being able to use @@ -94,10 +93,3 @@ func (m *OrderedMap) getMap(key string) *OrderedMap { } return (*OrderedMap)(f.Value.(*ast.StructLit)) } - -// MarshalJSON implements [encoding/json.Marshaler]. -func (m *OrderedMap) MarshalJSON() (b []byte, err error) { - // This is a pointer receiever to enforce that we only store pointers to - // OrderedMap in the output. - return internaljson.Encode((*ast.StructLit)(m)) -} diff --git a/internal/encoding/json/encode.go b/internal/encoding/json/encode.go index 40c2efdb9..7d6a11dcd 100644 --- a/internal/encoding/json/encode.go +++ b/internal/encoding/json/encode.go @@ -17,15 +17,6 @@ package json import ( "bytes" "encoding/json" - "math/big" - "strings" - - "cuelang.org/go/cue/ast" - "cuelang.org/go/cue/errors" - "cuelang.org/go/cue/literal" - "cuelang.org/go/cue/token" - "cuelang.org/go/internal" - "cuelang.org/go/internal/astinternal" ) // Marshal is a replacement for [json.Marshal] without HTML escaping. @@ -41,282 +32,3 @@ func Marshal(v any) ([]byte, error) { p = bytes.TrimSuffix(p, []byte("\n")) return p, nil } - -// Encode converts a CUE AST to unescaped JSON. -// -// The given file must only contain values that can be directly supported by -// JSON: -// -// Type Restrictions -// BasicLit -// File no imports, aliases, or definitions -// StructLit no embeddings, aliases, or definitions -// List -// Field must be regular; label must be a BasicLit or Ident -// -// Comments and attributes are ignored. -func Encode(n ast.Node) (b []byte, err error) { - e := encoder{} - err = e.encode(n) - if err != nil { - return nil, err - } - return e.w.Bytes(), nil -} - -type encoder struct { - w bytes.Buffer - tab []byte - indentsAtLevel []int - indenting bool - unIndenting int -} - -func (e *encoder) writeIndent(b byte) { - if e.indenting { - e.indentsAtLevel[len(e.indentsAtLevel)-1]++ - } else { - e.indentsAtLevel = append(e.indentsAtLevel, 0) - } - e.indenting = true - _ = e.w.WriteByte(b) -} - -func (e *encoder) writeUnindent(b byte, pos, def token.Pos) { - if e.unIndenting > 0 { - e.unIndenting-- - } else { - e.unIndenting = e.indentsAtLevel[len(e.indentsAtLevel)-1] - e.indentsAtLevel = e.indentsAtLevel[:len(e.indentsAtLevel)-1] - } - e.indenting = false - e.ws(pos, def.RelPos()) - _ = e.w.WriteByte(b) -} - -func (e *encoder) writeString(s string) { - _, _ = e.w.WriteString(s) - e.indenting = false -} - -func (e *encoder) writeByte(b byte) { - _ = e.w.WriteByte(b) -} - -func (e *encoder) write(b []byte) { - _, _ = e.w.Write(b) - e.indenting = false -} - -func (e *encoder) indent() { - for range e.indentsAtLevel { - e.write(e.tab) - } -} - -func (e *encoder) ws(pos token.Pos, default_ token.RelPos) { - rel := pos.RelPos() - if pos == token.NoPos { - rel = default_ - } - switch rel { - case token.NoSpace: - case token.Blank: - e.writeByte(' ') - case token.Newline: - e.writeByte('\n') - e.indent() - case token.NewSection: - e.writeString("\n\n") - e.indent() - } -} -func (e *encoder) encode(n ast.Node) error { - if e.tab == nil { - e.tab = []byte(" ") - } - const defPos = token.NoSpace - switch x := n.(type) { - case *ast.BasicLit: - e.ws(x.Pos(), defPos) - return e.encodeScalar(x, true) - - case *ast.ListLit: - e.ws(foldNewline(x.Pos()), token.NoRelPos) - if len(x.Elts) == 0 { - e.writeString("[]") - return nil - } - e.writeIndent('[') - for i, x := range x.Elts { - if i > 0 { - e.writeString(",") - } - if err := e.encode(x); err != nil { - return err - } - } - e.writeUnindent(']', x.Rbrack, compactNewline(x.Elts[0].Pos())) - return nil - - case *ast.StructLit: - e.ws(foldNewline(n.Pos()), token.NoRelPos) - return e.encodeDecls(x.Elts, x.Rbrace) - - case *ast.File: - return e.encodeDecls(x.Decls, token.NoPos) - - case *ast.UnaryExpr: - e.ws(foldNewline(x.Pos()), defPos) - l, ok := x.X.(*ast.BasicLit) - if ok && x.Op == token.SUB && (l.Kind == token.INT || l.Kind == token.FLOAT) { - e.writeByte('-') - return e.encodeScalar(l, false) - } - } - return errors.Newf(n.Pos(), "json: unsupported node %s (%T)", astinternal.DebugStr(n), n) -} - -func (e *encoder) encodeScalar(l *ast.BasicLit, allowMinus bool) error { - switch l.Kind { - case token.INT: - var x big.Int - return e.setNum(l, allowMinus, &x) - - case token.FLOAT: - var x big.Float - return e.setNum(l, allowMinus, &x) - - case token.TRUE: - e.writeString("true") - - case token.FALSE: - e.writeString("false") - - case token.NULL: - e.writeString("null") - - case token.STRING: - str, err := literal.Unquote(l.Value) - if err != nil { - return err - } - b, err := Marshal(str) - if err != nil { - return err - } - e.write(b) - - default: - return errors.Newf(l.Pos(), "unknown literal type %v", l.Kind) - } - return nil -} - -func (e *encoder) setNum(l *ast.BasicLit, allowMinus bool, x interface{}) error { - if !allowMinus && strings.HasPrefix(l.Value, "-") { - return errors.Newf(l.Pos(), "double minus not allowed") - } - var ni literal.NumInfo - if err := literal.ParseNum(l.Value, &ni); err != nil { - return err - } - e.writeString(ni.String()) - return nil -} - -// encodeDecls converts a sequence of declarations to a value. If it encounters -// an embedded value, it will return this expression. This is more relaxed for -// structs than is currently allowed for CUE, but the expectation is that this -// will be allowed at some point. The input would still be illegal CUE. -func (e *encoder) encodeDecls(decls []ast.Decl, endPos token.Pos) error { - var embed ast.Expr - var fields []*ast.Field - - for _, d := range decls { - switch x := d.(type) { - default: - return errors.Newf(x.Pos(), "json: unsupported node %s (%T)", astinternal.DebugStr(x), x) - - case *ast.Package: - if embed != nil || fields != nil { - return errors.Newf(x.Pos(), "invalid package clause") - } - continue - - case *ast.Field: - if !internal.IsRegularField(x) { - return errors.Newf(x.TokenPos, "json: definition or hidden field not allowed") - } - if x.Optional != token.NoPos { - return errors.Newf(x.Optional, "json: optional fields not allowed") - } - fields = append(fields, x) - - case *ast.EmbedDecl: - if embed != nil { - return errors.Newf(x.Pos(), "json: multiple embedded values") - } - embed = x.Expr - - case *ast.CommentGroup: - } - } - - if embed != nil { - if fields != nil { - return errors.Newf(embed.Pos(), "json: embedding mixed with fields") - } - return e.encode(embed) - } - - if len(fields) == 0 { - e.writeString("{}") - return nil - } - - e.writeIndent('{') - pos := compactNewline(fields[0].Pos()) - if endPos == token.NoPos && pos.RelPos() == token.Blank { - pos = token.NoPos - } - firstPos := pos - const defPos = token.NoRelPos - for i, x := range fields { - if i > 0 { - e.writeByte(',') - pos = x.Pos() - } - name, _, err := ast.LabelName(x.Label) - if err != nil { - return errors.Newf(x.Label.Pos(), "json: only literal labels allowed") - } - b, err := Marshal(name) - if err != nil { - return err - } - e.ws(pos, defPos) - e.write(b) - e.writeByte(':') - - if err := e.encode(x.Value); err != nil { - return err - } - } - e.writeUnindent('}', endPos, firstPos) - return nil -} - -func compactNewline(pos token.Pos) token.Pos { - if pos.RelPos() == token.NewSection { - pos = token.Newline.Pos() - } - return pos -} - -func foldNewline(pos token.Pos) token.Pos { - if pos.RelPos() >= token.Newline { - pos = token.Blank.Pos() - } - return pos -} diff --git a/internal/encoding/json/encode_test.go b/internal/encoding/json/encode_test.go deleted file mode 100644 index 0bf00d0c8..000000000 --- a/internal/encoding/json/encode_test.go +++ /dev/null @@ -1,315 +0,0 @@ -// Copyright 2020 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 json - -import ( - "encoding/json" - "strings" - "testing" - - "cuelang.org/go/cue/ast" - "cuelang.org/go/cue/parser" - "cuelang.org/go/cue/token" - "github.com/google/go-cmp/cmp" -) - -func TestEncodeFile(t *testing.T) { - testCases := []struct { - name string - in string - out string - }{{ - name: "foo", - in: ` - package test - - seq: [ - 1, 2, 3, { - a: 1 - b: 2 - } - ] - a: b: c: 3 - b: { - x: 0 - y: 1 - z: 2 - } - `, - out: `{ - "seq": [ - 1, 2, 3, { - "a": 1, - "b": 2 - } - ], - "a": {"b": {"c": 3}}, - "b": { - "x": 0, - "y": 1, - "z": 2 - } -}`, - }, { - name: "oneLineFields", - in: ` - seq: [1, 2, 3] - esq: [] - emp: {} - map: {a: 3} - str: "str" - int: 1K - bin: 0b11 - hex: 0x11 - dec: .3 - dat: '\x80' - nil: null - yes: true - non: false - `, - out: `{ - "seq": [1, 2, 3], - "esq": [], - "emp": {}, - "map": {"a": 3}, - "str": "str", - "int": 1000, - "bin": 3, - "hex": 17, - "dec": 0.3, - "dat": "\ufffd", - "nil": null, - "yes": true, - "non": false -}`, - }, { - name: "comments", - in: ` -// Document - -// head 1 -f1: 1 -// foot 1 - -// head 2 -f2: 2 // line 2 - -// intermezzo f2 -// -// with multiline - -// head 3 -f3: - // struct doc - { - a: 1 - } - -f4: { -} // line 4 - -// Trailing -`, - out: `{ - "f1": 1, - "f2": 2, - "f3": { - "a": 1 - }, - - "f4": {} -}`, - }, { - // TODO: support this at some point - name: "embed", - in: ` - // hex - 0xabc // line - // trail - `, - out: `2748`, - }, { - name: "anchors", - in: ` - a: b - b: 3 - `, - out: "json: unsupported node b (*ast.Ident)", - }, { - name: "errors", - in: ` - m: { - a: 1 - b: 3 - } - c: [1, [for x in m { x }]] - `, - out: "json: unsupported node for x in m {x} (*ast.Comprehension)", - }, { - name: "disallowMultipleEmbeddings", - in: ` - 1 - 1 - `, - out: "json: multiple embedded values", - }, { - name: "disallowDefinitions", - in: `#a: 2 `, - out: "json: definition or hidden field not allowed", - }, { - name: "disallowHidden", - in: `_a: 2 `, - out: "json: definition or hidden field not allowed", - }, { - name: "disallowOptionals", - in: `a?: 2`, - out: "json: optional fields not allowed", - }, { - name: "disallowBulkOptionals", - in: `[string]: 2`, - out: "json: only literal labels allowed", - }, { - name: "noImports", - in: ` - import "foo" - - a: 1 - `, - out: `json: unsupported node import "foo" (*ast.ImportDecl)`, - }, { - name: "disallowMultipleEmbeddings", - in: ` - 1 - a: 2 - `, - out: "json: embedding mixed with fields", - }, { - name: "prometheus", - in: ` - { - receivers: [{ - name: "pager" - slack_configs: [{ - text: """ - {{ range .Alerts }}{{ .Annotations.description }} - {{ end }} - """ - channel: "#cloudmon" - send_resolved: true - }] - }] - route: { - receiver: "pager" - group_by: ["alertname", "cluster"] - } - }`, - out: `{ - "receivers": [{ - "name": "pager", - "slack_configs": [{ - "text": "{{ range .Alerts }}{{ .Annotations.description }}\n{{ end }}", - "channel": "#cloudmon", - "send_resolved": true - }] - }], - "route": { - "receiver": "pager", - "group_by": ["alertname", "cluster"] - } -}`, - }} - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - f, err := parser.ParseFile(tc.name, tc.in, parser.ParseComments) - if err != nil { - t.Fatal(err) - } - b, err := Encode(f) - var got string - if err != nil { - got = err.Error() - } else { - if !json.Valid(b) { - t.Fatal("invalid JSON") - } - got = strings.TrimSpace(string(b)) - } - want := strings.TrimSpace(tc.out) - if got != want { - t.Log("\n" + got) - t.Error(cmp.Diff(got, want)) - } - }) - } -} - -func TestEncodeAST(t *testing.T) { - comment := func(s string) *ast.CommentGroup { - return &ast.CommentGroup{List: []*ast.Comment{ - {Text: "// " + s}, - }} - } - testCases := []struct { - name string - in ast.Expr - out string - }{{ - in: ast.NewStruct( - comment("foo"), - comment("bar"), - "field", ast.NewString("value"), - "field2", ast.NewString("value"), - comment("trail1"), - comment("trail2"), - ), - out: `{"field":"value","field2":"value"}`, - }, { - in: &ast.StructLit{Elts: []ast.Decl{ - comment("bar"), - &ast.EmbedDecl{Expr: ast.NewBool(true)}, - }}, - out: `true`, - }, { - in: &ast.UnaryExpr{ - Op: token.SUB, - X: &ast.BasicLit{Kind: token.INT, Value: "-2"}, - }, - out: `double minus not allowed`, - }, { - in: &ast.BasicLit{Kind: token.INT, Value: "-2.0.0"}, - out: `invalid number "-2.0.0"`, - }, { - in: &ast.StructLit{Elts: []ast.Decl{ - &ast.EmbedDecl{Expr: ast.NewBool(true)}, - &ast.Package{}, - }}, - out: `invalid package clause`, - }} - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - b, err := Encode(tc.in) - var got string - if err != nil { - got = err.Error() - } else { - got = strings.TrimSpace(string(b)) - } - want := strings.TrimSpace(tc.out) - if got != want { - t.Error(cmp.Diff(got, want)) - } - }) - } -} -- 2.51.2