encoding/openapi: implement Extract
This is mostly plumbing around the jsonschema
package.
- The txtar-based test was copied and modified
from the jsonschema package.
- This does not yet implement the OpenAPI-specific
dialect of JSON Schema.
Issue #56
Change-Id: I5287507ef9e6eddb27184122cd5764a82c3d45fc
Reviewed-on: https://cue-review.googlesource.com/c/cue/+/5251
Reviewed-by: Marcel van Lohuizen <mpvl@golang.org>
diff --git a/encoding/openapi/decode.go b/encoding/openapi/decode.go
new file mode 100644
index 0000000..7317785
--- /dev/null
+++ b/encoding/openapi/decode.go
@@ -0,0 +1,133 @@
+// 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 openapi
+
+import (
+ "fmt"
+ "strings"
+
+ "cuelang.org/go/cue"
+ "cuelang.org/go/cue/ast"
+ "cuelang.org/go/cue/errors"
+ "cuelang.org/go/cue/token"
+ "cuelang.org/go/encoding/jsonschema"
+ "cuelang.org/go/internal"
+)
+
+// Extract converts OpenAPI definitions to an equivalent CUE representation.
+//
+// It currently only converts entries in #/components/schema and extracts some
+// meta data.
+func Extract(data *cue.Instance, c *Config) (*ast.File, error) {
+ // TODO: find a good OpenAPI validator. Both go-openapi and kin-openapi
+ // seem outdated. The k8s one might be good, but avoid pulling in massive
+ // amounts of dependencies.
+
+ f := &ast.File{}
+ add := func(d ast.Decl) {
+ if d != nil {
+ f.Decls = append(f.Decls, d)
+ }
+ }
+
+ js, err := jsonschema.Extract(data, &jsonschema.Config{
+ Root: oapiSchemas,
+ Map: openAPIMapping,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ v := data.Value()
+
+ doc, _ := v.Lookup("info", "title").String() // Required
+ if s, _ := v.Lookup("info", "description").String(); s != "" {
+ doc += "\n\n" + s
+ }
+ cg := internal.NewComment(true, doc)
+
+ if c.PkgName != "" {
+ p := &ast.Package{Name: ast.NewIdent(c.PkgName)}
+ p.AddComment(cg)
+ add(p)
+ } else if cg != nil {
+ add(cg)
+ }
+
+ i := 0
+ for ; i < len(js.Decls); i++ {
+ switch x := js.Decls[i].(type) {
+ case *ast.Package:
+ return nil, errors.Newf(x.Pos(), "unexpected package %q", x.Name.Name)
+
+ case *ast.ImportDecl, *ast.CommentGroup:
+ add(x)
+ continue
+ }
+ break
+ }
+
+ // TODO: allow attributes before imports? Would be easier.
+
+ // TODO: do we want to store the OpenAPI version?
+ // if version, _ := v.Lookup("openapi").String(); version != "" {
+ // add(internal.NewAttr("openapi", "version="+ version))
+ // }
+
+ info := v.Lookup("info")
+ if version, _ := info.Lookup("version").String(); version != "" {
+ add(internal.NewAttr("version", version))
+ }
+
+ add(fieldsAttr(info, "license", "name", "url"))
+ add(fieldsAttr(info, "contact", "name", "url", "email"))
+ // TODO: terms of service.
+
+ if i < len(js.Decls) {
+ ast.SetRelPos(js.Decls[i], token.NewSection)
+ f.Decls = append(f.Decls, js.Decls[i:]...)
+ }
+
+ return f, nil
+}
+
+func fieldsAttr(v cue.Value, name string, fields ...string) ast.Decl {
+ group := v.Lookup(name)
+ if !group.Exists() {
+ return nil
+ }
+
+ buf := &strings.Builder{}
+ for _, f := range fields {
+ if s, _ := group.Lookup(f).String(); s != "" {
+ if buf.Len() > 0 {
+ buf.WriteByte(',')
+ }
+ _, _ = fmt.Fprintf(buf, "%s=%q", f, s)
+ }
+ }
+ return internal.NewAttr(name, buf.String())
+}
+
+const oapiSchemas = "#/components/schemas/"
+
+func openAPIMapping(pos token.Pos, a []string) ([]string, error) {
+ if len(a) != 3 || a[0] != "components" || a[1] != "schemas" {
+ return nil, errors.Newf(pos,
+ `openapi: reference must be of the form %q; found "#/%s"`,
+ oapiSchemas, strings.Join(a, "/"))
+ }
+ return a[2:], nil
+}
diff --git a/encoding/openapi/decode_test.go b/encoding/openapi/decode_test.go
new file mode 100644
index 0000000..8a6d8a8
--- /dev/null
+++ b/encoding/openapi/decode_test.go
@@ -0,0 +1,122 @@
+// Copyright 2019 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 openapi_test
+
+import (
+ "bytes"
+ "io/ioutil"
+ "os"
+ "path"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/google/go-cmp/cmp"
+ "github.com/stretchr/testify/assert"
+ "golang.org/x/tools/txtar"
+
+ "cuelang.org/go/cue"
+ "cuelang.org/go/cue/errors"
+ "cuelang.org/go/cue/format"
+ "cuelang.org/go/encoding/json"
+ "cuelang.org/go/encoding/openapi"
+ "cuelang.org/go/encoding/yaml"
+)
+
+// TestDecode reads the testdata/*.txtar files, converts the contained
+// JSON schema to CUE and compares it against the output.
+//
+// Use the --update flag to update test files with the corresponding output.
+func TestDecode(t *testing.T) {
+ err := filepath.Walk("testdata/script", func(fullpath string, info os.FileInfo, err error) error {
+ _ = err
+ if !strings.HasSuffix(fullpath, ".txtar") {
+ return nil
+ }
+
+ t.Run(fullpath, func(t *testing.T) {
+ a, err := txtar.ParseFile(fullpath)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ cfg := &openapi.Config{PkgName: "foo"}
+
+ r := &cue.Runtime{}
+ var in *cue.Instance
+ var out, errout []byte
+ outIndex := -1
+
+ for i, f := range a.Files {
+ switch path.Ext(f.Name) {
+ case ".json":
+ in, err = json.Decode(r, f.Name, f.Data)
+ case ".yaml":
+ in, err = yaml.Decode(r, f.Name, f.Data)
+ case ".cue":
+ out = f.Data
+ outIndex = i
+ case ".err":
+ errout = f.Data
+ }
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ expr, err := openapi.Extract(in, cfg)
+ if err != nil && errout == nil {
+ t.Fatal(errors.Details(err, nil))
+ }
+ got := []byte(nil)
+ if err != nil {
+ got = []byte(err.Error())
+ }
+ if !cmp.Equal(errout, got) {
+ t.Error(cmp.Diff(string(got), string(errout)))
+ }
+
+ if expr != nil {
+ b, err := format.Node(expr, format.Simplify())
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // verify the generated CUE.
+ if _, err = r.Compile(fullpath, b); err != nil {
+ t.Fatal(errors.Details(err, nil))
+ }
+
+ b = bytes.TrimSpace(b)
+ out = bytes.TrimSpace(out)
+
+ if !cmp.Equal(b, out) {
+ if *update {
+ a.Files[outIndex].Data = b
+ b = txtar.Format(a)
+ err = ioutil.WriteFile(fullpath, b, 0644)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return
+ }
+ t.Error(cmp.Diff(string(out), string(b)))
+ }
+ }
+ })
+ return nil
+ })
+ assert.NoError(t, err)
+}
diff --git a/encoding/openapi/openapi.go b/encoding/openapi/openapi.go
index 9ec9239..49f33fd 100644
--- a/encoding/openapi/openapi.go
+++ b/encoding/openapi/openapi.go
@@ -26,6 +26,9 @@
// A Config defines options for converting CUE to and from OpenAPI.
type Config struct {
+ // PkgName defines to package name for a generated CUE package.
+ PkgName string
+
// Info specifies the info section of the OpenAPI document. To be a valid
// OpenAPI document, it must include at least the title and version fields.
// Info may be a *ast.StructLit or any type that marshals to JSON.
diff --git a/encoding/openapi/testdata/script/basics.txtar b/encoding/openapi/testdata/script/basics.txtar
new file mode 100644
index 0000000..b7ece27
--- /dev/null
+++ b/encoding/openapi/testdata/script/basics.txtar
@@ -0,0 +1,42 @@
+-- type.yaml --
+openapi: 3.0.0
+info:
+ title: Users schema
+ version: v1beta1
+ contact:
+ name: The CUE Authors
+ url: https://cuelang.org
+
+components:
+ schemas:
+ User:
+ description: "A User uses something."
+ type: object
+ properties:
+ id:
+ type: integer
+ name:
+ type: string
+ address:
+ $ref: "#/components/schemas/PhoneNumber"
+ PhoneNumber:
+ description: "The number to dial."
+ type: string
+
+-- out.cue --
+// Users schema
+package foo
+
+@version(v1beta1)
+@contact(name="The CUE Authors",url="https://cuelang.org")
+
+// A User uses something.
+User :: {
+ name?: string
+ id?: int
+ address?: PhoneNumber
+ ...
+}
+
+// The number to dial.
+PhoneNumber :: string