Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,22 @@ jobs:
with:
persist-credentials: false

- name: Setup Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: cmd/cue2openapi/go.mod
cache-dependency-path: |
cmd/cue2openapi/go.sum
cmd/openapi2md/go.sum

- name: Test CUE to OpenAPI converter
working-directory: cmd/cue2openapi
run: go test -count=1 ./...

- name: Test OpenAPI to Markdown converter
working-directory: cmd/openapi2md
run: go test -count=1 ./...

- name: Setup Cue
uses: cue-lang/setup-cue@a93fa358375740cd8b0078f76355512b9208acb1 # v1.0.1

Expand Down
136 changes: 135 additions & 1 deletion cmd/cue2openapi/converter.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"strings"

"cuelang.org/go/cue/ast"
"cuelang.org/go/cue/literal"
"cuelang.org/go/cue/load"
"cuelang.org/go/cue/token"
"github.com/goccy/go-yaml"
Expand Down Expand Up @@ -46,6 +47,7 @@ type SchemaInfo struct {
Pattern string `yaml:"pattern,omitempty" json:"pattern,omitempty"`
Format string `yaml:"format,omitempty" json:"format,omitempty"`
Items interface{} `yaml:"items,omitempty" json:"items,omitempty"`
Enum []string `yaml:"enum,omitempty" json:"enum,omitempty"`
Ref string `yaml:"$ref,omitempty" json:"$ref,omitempty"`
}

Expand Down Expand Up @@ -301,7 +303,7 @@ func convertStructToSchema(st *ast.StructLit, spec *OpenAPISpec, description str
if fieldName != "" {
schema.Properties[fieldName] = fieldSchema
// Check if field is required
if x.Optional == token.NoPos {
if x.Constraint != token.OPTION {
schema.Required = append(schema.Required, fieldName)
}
}
Expand Down Expand Up @@ -348,6 +350,8 @@ func convertExprToSchema(expr ast.Expr, spec *OpenAPISpec, description string) i
return convertIdentToSchema(x, spec, description)
case *ast.BinaryExpr:
return convertBinaryExprToSchema(x, spec, description)
case *ast.ParenExpr:
return convertExprToSchema(x.X, spec, description)
case *ast.ListLit:
return convertListLitToSchema(x, spec, description)
case *ast.StructLit:
Expand Down Expand Up @@ -404,12 +408,142 @@ func convertBinaryExprToSchema(expr *ast.BinaryExpr, spec *OpenAPISpec, descript

// Handle union types (disjunctions)
if expr.Op == token.OR {
if isStringListUnion(expr) {
return &SchemaInfo{
Type: "array",
Description: description,
Items: &SchemaInfo{Type: "string"},
}
}
if values, ok := stringLiteralUnion(expr); ok {
return &SchemaInfo{
Type: "string",
Description: description,
Enum: values,
}
}
return &SchemaInfo{Type: "string", Description: description}
}

return &SchemaInfo{Type: "string", Description: description}
}

func stringLiteralUnion(expr ast.Expr) ([]string, bool) {
values := make([]string, 0)
seen := make(map[string]struct{})
if !collectStringLiteralUnion(expr, &values, seen) || len(values) == 0 {
return nil, false
}
return values, true
}

func collectStringLiteralUnion(expr ast.Expr, values *[]string, seen map[string]struct{}) bool {
switch x := expr.(type) {
case *ast.ParenExpr:
return collectStringLiteralUnion(x.X, values, seen)
case *ast.UnaryExpr:
return x.Op == token.MUL && collectStringLiteralUnion(x.X, values, seen)
case *ast.BinaryExpr:
if x.Op != token.OR {
return false
}
return collectStringLiteralUnion(x.X, values, seen) &&
collectStringLiteralUnion(x.Y, values, seen)
case *ast.BasicLit:
if x.Kind != token.STRING {
return false
}
quote, _, _, err := literal.ParseQuotes(x.Value, x.Value)
if err != nil || !quote.IsDouble() {
return false
}
value, err := literal.Unquote(x.Value)
if err != nil {
return false
}
if _, exists := seen[value]; !exists {
seen[value] = struct{}{}
*values = append(*values, value)
}
return true
default:
return false
}
}

func isStringListUnion(expr ast.Expr) bool {
binary, ok := expr.(*ast.BinaryExpr)
return ok && binary.Op == token.OR &&
isStringListExpression(binary.X) && isStringListExpression(binary.Y)
}

func isStringListExpression(expr ast.Expr) bool {
switch x := expr.(type) {
case *ast.ParenExpr:
return isStringListExpression(x.X)
case *ast.UnaryExpr:
return x.Op == token.MUL && isStringListExpression(x.X)
case *ast.BinaryExpr:
return x.Op == token.OR &&
isStringListExpression(x.X) && isStringListExpression(x.Y)
case *ast.ListLit:
return isStringListLiteral(x)
default:
return false
}
}

func isStringListLiteral(list *ast.ListLit) bool {
if len(list.Elts) == 0 {
return false
}
for _, element := range list.Elts {
switch x := element.(type) {
case *ast.Ellipsis:
if !isStringTypeExpression(x.Type) {
return false
}
default:
if !isStringListElement(element) {
return false
}
}
}
return true
}

func isStringListElement(expr ast.Expr) bool {
switch x := expr.(type) {
case *ast.ParenExpr:
return isStringListElement(x.X)
case *ast.UnaryExpr:
return x.Op == token.MUL && isStringListElement(x.X)
case *ast.BasicLit:
if x.Kind != token.STRING {
return false
}
quote, _, _, err := literal.ParseQuotes(x.Value, x.Value)
return err == nil && quote.IsDouble()
case *ast.Ident:
return x.Name == "string"
default:
return false
}
}

func isStringTypeExpression(expr ast.Expr) bool {
switch x := expr.(type) {
case *ast.ParenExpr:
return isStringTypeExpression(x.X)
case *ast.UnaryExpr:
return x.Op == token.MUL && isStringTypeExpression(x.X)
case *ast.Ident:
return x.Name == "string"
default:
return false
}
}

func convertListLitToSchema(list *ast.ListLit, spec *OpenAPISpec, description string) interface{} {
// Check if it's an ellipsis list like [...#Contact] or [#Contact, ...]
for _, elt := range list.Elts {
Expand Down
190 changes: 190 additions & 0 deletions cmd/cue2openapi/converter_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
package main

import (
"os"
"path/filepath"
"reflect"
"testing"

"cuelang.org/go/cue/parser"
"github.com/goccy/go-yaml"
)

func TestSecurityToolUnionProjection(t *testing.T) {
t.Parallel()

schemaDir := t.TempDir()
schemaPath := filepath.Join(schemaDir, "schema.cue")
schema := `package spec

#SecurityTool: {
type: "fuzzing" | "container" | "secret" | "SCA" | "SAST" | "other"
rulesets: ["default"] | [...string]
}
`
if err := os.WriteFile(schemaPath, []byte(schema), 0o600); err != nil {
t.Fatalf("write schema fixture: %v", err)
}

outputPath := filepath.Join(t.TempDir(), "openapi.yaml")
if err := convertCUEToOpenAPI(schemaDir, outputPath, ConvertOpts{Version: "test"}); err != nil {
t.Fatalf("convert CUE to OpenAPI: %v", err)
}

data, err := os.ReadFile(outputPath)
if err != nil {
t.Fatalf("read generated OpenAPI: %v", err)
}
var document struct {
Components struct {
Schemas map[string]struct {
Properties map[string]struct {
Type string `yaml:"type"`
Enum []string `yaml:"enum"`
Items *struct {
Type string `yaml:"type"`
} `yaml:"items"`
} `yaml:"properties"`
} `yaml:"schemas"`
} `yaml:"components"`
}
if err := yaml.Unmarshal(data, &document); err != nil {
t.Fatalf("parse generated OpenAPI: %v", err)
}

securityTool, ok := document.Components.Schemas["SecurityTool"]
if !ok {
t.Fatal("generated OpenAPI is missing SecurityTool")
}
rulesets := securityTool.Properties["rulesets"]
if rulesets.Type != "array" || rulesets.Items == nil || rulesets.Items.Type != "string" {
t.Fatalf("rulesets = type %q items %#v; want array[string]", rulesets.Type, rulesets.Items)
}

wantEnum := []string{"fuzzing", "container", "secret", "SCA", "SAST", "other"}
if got := securityTool.Properties["type"].Enum; !reflect.DeepEqual(got, wantEnum) {
t.Fatalf("SecurityTool.type enum = %#v; want %#v", got, wantEnum)
}
}

func TestUnionProjectionIsTypeSafe(t *testing.T) {
t.Parallel()

tests := []struct {
name string
expression string
wantType string
wantItemsType string
wantEnum []string
}{
{
name: "nested enum preserves source order and removes duplicates",
expression: `("fuzzing" | "SCA") | ("SAST" | "fuzzing")`,
wantType: "string",
wantEnum: []string{"fuzzing", "SCA", "SAST"},
},
{
name: "fully parenthesized enum is preserved",
expression: `((("fuzzing" | "SCA")))`,
wantType: "string",
wantEnum: []string{"fuzzing", "SCA"},
},
{
name: "defaulted enum is preserved",
expression: `*"fuzzing" | "SCA"`,
wantType: "string",
wantEnum: []string{"fuzzing", "SCA"},
},
{
name: "escaped and unicode enum literals are decoded",
expression: `"line\nfeed" | "café"`,
wantType: "string",
wantEnum: []string{"line\nfeed", "café"},
},
{
name: "mixed scalar union fails closed without enum",
expression: `"fuzzing" | bool`,
wantType: "string",
},
{
name: "string list union becomes array of strings",
expression: `["default"] | ([...string] | [string])`,
wantType: "array",
wantItemsType: "string",
},
{
name: "fully parenthesized list union remains an array",
expression: `((["default"] | [...string]))`,
wantType: "array",
wantItemsType: "string",
},
{
name: "parenthesized list elements and ellipsis type remain strings",
expression: `[("default")] | [...(string)]`,
wantType: "array",
wantItemsType: "string",
},
{
name: "defaulted string list remains an array",
expression: `*["default"] | [...string]`,
wantType: "array",
wantItemsType: "string",
},
{
name: "mixed list union fails closed",
expression: `["default"] | [...int]`,
wantType: "string",
},
{
name: "non-default unary constraint is not treated as a string list",
expression: `!=["default"] | [...string]`,
wantType: "string",
},
{
name: "empty list does not imply string items",
expression: `[] | [...string]`,
wantType: "string",
},
{
name: "bytes literals are not projected as string enums",
expression: `'fuzzing' | 'SCA'`,
wantType: "string",
},
{
name: "mixed string and bytes literals fail closed",
expression: `"fuzzing" | 'SCA'`,
wantType: "string",
},
}

for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
t.Parallel()
expr, err := parser.ParseExpr("test.cue", test.expression)
if err != nil {
t.Fatalf("parse expression %q: %v", test.expression, err)
}
got, ok := convertExprToSchema(expr, &OpenAPISpec{}, "").(*SchemaInfo)
if !ok {
t.Fatalf("projection type = %T; want *SchemaInfo", got)
}
Comment on lines +168 to +171

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 (low, non-blocking) Tiny diagnostic nit: in the comma-ok form, got is statically *SchemaInfo, so if this ever failed, %T would print *main.SchemaInfo — the message could never show the actual offending type. Asserting on the interface value first keeps the diagnostic honest:

Suggested change
got, ok := convertExprToSchema(expr, &OpenAPISpec{}, "").(*SchemaInfo)
if !ok {
t.Fatalf("projection type = %T; want *SchemaInfo", got)
}
result := convertExprToSchema(expr, &OpenAPISpec{}, "")
got, ok := result.(*SchemaInfo)
if !ok {
t.Fatalf("projection type = %T; want *SchemaInfo", result)
}

if got.Type != test.wantType {
t.Fatalf("type = %q; want %q", got.Type, test.wantType)
}
if !reflect.DeepEqual(got.Enum, test.wantEnum) {
t.Fatalf("enum = %#v; want %#v", got.Enum, test.wantEnum)
}
if test.wantItemsType == "" {
if got.Items != nil {
t.Fatalf("items = %#v; want nil", got.Items)
}
return
}
items, ok := got.Items.(*SchemaInfo)
if !ok || items.Type != test.wantItemsType {
t.Fatalf("items = %#v; want type %q", got.Items, test.wantItemsType)
}
})
}
}
Loading
Loading