|
1 | 1 | package main |
2 | 2 |
|
3 | 3 | import ( |
| 4 | + "encoding/json" |
4 | 5 | "fmt" |
5 | 6 | "os" |
| 7 | + "path/filepath" |
| 8 | + "sort" |
6 | 9 | "strings" |
7 | 10 |
|
8 | 11 | "cuelang.org/go/cue/ast" |
| 12 | + "cuelang.org/go/cue/load" |
9 | 13 | "cuelang.org/go/cue/token" |
10 | | - "gopkg.in/yaml.v3" |
| 14 | + "github.com/goccy/go-yaml" |
11 | 15 | ) |
12 | 16 |
|
| 17 | +// ConvertOpts configures CUE-to-OpenAPI conversion. |
| 18 | +type ConvertOpts struct { |
| 19 | + ManifestPath string // If set, write schema→file manifest JSON here |
| 20 | + Root string // Optional #Name whose comment sets Info.Description |
| 21 | + Version string // Override version (default: VERSION file or "unknown") |
| 22 | + Title string // OpenAPI info title (default: "Security Insights") |
| 23 | +} |
| 24 | + |
13 | 25 | type OpenAPISpec struct { |
14 | 26 | OpenAPI string `yaml:"openapi" json:"openapi"` |
15 | 27 | Info OpenAPIInfo `yaml:"info" json:"info"` |
@@ -37,64 +49,146 @@ type SchemaInfo struct { |
37 | 49 | Ref string `yaml:"$ref,omitempty" json:"$ref,omitempty"` |
38 | 50 | } |
39 | 51 |
|
40 | | -func readVersion() string { |
41 | | - path := "../../VERSION" |
42 | | - |
| 52 | +func readVersion(schemaDir string) string { |
| 53 | + path := filepath.Join(schemaDir, "VERSION") |
43 | 54 | if data, err := os.ReadFile(path); err == nil { |
44 | 55 | version := strings.TrimSpace(string(data)) |
45 | 56 | if version != "" { |
46 | 57 | return version |
47 | 58 | } |
48 | 59 | } |
49 | | - |
50 | | - return "unknown version" |
| 60 | + return "unknown" |
51 | 61 | } |
52 | 62 |
|
53 | | -func parseCUEToOpenAPI(file *ast.File) *OpenAPISpec { |
54 | | - version := readVersion() |
| 63 | +func convertCUEToOpenAPI(schemaDir, outputPath string, opts ConvertOpts) error { |
| 64 | + if !filepath.IsAbs(schemaDir) { |
| 65 | + wd, err := os.Getwd() |
| 66 | + if err != nil { |
| 67 | + return fmt.Errorf("failed to get working directory: %w", err) |
| 68 | + } |
| 69 | + schemaDir = filepath.Join(wd, schemaDir) |
| 70 | + } |
| 71 | + |
| 72 | + insts := load.Instances([]string{"."}, &load.Config{Dir: schemaDir}) |
| 73 | + if len(insts) == 0 || insts[0].Err != nil { |
| 74 | + err := error(nil) |
| 75 | + if len(insts) > 0 { |
| 76 | + err = insts[0].Err |
| 77 | + } |
| 78 | + return fmt.Errorf("failed to load CUE package: %v", err) |
| 79 | + } |
| 80 | + |
| 81 | + version := opts.Version |
| 82 | + if version == "" { |
| 83 | + version = readVersion(schemaDir) |
| 84 | + } |
| 85 | + title := opts.Title |
| 86 | + if title == "" { |
| 87 | + title = "Security Insights" |
| 88 | + } |
| 89 | + |
55 | 90 | spec := &OpenAPISpec{ |
56 | 91 | OpenAPI: "3.0.3", |
57 | 92 | Info: OpenAPIInfo{ |
58 | | - Title: "Security Insights Specification", |
59 | | - Version: version, |
| 93 | + Title: title, |
| 94 | + Version: version, |
| 95 | + Description: "Security Insights schema definitions", |
60 | 96 | }, |
61 | 97 | Components: OpenAPIComponents{ |
62 | 98 | Schemas: make(map[string]interface{}), |
63 | 99 | }, |
64 | 100 | } |
65 | 101 |
|
66 | | - // Extract root type description |
67 | | - var rootDescription string |
| 102 | + seen := make(map[string]bool) |
| 103 | + manifest := make(map[string][]string) // filename → schema names |
| 104 | + files := insts[0].Files |
| 105 | + names := make([]string, 0, len(files)) |
| 106 | + byName := make(map[string]*ast.File) |
| 107 | + for _, f := range files { |
| 108 | + name := "<no filename>" |
| 109 | + if f.Filename != "" { |
| 110 | + name = filepath.Base(f.Filename) |
| 111 | + } |
| 112 | + names = append(names, name) |
| 113 | + byName[name] = f |
| 114 | + } |
| 115 | + sort.Strings(names) |
| 116 | + |
| 117 | + for _, name := range names { |
| 118 | + if name == "<no filename>" { |
| 119 | + continue |
| 120 | + } |
| 121 | + f := byName[name] |
| 122 | + rootDesc, typeNames := parseFile(f, spec, seen, opts.Root) |
| 123 | + if rootDesc != "" && opts.Root != "" { |
| 124 | + spec.Info.Description = rootDesc |
| 125 | + } |
| 126 | + if len(typeNames) > 0 { |
| 127 | + manifest[name] = typeNames |
| 128 | + } |
| 129 | + } |
| 130 | + |
| 131 | + if err := writeOpenAPISpec(spec, outputPath); err != nil { |
| 132 | + return err |
| 133 | + } |
| 134 | + if opts.ManifestPath != "" { |
| 135 | + if err := writeManifest(manifest, opts.ManifestPath); err != nil { |
| 136 | + return err |
| 137 | + } |
| 138 | + } |
| 139 | + return nil |
| 140 | +} |
| 141 | + |
| 142 | +func writeManifest(manifest map[string][]string, path string) error { |
| 143 | + keys := make([]string, 0, len(manifest)) |
| 144 | + for k := range manifest { |
| 145 | + keys = append(keys, k) |
| 146 | + } |
| 147 | + sort.Strings(keys) |
| 148 | + ordered := make(map[string][]string, len(manifest)) |
| 149 | + for _, k := range keys { |
| 150 | + ordered[k] = manifest[k] |
| 151 | + } |
| 152 | + data, err := json.MarshalIndent(ordered, "", " ") |
| 153 | + if err != nil { |
| 154 | + return fmt.Errorf("marshal manifest: %w", err) |
| 155 | + } |
| 156 | + return os.WriteFile(path, data, 0644) |
| 157 | +} |
| 158 | + |
| 159 | +// parseFile processes a single CUE file and merges definitions into spec. |
| 160 | +// It returns the root type description (if opts.Root matches) and the list of type names added. |
| 161 | +func parseFile(file *ast.File, spec *OpenAPISpec, seen map[string]bool, rootName string) (rootDescription string, typeNames []string) { |
68 | 162 | for _, decl := range file.Decls { |
69 | | - if field, ok := decl.(*ast.Field); ok { |
70 | | - if ident, ok := field.Label.(*ast.Ident); ok && ident.Name == "#SecurityInsights" { |
71 | | - if field.Comments() != nil { |
72 | | - for _, cg := range field.Comments() { |
73 | | - for _, c := range cg.List { |
74 | | - if c.Text != "" { |
75 | | - rootDescription = extractComment(c.Text) |
76 | | - break |
77 | | - } |
| 163 | + field, ok := decl.(*ast.Field) |
| 164 | + if !ok { |
| 165 | + continue |
| 166 | + } |
| 167 | + ident, ok := field.Label.(*ast.Ident) |
| 168 | + if !ok || !strings.HasPrefix(ident.Name, "#") { |
| 169 | + continue |
| 170 | + } |
| 171 | + typeName := strings.TrimPrefix(ident.Name, "#") |
| 172 | + if rootName != "" && ident.Name == "#"+rootName { |
| 173 | + if field.Comments() != nil { |
| 174 | + for _, cg := range field.Comments() { |
| 175 | + for _, c := range cg.List { |
| 176 | + if c.Text != "" { |
| 177 | + rootDescription = extractComment(c.Text) |
| 178 | + break |
78 | 179 | } |
79 | 180 | } |
80 | 181 | } |
81 | | - if rootDescription != "" { |
82 | | - spec.Info.Description = rootDescription |
83 | | - } |
84 | | - break |
85 | 182 | } |
86 | 183 | } |
87 | | - } |
88 | | - |
89 | | - // Walk through all declarations to find type definitions |
90 | | - for _, decl := range file.Decls { |
91 | | - switch x := decl.(type) { |
92 | | - case *ast.Field: |
93 | | - parseDefinitionField(x, spec) |
| 184 | + if seen[typeName] { |
| 185 | + continue |
94 | 186 | } |
| 187 | + seen[typeName] = true |
| 188 | + parseDefinitionField(field, spec) |
| 189 | + typeNames = append(typeNames, typeName) |
95 | 190 | } |
96 | | - |
97 | | - return spec |
| 191 | + return rootDescription, typeNames |
98 | 192 | } |
99 | 193 |
|
100 | 194 | func parseDefinitionField(field *ast.Field, spec *OpenAPISpec) { |
|
0 commit comments