1// Copyright 2020 CUE Authors
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15package openapi
16
17import (
18 "fmt"
19 "strings"
20
21 "cuelang.org/go/cue"
22 "cuelang.org/go/cue/ast"
23 "cuelang.org/go/cue/errors"
24 "cuelang.org/go/cue/token"
25 "cuelang.org/go/encoding/jsonschema"
26 "cuelang.org/go/internal"
27)
28
29// Extract converts OpenAPI definitions to an equivalent CUE representation.
30//
31// It currently only converts entries in #/components/schema and extracts some
32// meta data.
33func Extract(data cue.InstanceOrValue, c *Config) (*ast.File, error) {
34 // TODO: find a good OpenAPI validator. Both go-openapi and kin-openapi
35 // seem outdated. The k8s one might be good, but avoid pulling in massive
36 // amounts of dependencies.
37
38 f := &ast.File{}
39 add := func(d ast.Decl) {
40 if d != nil {
41 f.Decls = append(f.Decls, d)
42 }
43 }
44
45 v := data.Value()
46 versionValue := v.LookupPath(cue.MakePath(cue.Str("openapi")))
47 if versionValue.Err() != nil {
48 return nil, fmt.Errorf("openapi field is required but not found")
49 }
50 version, err := versionValue.String()
51 if err != nil {
52 return nil, fmt.Errorf("invalid openapi field (must be string): %v", err)
53 }
54 // A simple prefix match is probably OK for now, following
55 // the same logic used by internal/encoding.isOpenAPI.
56 // The specification says that the patch version should be disregarded:
57 // https://swagger.io/specification/v3/
58 var schemaVersion jsonschema.Version
59 switch {
60 case strings.HasPrefix(version, "3.0."):
61 schemaVersion = jsonschema.VersionOpenAPI
62 case strings.HasPrefix(version, "3.1."):
63 schemaVersion = jsonschema.VersionDraft2020_12
64 default:
65 return nil, fmt.Errorf("unknown OpenAPI version %q", version)
66 }
67
68 doc, _ := v.LookupPath(cue.MakePath(cue.Str("info"), cue.Str("title"))).String() // Required
69 if s, _ := v.LookupPath(cue.MakePath(cue.Str("info"), cue.Str("description"))).String(); s != "" {
70 doc += "\n\n" + s
71 }
72 cg := internal.NewComment(true, doc)
73
74 if c.PkgName != "" {
75 p := &ast.Package{Name: ast.NewIdent(c.PkgName)}
76 ast.AddComment(p, cg)
77 add(p)
78 } else if cg != nil {
79 add(cg)
80 }
81
82 js, err := jsonschema.Extract(data, &jsonschema.Config{
83 Root: oapiSchemas,
84 Map: openAPIMapping,
85 DefaultVersion: schemaVersion,
86 StrictFeatures: c.StrictFeatures,
87 // OpenAPI 3.0 is stricter than JSON Schema about allowed keywords.
88 StrictKeywords: schemaVersion == jsonschema.VersionOpenAPI || c.StrictKeywords,
89 })
90 if err != nil {
91 return nil, err
92 }
93 preamble := js.Preamble()
94 body := js.Decls[len(preamble):]
95 for _, d := range preamble {
96 switch x := d.(type) {
97 case *ast.Package:
98 return nil, errors.Newf(x.Pos(), "unexpected package %q", x.Name.Name)
99
100 default:
101 add(x)
102 }
103 }
104
105 // TODO: allow attributes before imports? Would be easier.
106
107 // TODO: do we want to store the OpenAPI version?
108 // if version, _ := v.Lookup("openapi").String(); version != "" {
109 // add(&ast.Attribute{Text: fmt.Sprintf("@openapi(version=%s)", version)})
110 // }
111
112 if info := v.LookupPath(cue.MakePath(cue.Str("info"))); info.Exists() {
113 decls := []interface{}{}
114 if st, ok := info.Syntax().(*ast.StructLit); ok {
115 // Remove title.
116 for _, d := range st.Elts {
117 if f, ok := d.(*ast.Field); ok {
118 switch name, _, _ := ast.LabelName(f.Label); name {
119 case "title", "version":
120 // title: *"title" | string
121 decls = append(decls, &ast.Field{
122 Label: f.Label,
123 Value: ast.NewBinExpr(token.OR,
124 &ast.UnaryExpr{Op: token.MUL, X: f.Value},
125 ast.NewIdent("string")),
126 })
127 continue
128 }
129 }
130 decls = append(decls, d)
131 }
132 add(&ast.Field{
133 Label: ast.NewIdent("info"),
134 Value: ast.NewStruct(decls...),
135 })
136 }
137 }
138
139 if len(body) > 0 {
140 ast.SetRelPos(body[0], token.NewSection)
141 f.Decls = append(f.Decls, body...)
142 }
143
144 return f, nil
145}
146
147const oapiSchemas = "#/components/schemas/"
148
149// rootDefs is the fallback for schemas that are not valid identifiers.
150// TODO: find something more principled.
151const rootDefs = "#SchemaMap"
152
153func openAPIMapping(pos token.Pos, a []string) ([]ast.Label, error) {
154 if len(a) != 3 || a[0] != "components" || a[1] != "schemas" {
155 return nil, errors.Newf(pos,
156 `openapi: reference must be of the form %q; found "#/%s"`,
157 oapiSchemas, strings.Join(a, "/"))
158 }
159 name := a[2]
160 if name != rootDefs[1:] && !ast.StringLabelNeedsQuoting(name) {
161 return []ast.Label{ast.NewIdent("#" + name)}, nil
162 }
163 return []ast.Label{ast.NewIdent(rootDefs), ast.NewString(name)}, nil
164}