Skip to content
Draft
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
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,12 @@ all: tidy cuefmtcheck lintcue lintinsights test
# SCHEMA VALIDATION TESTS
#

# -count=1: the tests read the *.cue schemas at runtime, which Go's test cache
# cannot track, so cached passes can go stale against edited schemas.
test:
@echo " > Running schema validation tests ..."
@cd test && go test -v ./...
@cd test && go test -v -count=1 ./...
@cd cmd && go test -v -count=1 ./...
@echo " > Schema validation tests complete."


Expand Down
121 changes: 116 additions & 5 deletions cmd/internal/cmd/converter.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,14 @@ type OpenAPIComponents struct {
Schemas map[string]interface{} `yaml:"schemas" json:"schemas"`
}

// hiddenBase maps a hidden definition name (e.g. "_MappingStrict") to the
// visible definition it constrains (e.g. "Mapping"), so refs can be rewritten
// and the hidden definition omitted from output. Populated before parsing.
// Rewriting a ref intentionally drops the hidden definition's extra
// constraints (e.g. conditional requiredness), which OpenAPI cannot express;
// the ref collapses to the visible base type.
var hiddenBase map[string]string

type SchemaInfo struct {
Type string `yaml:"type,omitempty" json:"type,omitempty"`
Description string `yaml:"description,omitempty" json:"description,omitempty"`
Expand All @@ -48,6 +56,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"`
XStatus string `yaml:"x-status,omitempty" json:"x-status,omitempty"`
}
Expand Down Expand Up @@ -105,6 +114,28 @@ func convertCUEToOpenAPI(schemaDir, outputPath string, opts ConvertOpts) error {
seen := make(map[string]bool)
manifest := make(map[string][]string) // filename → schema names
files := insts[0].Files

// Pre-pass: map hidden definitions (#_Name) to the visible definition
// they constrain, so they can be filtered and their refs rewritten.
hiddenBase = make(map[string]string)
for _, f := range files {
for _, decl := range f.Decls {
field, ok := decl.(*ast.Field)
if !ok {
continue
}
ident, ok := field.Label.(*ast.Ident)
if !ok || !strings.HasPrefix(ident.Name, "#_") {
continue
}
base := findBaseIdent(field.Value)
if base == "" {
return fmt.Errorf("hidden definition %s does not unify with a visible definition; cue2openapi cannot rewrite refs to it", ident.Name)
}
hiddenBase[strings.TrimPrefix(ident.Name, "#")] = base
}
}

names := make([]string, 0, len(files))
byName := make(map[string]*ast.File)
for _, f := range files {
Expand Down Expand Up @@ -205,6 +236,9 @@ func parseFile(file *ast.File, spec *OpenAPISpec, seen map[string]bool, rootName
continue
}
typeName := strings.TrimPrefix(ident.Name, "#")
if _, hidden := hiddenBase[typeName]; hidden {
continue // internal validation helper; refs are rewritten to its base
}
if rootName != "" && ident.Name == "#"+rootName {
if field.Comments() != nil {
for _, cg := range field.Comments() {
Expand Down Expand Up @@ -339,6 +373,76 @@ func parseDefinitionField(field *ast.Field, spec *OpenAPISpec, fileStatus string
}
}

// findBaseIdent walks a conjunction like `{...} & #Base & {...}` and returns
// the first visible definition name it embeds or unifies with. Inside struct
// literals only embed declarations are inspected, so an attribute-only struct
// like `{@go(-)}` never counts as a base.
func findBaseIdent(expr ast.Expr) string {
switch x := expr.(type) {
case *ast.Ident:
if strings.HasPrefix(x.Name, "#") && !strings.HasPrefix(x.Name, "#_") {
return strings.TrimPrefix(x.Name, "#")
}
case *ast.BinaryExpr:
if x.Op == token.AND {
if n := findBaseIdent(x.X); n != "" {
return n
}
return findBaseIdent(x.Y)
}
case *ast.StructLit:
for _, elt := range x.Elts {
if ed, ok := elt.(*ast.EmbedDecl); ok {
if n := findBaseIdent(ed.Expr); n != "" {
return n
}
}
}
}
return ""
}

// refTarget resolves a definition reference name, substituting hidden
// definitions with the visible definition they constrain.
func refTarget(name string) string {
n := strings.TrimPrefix(name, "#")
if base, ok := hiddenBase[n]; ok {
return base
}
return n
}

// collectEnumStrings flattens a disjunction of string literals ("a" | *"b" | "c")
// into its values. Returns ok=false if any branch is not a string literal.
func collectEnumStrings(expr ast.Expr) ([]string, bool) {
switch x := expr.(type) {
case *ast.BinaryExpr:
if x.Op != token.OR {
return nil, false
}
left, ok := collectEnumStrings(x.X)
if !ok {
return nil, false
}
right, ok := collectEnumStrings(x.Y)
if !ok {
return nil, false
}
return append(left, right...), true
case *ast.UnaryExpr:
// Default marker: *"value". The default itself is deliberately not
// emitted as an OpenAPI `default`; only the enum value is kept.
if x.Op == token.MUL {
return collectEnumStrings(x.X)
}
case *ast.BasicLit:
if x.Kind == token.STRING {
return []string{strings.Trim(x.Value, "\"")}, true
}
}
return nil, false
}

func convertStructToSchema(st *ast.StructLit, spec *OpenAPISpec, description string) *SchemaInfo {
schema := &SchemaInfo{
Type: "object",
Expand All @@ -355,7 +459,13 @@ func convertStructToSchema(st *ast.StructLit, spec *OpenAPISpec, description str
fieldSchema := convertFieldToSchema(x, spec, pendingComment)
if fieldSchema != nil {
fieldName := getFieldName(x)
if fieldName != "" {
// Only an unquoted _name label is hidden in CUE; a quoted
// "_name" is a regular exported field.
hidden := false
if id, ok := x.Label.(*ast.Ident); ok {
hidden = strings.HasPrefix(id.Name, "_")
}
if fieldName != "" && !hidden {
schema.Properties[fieldName] = fieldSchema
// Check if field is required
if x.Optional == token.NoPos {
Expand Down Expand Up @@ -427,8 +537,7 @@ func convertIdentToSchema(ident *ast.Ident, spec *OpenAPISpec, description strin
return &SchemaInfo{Type: "boolean", Description: description}
} else if strings.HasPrefix(name, "#") {
// Type reference
refType := strings.TrimPrefix(name, "#")
return &SchemaInfo{Ref: fmt.Sprintf("#/components/schemas/%s", refType), Description: description}
return &SchemaInfo{Ref: fmt.Sprintf("#/components/schemas/%s", refTarget(name)), Description: description}
}

return &SchemaInfo{Type: "string", Description: description}
Expand Down Expand Up @@ -461,6 +570,9 @@ func convertBinaryExprToSchema(expr *ast.BinaryExpr, spec *OpenAPISpec, descript

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

Expand All @@ -483,12 +595,11 @@ func convertListLitToSchema(list *ast.ListLit, spec *OpenAPISpec, description st
// Also check for [#Contact, ...] pattern
if ident, ok := elt.(*ast.Ident); ok {
if strings.HasPrefix(ident.Name, "#") {
refType := strings.TrimPrefix(ident.Name, "#")
return &SchemaInfo{
Type: "array",
Description: description,
Items: &SchemaInfo{
Ref: fmt.Sprintf("#/components/schemas/%s", refType),
Ref: fmt.Sprintf("#/components/schemas/%s", refTarget(ident.Name)),
},
}
}
Expand Down
99 changes: 99 additions & 0 deletions cmd/internal/cmd/converter_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// SPDX-License-Identifier: Apache-2.0

package cmd

import (
"os"
"path/filepath"
"slices"
"strings"
"testing"

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

func TestConvertCUEToOpenAPI(t *testing.T) {
out := filepath.Join(t.TempDir(), "openapi.yaml")
if err := convertCUEToOpenAPI("../../..", out, ConvertOpts{}); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(out)
if err != nil {
t.Fatal(err)
}
var spec struct {
Components struct {
Schemas map[string]SchemaInfo `yaml:"schemas"`
} `yaml:"components"`
}
if err := yaml.Unmarshal(data, &spec); err != nil {
t.Fatal(err)
}
schemas := spec.Components.Schemas

// Disjunctions of string literals emit enum values. Expected values come
// from #MethodType (policy.cue) and #Lifecycle (collections.cue); update
// here when those CUE enums change.
if got := schemas["MethodType"].Enum; !slices.Equal(got, []string{"Behavioral", "Intent", "Remediation", "Gate"}) {
t.Errorf("MethodType enum = %v", got)
}
// "Active" is the *default in CUE; it appears here as a plain enum value.
if got := schemas["Lifecycle"].Enum; !slices.Equal(got, []string{"Active", "Draft", "Deprecated", "Retired"}) {
t.Errorf("Lifecycle enum = %v", got)
}

// Hidden definitions and hidden fields don't leak into the output.
for name, s := range schemas {
if strings.HasPrefix(name, "_") {
t.Errorf("hidden definition %s leaked into schemas", name)
}
for prop := range s.Properties {
if strings.HasPrefix(prop, "_") {
t.Errorf("hidden field %s.%s leaked into properties", name, prop)
}
}
}

// Refs to hidden definitions resolve to their visible base type
// (#_MappingStrict -> Mapping, mappingdocument.cue).
mappings, ok := schemas["MappingDocument"].Properties["mappings"].(map[string]interface{})
if !ok {
t.Fatalf("MappingDocument.mappings missing or not a map: %v", schemas["MappingDocument"].Properties["mappings"])
}
items, _ := mappings["items"].(map[string]interface{})
if ref, _ := items["$ref"].(string); ref != "#/components/schemas/Mapping" {
t.Errorf("MappingDocument.mappings items $ref = %q, want #/components/schemas/Mapping", ref)
}
if strings.Contains(string(data), "schemas/_") {
t.Errorf("ref to a hidden definition leaked into output")
}
}

func TestCollectEnumStrings(t *testing.T) {
cases := []struct {
expr string
want []string // nil: expect ok=false
}{
{`"a" | "b" | "c"`, []string{"a", "b", "c"}},
{`*"a" | "b"`, []string{"a", "b"}},
{`"a" | *"b" | "c"`, []string{"a", "b", "c"}},
{`"a" | #Other`, nil},
{`int | string`, nil},
{`"a" & "b"`, nil},
}
for _, tc := range cases {
expr, err := parser.ParseExpr("test", tc.expr)
if err != nil {
t.Fatalf("%s: %v", tc.expr, err)
}
got, ok := collectEnumStrings(expr)
if tc.want == nil {
if ok {
t.Errorf("%s: expected ok=false, got %v", tc.expr, got)
}
} else if !ok || !slices.Equal(got, tc.want) {
t.Errorf("%s: got %v (ok=%v), want %v", tc.expr, got, ok, tc.want)
}
}
}
Loading