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