diff --git a/encoding/openapi/build.go b/encoding/openapi/build.go index b076a08783b..952e963b100 100644 --- a/encoding/openapi/build.go +++ b/encoding/openapi/build.go @@ -46,6 +46,8 @@ type buildContext struct { fieldFilter *regexp.Regexp evalDepth int // detect cycles when resolving references + paths *OrderedMap + schemas *OrderedMap // Track external schemas. @@ -71,6 +73,85 @@ type oaSchema = OrderedMap type typeFunc func(b *builder, a cue.Value) +func paths(g *Generator, inst *cue.Instance) (paths *ast.StructLit, err error) { + var fieldFilter *regexp.Regexp + if g.FieldFilter != "" { + fieldFilter, err = regexp.Compile(g.FieldFilter) + if err != nil { + return nil, errors.Newf(token.NoPos, "invalid field filter: %v", err) + } + + // verify that certain elements are still passed. + for _, f := range strings.Split( + "version,title,allOf,anyOf,not,enum,Schema/properties,Schema/items"+ + "nullable,type", ",") { + if fieldFilter.MatchString(f) { + return nil, errors.Newf(token.NoPos, "field filter may not exclude %q", f) + } + } + } + + c := buildContext{ + inst: inst, + instExt: inst, + refPrefix: "components/schemas", + expandRefs: g.ExpandReferences, + structural: g.ExpandReferences, + nameFunc: g.ReferenceFunc, + descFunc: g.DescriptionFunc, + paths: &OrderedMap{}, + schemas: &OrderedMap{}, + externalRefs: map[string]*externalType{}, + fieldFilter: fieldFilter, + } + + switch g.Version { + case "3.0.0": + c.exclusiveBool = true + case "3.1.0": + default: + return nil, errors.Newf(token.NoPos, "unsupported version %s", g.Version) + } + + defer func() { + switch x := recover().(type) { + case nil: + case *openapiError: + err = x + default: + panic(x) + } + }() + + i, err := inst.Value().Fields(cue.Definitions(true)) + if err != nil { + return nil, err + } + for i.Next() { + label := i.Label() + + if i.IsDefinition() || !strings.HasPrefix(label, "$/") || c.isInternal(label) { + continue + } + + label = label[1:] + ref := c.makeRef(inst, []string{label}) + if ref == "" { + continue + } + c.paths.Set(ref, c.buildPath(i.Value())) + } + + a := c.paths.Elts + sort.Slice(a, func(i, j int) bool { + x, _, _ := ast.LabelName(a[i].(*ast.Field).Label) + y, _, _ := ast.LabelName(a[j].(*ast.Field).Label) + return x < y + }) + + return (*ast.StructLit)(c.paths), c.errs +} + func schemas(g *Generator, inst *cue.Instance) (schemas *ast.StructLit, err error) { var fieldFilter *regexp.Regexp if g.FieldFilter != "" { @@ -180,6 +261,10 @@ func schemas(g *Generator, inst *cue.Instance) (schemas *ast.StructLit, err erro return (*ast.StructLit)(c.schemas), c.errs } +func (c *buildContext) buildPath(v cue.Value) *ast.StructLit { + return newRootPathBuilder(c).buildPath(v) +} + func (c *buildContext) build(name string, v cue.Value) *ast.StructLit { return newCoreBuilder(c).schema(nil, name, v) } @@ -212,6 +297,94 @@ func (b *builder) checkArgs(a []cue.Value, n int) { } } +func (pb *PathBuilder) pathDescription(v cue.Value) { + description, err := v.String() + if err != nil { + description = "" + } + pb.path.Set("description", ast.NewString(description)) +} + +func (pb *PathBuilder) responses(v cue.Value) *ast.StructLit { + responses := &OrderedMap{} + for i, _ := v.Value().Fields(cue.Definitions(false)); i.Next(); { + // searching http status + label, err := strconv.Atoi(i.Label()) + if err != nil { + pb.failf(v, "%v is no HTTP Status code", label) + } + + if label > 599 || label < 100 { + pb.failf(v, "wrong HTTP Status code %v", label) + } + + responseStruct := Response(i.Value(), pb.ctx) + responses.Set(strconv.Itoa(label), responseStruct) + + } + + return (*ast.StructLit)(responses) +} + +func (pb *PathBuilder) operation(v cue.Value) { + operation := &OrderedMap{} + var security *ast.ListLit + + if v.Lookup("description").Exists() { + description, err := v.Lookup("description").String() + if err != nil { + description = "" + } + operation.Set("description", description) + } + + if v.Lookup("security").Exists() { + security = pb.securityList(v.Lookup("security")) + } else if pb.security != nil { + security = pb.security + } + if security != nil { + operation.Set("security", security) + + } + + responses := pb.responses(v.Lookup("responses")) + + operation.Set("responses", responses) + + label, _ := v.Label() + pb.path.Set(label, operation) +} + +func (pb *PathBuilder) failf(v cue.Value, format string, args ...interface{}) { + panic(&openapiError{ + errors.NewMessage(format, args), + pb.ctx.path, + v.Pos(), + }) +} + +func (pb *PathBuilder) buildPath(v cue.Value) *ast.StructLit { + + for i, _ := v.Value().Fields(cue.Definitions(true)); i.Next(); { + label := i.Label() + + switch label { + case "description": + pb.pathDescription(v.Lookup("description")) + case "security": + pb.security = pb.securityList(v.Lookup("security")) + case "get", "put", "post", "delete", "options", "head", "patch", "trace": + pb.operation(v.Lookup(label)) + default: + pb.failf(i.Value(), "unsupported field \"%v\" for path struct", label) + } + + } + + return (*ast.StructLit)(pb.path) +} + func (b *builder) schema(core *builder, name string, v cue.Value) *ast.StructLit { oldPath := b.ctx.path b.ctx.path = append(b.ctx.path, name) @@ -899,6 +1072,16 @@ func (b *builder) array(v cue.Value) { } } +func (pb *PathBuilder) securityList(v cue.Value) *ast.ListLit { + items := []ast.Expr{} + + for i, _ := v.List(); i.Next(); { + items = append(items, pb.decode(i.Value())) + } + + return ast.NewList(items...) +} + func (b *builder) listCap(v cue.Value) { switch op, a := v.Expr(); op { case cue.LessThanOp: @@ -1093,6 +1276,13 @@ func (b *builder) bytes(v cue.Value) { } } +type PathBuilder struct { + ctx *buildContext + path *OrderedMap + + security *ast.ListLit +} + type builder struct { ctx *buildContext typ string @@ -1112,6 +1302,11 @@ type builder struct { items *builder } +func newRootPathBuilder(c *buildContext) *PathBuilder { + return &PathBuilder{ctx: c, + path: &OrderedMap{}} +} + func newRootBuilder(c *buildContext) *builder { return &builder{ctx: c} } @@ -1317,6 +1512,11 @@ func (b *builder) decode(v cue.Value) ast.Expr { return v.Syntax(cue.Final()).(ast.Expr) } +func (pb *PathBuilder) decode(v cue.Value) ast.Expr { + v, _ = v.Default() + return v.Syntax(cue.Final()).(ast.Expr) +} + func (b *builder) big(v cue.Value) ast.Expr { v, _ = v.Default() return v.Syntax(cue.Final()).(ast.Expr) diff --git a/encoding/openapi/openapi.go b/encoding/openapi/openapi.go index 6eaebb8cae0..1bcc09c7d7b 100644 --- a/encoding/openapi/openapi.go +++ b/encoding/openapi/openapi.go @@ -92,7 +92,12 @@ func Generate(inst *cue.Instance, c *Config) (*ast.File, error) { if err != nil { return nil, err } - top, err := c.compose(inst, all) + paths, err := paths(c, inst) + if err != nil { + return nil, err + } + + top, err := c.compose(inst, all, paths) if err != nil { return nil, err } @@ -108,7 +113,12 @@ func (g *Generator) All(inst *cue.Instance) (*OrderedMap, error) { if err != nil { return nil, err } - top, err := g.compose(inst, all) + paths, err := paths(g, inst) + if err != nil { + return nil, err + } + + top, err := g.compose(inst, all, paths) return (*OrderedMap)(top), err } @@ -125,7 +135,7 @@ func toCUE(name string, x interface{}) (v ast.Expr, err error) { } -func (c *Config) compose(inst *cue.Instance, schemas *ast.StructLit) (x *ast.StructLit, err error) { +func (c *Config) compose(inst *cue.Instance, schemas *ast.StructLit, paths *ast.StructLit) (x *ast.StructLit, err error) { var errs errors.Error @@ -133,7 +143,7 @@ func (c *Config) compose(inst *cue.Instance, schemas *ast.StructLit) (x *ast.Str var info *ast.StructLit for i, _ := inst.Value().Fields(cue.Definitions(true)); i.Next(); { - if i.IsDefinition() { + if i.IsDefinition() || strings.HasPrefix(i.Label(), "$/") { continue } label := i.Label() @@ -210,7 +220,7 @@ func (c *Config) compose(inst *cue.Instance, schemas *ast.StructLit) (x *ast.Str return ast.NewStruct( "openapi", ast.NewString(c.Version), "info", info, - "paths", ast.NewStruct(), + "paths", paths, "components", ast.NewStruct("schemas", schemas), ), errs } diff --git a/encoding/openapi/openapi_test.go b/encoding/openapi/openapi_test.go index ebfba56516f..67f9745c43e 100644 --- a/encoding/openapi/openapi_test.go +++ b/encoding/openapi/openapi_test.go @@ -151,7 +151,28 @@ func TestParseDefinitions(t *testing.T) { in: "cycle.cue", config: &openapi.Config{Info: info, ExpandReferences: true}, err: "cycle", - }} + }, { + in: "simple-path.cue", + out: "simple-path.json", + config: defaultConfig, + }, { + in: "no-content-path.cue", + out: "no-content-path.json", + config: defaultConfig, + }, { + in: "path-with-ref.cue", + out: "path-with-ref.json", + config: defaultConfig, + }, { + in: "path-multiple-operations.cue", + out: "path-multiple-operations.json", + config: defaultConfig, + }, + { + in: "multiple-responses-path.cue", + out: "multiple-responses-path.json", + config: defaultConfig, + }} for _, tc := range testCases { t.Run(tc.out, func(t *testing.T) { filename := filepath.FromSlash(tc.in) @@ -256,3 +277,35 @@ func TestX(t *testing.T) { _ = json.Indent(out, b, "", " ") t.Error(out.String()) } + +func TestExpectedError(t *testing.T) { + defaultConfig := &openapi.Config{} + testCases := []struct { + in, out string + config *openapi.Config + err string + }{{ + in: "wrong-http-status.cue", + config: defaultConfig, + }} + + for _, tc := range testCases { + t.Run(tc.out, func(t *testing.T) { + filename := filepath.FromSlash(tc.in) + + inst := cue.Build(load.Instances([]string{filename}, &load.Config{ + Dir: "./testdata", + }))[0] + if inst.Err != nil { + t.Fatal(errors.Details(inst.Err, nil)) + } + + _, err := openapi.Gen(inst, tc.config) + if err == nil { + t.Fatal("expected error") + return + } + }) + } + +} diff --git a/encoding/openapi/pathResponse.go b/encoding/openapi/pathResponse.go new file mode 100644 index 00000000000..8026dbdc320 --- /dev/null +++ b/encoding/openapi/pathResponse.go @@ -0,0 +1,63 @@ +package openapi + +import ( + "regexp" + + "cuelang.org/go/cue" + "cuelang.org/go/cue/ast" +) + +type ResponseObjectBuilder struct { + ctx *buildContext + description string + mediaTypes *OrderedMap +} + +func newResponseBuilder(c *buildContext) *ResponseObjectBuilder { + return &ResponseObjectBuilder{ctx: c, mediaTypes: &OrderedMap{}} +} + +func (rb *ResponseObjectBuilder) mediaType(v cue.Value) { + schema := &OrderedMap{} + schemaStruct := rb.ctx.build("schema", v.Lookup("schema")) + + schema.Set("schema", schemaStruct) + + label, _ := v.Label() + rb.mediaTypes.Set(label, schema) +} + +func (rb *ResponseObjectBuilder) buildResponse(v cue.Value) *ast.StructLit { + + response := &OrderedMap{} + + description, err := v.Lookup("description").String() + if err != nil { + description = "" + } + rb.description = description + + contentStruct := v.Lookup("content") + r, _ := regexp.Compile(`([^\s]+)[/]([^\s]+)`) + for i, _ := contentStruct.Value().Fields(cue.Definitions(false)); i.Next(); { + label := i.Label() + matched := r.MatchString(label) + + if !matched { + continue + } + rb.mediaType(i.Value()) + + } + + response.Set("description", description) + if rb.mediaTypes.len() != 0 { + response.Set("content", rb.mediaTypes) + } + + return (*ast.StructLit)(response) +} + +func Response(v cue.Value, c *buildContext) *ast.StructLit { + return newResponseBuilder(c).buildResponse(v) +} diff --git a/encoding/openapi/testdata/multiple-responses-path.cue b/encoding/openapi/testdata/multiple-responses-path.cue new file mode 100644 index 00000000000..21dfe4a28d1 --- /dev/null +++ b/encoding/openapi/testdata/multiple-responses-path.cue @@ -0,0 +1,27 @@ +"$/ping": { + security: [{ + "type": ["http"], + "scheme": ["basic"] + }] + description: "Ping endpoint" + get: { + description: "Returns pong" + responses:{ + '200':{ + content: { + "text/plain":{ + schema: string + + } + } + } + '400': { + content: { + "text/plain": { + schema: string + } + } + } + } + } +} diff --git a/encoding/openapi/testdata/multiple-responses-path.json b/encoding/openapi/testdata/multiple-responses-path.json new file mode 100644 index 00000000000..c06ad9104b3 --- /dev/null +++ b/encoding/openapi/testdata/multiple-responses-path.json @@ -0,0 +1,50 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Generated by cue.", + "version": "no version" + }, + "paths": { + "/ping": { + "description": "Ping endpoint", + "get": { + "description": "Returns pong", + "security": [ + { + "type": [ + "http" + ], + "scheme": [ + "basic" + ] + } + ], + "responses": { + "200": { + "description": "", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + } + }, + "components": { + "schemas": {} + } +} \ No newline at end of file diff --git a/encoding/openapi/testdata/no-content-path.cue b/encoding/openapi/testdata/no-content-path.cue new file mode 100644 index 00000000000..837a4997a13 --- /dev/null +++ b/encoding/openapi/testdata/no-content-path.cue @@ -0,0 +1,10 @@ +"$/live": { + get: { + responses: { + "200": { + description: "live endpoint" + } + } + } +} + diff --git a/encoding/openapi/testdata/no-content-path.json b/encoding/openapi/testdata/no-content-path.json new file mode 100644 index 00000000000..449e353bd23 --- /dev/null +++ b/encoding/openapi/testdata/no-content-path.json @@ -0,0 +1,21 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Generated by cue.", + "version": "no version" + }, + "paths": { + "/live": { + "get": { + "responses": { + "200": { + "description": "live endpoint" + } + } + } + } + }, + "components": { + "schemas": {} + } +} \ No newline at end of file diff --git a/encoding/openapi/testdata/path-multiple-operations.cue b/encoding/openapi/testdata/path-multiple-operations.cue new file mode 100644 index 00000000000..e93a81242a6 --- /dev/null +++ b/encoding/openapi/testdata/path-multiple-operations.cue @@ -0,0 +1,34 @@ +"$/ping": { + security: [{ + "type": ["http"], + "scheme": ["basic"] + }] + description: "Ping endpoint" + get: { + description: "Returns pong" + responses:{ + '200':{ + content: { + "text/plain":{ + schema: string + + } + } + } + } + } + post: { + description: "Received a pong" + responses:{ + '200':{ + content: { + "application/json":{ + schema: int + } + } + } + } + } + + +} diff --git a/encoding/openapi/testdata/path-multiple-operations.json b/encoding/openapi/testdata/path-multiple-operations.json new file mode 100644 index 00000000000..46b530e1264 --- /dev/null +++ b/encoding/openapi/testdata/path-multiple-operations.json @@ -0,0 +1,65 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Generated by cue.", + "version": "no version" + }, + "paths": { + "/ping": { + "description": "Ping endpoint", + "get": { + "description": "Returns pong", + "security": [ + { + "type": [ + "http" + ], + "scheme": [ + "basic" + ] + } + ], + "responses": { + "200": { + "description": "", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + }, + "post": { + "description": "Received a pong", + "security": [ + { + "type": [ + "http" + ], + "scheme": [ + "basic" + ] + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "integer" + } + } + } + } + } + } + } + }, + "components": { + "schemas": {} + } +} \ No newline at end of file diff --git a/encoding/openapi/testdata/path-with-ref.cue b/encoding/openapi/testdata/path-with-ref.cue new file mode 100644 index 00000000000..f843bbb03ce --- /dev/null +++ b/encoding/openapi/testdata/path-with-ref.cue @@ -0,0 +1,11 @@ +info: { + title: "Foo API" + version: "v1" +} + +#Foo: bar?: number + +"$/foo": post: { + description: "foo it" + responses: "200": content: "application/json": schema: #Foo +} \ No newline at end of file diff --git a/encoding/openapi/testdata/path-with-ref.json b/encoding/openapi/testdata/path-with-ref.json new file mode 100644 index 00000000000..ef86132f91f --- /dev/null +++ b/encoding/openapi/testdata/path-with-ref.json @@ -0,0 +1,38 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Foo API", + "version": "v1" + }, + "paths": { + "/foo": { + "post": { + "description": "foo it", + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Foo" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Foo": { + "type": "object", + "properties": { + "bar": { + "type": "number" + } + } + } + } + } +} \ No newline at end of file diff --git a/encoding/openapi/testdata/simple-path.cue b/encoding/openapi/testdata/simple-path.cue new file mode 100644 index 00000000000..75a79e2213c --- /dev/null +++ b/encoding/openapi/testdata/simple-path.cue @@ -0,0 +1,20 @@ +"$/ping": { + security: [{ + "type": ["http"], + "scheme": ["basic"] + }] + description: "Ping endpoint" + get: { + description: "Returns pong" + responses:{ + '200':{ + content: { + "text/plain":{ + schema: string + + } + } + } + } + } +} diff --git a/encoding/openapi/testdata/simple-path.json b/encoding/openapi/testdata/simple-path.json new file mode 100644 index 00000000000..452b828dae6 --- /dev/null +++ b/encoding/openapi/testdata/simple-path.json @@ -0,0 +1,40 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Generated by cue.", + "version": "no version" + }, + "paths": { + "/ping": { + "description": "Ping endpoint", + "get": { + "description": "Returns pong", + "security": [ + { + "type": [ + "http" + ], + "scheme": [ + "basic" + ] + } + ], + "responses": { + "200": { + "description": "", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + } + }, + "components": { + "schemas": {} + } +} \ No newline at end of file diff --git a/encoding/openapi/testdata/wrong-http-status.cue b/encoding/openapi/testdata/wrong-http-status.cue new file mode 100644 index 00000000000..e67a4b273b0 --- /dev/null +++ b/encoding/openapi/testdata/wrong-http-status.cue @@ -0,0 +1,16 @@ +"$/ping": { + security: ["token", "user"] + description: "Ping endpoint" + get: { + description: "Returns pong" + responses:{ + '666':{ + content: { + "application/json":{ + schema: {} + } + } + } + } + } +}