mirror of
https://github.com/anchore/syft.git
synced 2026-11-01 15:06:36 +01:00
migrate json schema generation (#4270)
Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
Please see [schema/json/README.md](../../schema/json/README.md) for more information on the JSON schema files in this directory.
|
||||
@@ -0,0 +1,159 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/invopop/jsonschema"
|
||||
)
|
||||
|
||||
func copyAliasFieldComments(commentMap map[string]string, repoRoot string) {
|
||||
// find all type aliases by parsing Go source files
|
||||
aliases := findTypeAliases(repoRoot)
|
||||
|
||||
// for each alias, copy field comments from the source type
|
||||
for aliasName, sourceName := range aliases {
|
||||
// find all field comments for the source type
|
||||
for key, comment := range commentMap {
|
||||
// check if this is a field comment for the source type
|
||||
// format: "github.com/anchore/syft/syft/pkg.SourceType.FieldName"
|
||||
if strings.Contains(key, "."+sourceName+".") {
|
||||
// create the corresponding key for the alias
|
||||
aliasKey := strings.Replace(key, "."+sourceName+".", "."+aliasName+".", 1)
|
||||
commentMap[aliasKey] = comment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func findTypeAliases(repoRoot string) map[string]string {
|
||||
aliases := make(map[string]string)
|
||||
fset := token.NewFileSet()
|
||||
|
||||
// walk through all Go files in the repo
|
||||
err := filepath.Walk(repoRoot, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() || !strings.HasSuffix(path, ".go") {
|
||||
return nil
|
||||
}
|
||||
|
||||
// parse the file
|
||||
file, err := parser.ParseFile(fset, path, nil, parser.ParseComments)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// look for type alias declarations
|
||||
ast.Inspect(file, func(n ast.Node) bool {
|
||||
typeSpec, ok := n.(*ast.TypeSpec)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
|
||||
// check if this is a type alias (e.g., type A B where B is an identifier)
|
||||
ident, ok := typeSpec.Type.(*ast.Ident)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
|
||||
// store the alias mapping: aliasName -> sourceName
|
||||
aliases[typeSpec.Name.Name] = ident.Name
|
||||
return true
|
||||
})
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: failed to find type aliases: %v\n", err)
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return aliases
|
||||
}
|
||||
|
||||
func hasDescriptionInAlternatives(schema *jsonschema.Schema) bool {
|
||||
// check oneOf alternatives
|
||||
for _, alt := range schema.OneOf {
|
||||
if alt.Description != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// check anyOf alternatives
|
||||
for _, alt := range schema.AnyOf {
|
||||
if alt.Description != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func warnMissingDescriptions(schema *jsonschema.Schema, metadataNames []string) { //nolint:gocognit
|
||||
var missingTypeDescriptions []string
|
||||
var missingFieldDescriptions []string
|
||||
|
||||
// check metadata types for missing descriptions
|
||||
for _, name := range metadataNames {
|
||||
def, ok := schema.Definitions[name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// check if type has a description
|
||||
if def.Description == "" {
|
||||
missingTypeDescriptions = append(missingTypeDescriptions, name)
|
||||
}
|
||||
|
||||
// check if fields have descriptions
|
||||
if def.Properties != nil {
|
||||
for _, fieldName := range def.Properties.Keys() {
|
||||
fieldSchemaRaw, _ := def.Properties.Get(fieldName)
|
||||
fieldSchema, ok := fieldSchemaRaw.(*jsonschema.Schema)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// skip if field has a description
|
||||
if fieldSchema.Description != "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// skip if field is a reference (descriptions come from the referenced type)
|
||||
if fieldSchema.Ref != "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// skip if field is an array/object with items that are references
|
||||
if fieldSchema.Items != nil && fieldSchema.Items.Ref != "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// skip if field uses oneOf/anyOf with descriptions in the alternatives
|
||||
if hasDescriptionInAlternatives(fieldSchema) {
|
||||
continue
|
||||
}
|
||||
|
||||
missingFieldDescriptions = append(missingFieldDescriptions, fmt.Sprintf("%s.%s", name, fieldName))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// report findings
|
||||
if len(missingTypeDescriptions) > 0 {
|
||||
fmt.Fprintf(os.Stderr, "\nwarning: %d metadata types are missing descriptions:\n", len(missingTypeDescriptions))
|
||||
for _, name := range missingTypeDescriptions {
|
||||
fmt.Fprintf(os.Stderr, " - %s\n", name)
|
||||
}
|
||||
}
|
||||
|
||||
if len(missingFieldDescriptions) > 0 {
|
||||
fmt.Fprintf(os.Stderr, "\nwarning: %d fields are missing descriptions:\n", len(missingFieldDescriptions))
|
||||
for _, field := range missingFieldDescriptions {
|
||||
fmt.Fprintf(os.Stderr, " - %s\n", field)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/iancoleman/orderedmap"
|
||||
"github.com/invopop/jsonschema"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestCopyAliasFieldComments verifies that field comments from source types are correctly copied to alias types.
|
||||
// This is important for type aliases like `type RpmArchive RpmDBEntry` where the alias should inherit all field descriptions.
|
||||
func TestCopyAliasFieldComments(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
commentMap map[string]string
|
||||
aliases map[string]string
|
||||
wantComments map[string]string
|
||||
}{
|
||||
{
|
||||
name: "copies field comments from source type to alias",
|
||||
commentMap: map[string]string{
|
||||
"github.com/anchore/syft/syft/pkg.RpmDBEntry": "RpmDBEntry represents all captured data from a RPM DB package entry.",
|
||||
"github.com/anchore/syft/syft/pkg.RpmDBEntry.Name": "Name is the RPM package name.",
|
||||
"github.com/anchore/syft/syft/pkg.RpmDBEntry.Epoch": "Epoch is the version epoch.",
|
||||
},
|
||||
aliases: map[string]string{
|
||||
"RpmArchive": "RpmDBEntry",
|
||||
},
|
||||
wantComments: map[string]string{
|
||||
"github.com/anchore/syft/syft/pkg.RpmDBEntry": "RpmDBEntry represents all captured data from a RPM DB package entry.",
|
||||
"github.com/anchore/syft/syft/pkg.RpmDBEntry.Name": "Name is the RPM package name.",
|
||||
"github.com/anchore/syft/syft/pkg.RpmDBEntry.Epoch": "Epoch is the version epoch.",
|
||||
"github.com/anchore/syft/syft/pkg.RpmArchive.Name": "Name is the RPM package name.",
|
||||
"github.com/anchore/syft/syft/pkg.RpmArchive.Epoch": "Epoch is the version epoch.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "handles multiple aliases",
|
||||
commentMap: map[string]string{
|
||||
"github.com/anchore/syft/syft/pkg.DpkgDBEntry": "DpkgDBEntry represents data from dpkg.",
|
||||
"github.com/anchore/syft/syft/pkg.DpkgDBEntry.Package": "Package is the package name.",
|
||||
"github.com/anchore/syft/syft/pkg.DpkgDBEntry.Architecture": "Architecture is the target arch.",
|
||||
},
|
||||
aliases: map[string]string{
|
||||
"DpkgArchiveEntry": "DpkgDBEntry",
|
||||
"DpkgSnapshot": "DpkgDBEntry",
|
||||
},
|
||||
wantComments: map[string]string{
|
||||
"github.com/anchore/syft/syft/pkg.DpkgDBEntry": "DpkgDBEntry represents data from dpkg.",
|
||||
"github.com/anchore/syft/syft/pkg.DpkgDBEntry.Package": "Package is the package name.",
|
||||
"github.com/anchore/syft/syft/pkg.DpkgDBEntry.Architecture": "Architecture is the target arch.",
|
||||
"github.com/anchore/syft/syft/pkg.DpkgArchiveEntry.Package": "Package is the package name.",
|
||||
"github.com/anchore/syft/syft/pkg.DpkgArchiveEntry.Architecture": "Architecture is the target arch.",
|
||||
"github.com/anchore/syft/syft/pkg.DpkgSnapshot.Package": "Package is the package name.",
|
||||
"github.com/anchore/syft/syft/pkg.DpkgSnapshot.Architecture": "Architecture is the target arch.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "does not copy non-field comments",
|
||||
commentMap: map[string]string{
|
||||
"github.com/anchore/syft/syft/pkg.SomeType": "SomeType struct comment.",
|
||||
"github.com/anchore/syft/syft/pkg.SomeType.Field": "Field comment.",
|
||||
},
|
||||
aliases: map[string]string{
|
||||
"AliasType": "SomeType",
|
||||
},
|
||||
wantComments: map[string]string{
|
||||
"github.com/anchore/syft/syft/pkg.SomeType": "SomeType struct comment.",
|
||||
"github.com/anchore/syft/syft/pkg.SomeType.Field": "Field comment.",
|
||||
"github.com/anchore/syft/syft/pkg.AliasType.Field": "Field comment.",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// create temp dir for testing
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// create a test go file with type aliases
|
||||
testFile := filepath.Join(tmpDir, "test.go")
|
||||
content := "package test\n\n"
|
||||
for alias, source := range tt.aliases {
|
||||
content += "type " + alias + " " + source + "\n"
|
||||
}
|
||||
err := os.WriteFile(testFile, []byte(content), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
// make a copy of the comment map since the function modifies it
|
||||
commentMap := make(map[string]string)
|
||||
for k, v := range tt.commentMap {
|
||||
commentMap[k] = v
|
||||
}
|
||||
|
||||
// run the function
|
||||
copyAliasFieldComments(commentMap, tmpDir)
|
||||
|
||||
// verify results
|
||||
assert.Equal(t, tt.wantComments, commentMap)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindTypeAliases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
fileContent string
|
||||
wantAliases map[string]string
|
||||
}{
|
||||
{
|
||||
name: "finds simple type alias",
|
||||
fileContent: `package test
|
||||
|
||||
type RpmArchive RpmDBEntry
|
||||
type DpkgArchiveEntry DpkgDBEntry
|
||||
`,
|
||||
wantAliases: map[string]string{
|
||||
"RpmArchive": "RpmDBEntry",
|
||||
"DpkgArchiveEntry": "DpkgDBEntry",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ignores struct definitions",
|
||||
fileContent: `package test
|
||||
|
||||
type MyStruct struct {
|
||||
Field string
|
||||
}
|
||||
|
||||
type AliasType BaseType
|
||||
`,
|
||||
wantAliases: map[string]string{
|
||||
"AliasType": "BaseType",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ignores interface definitions",
|
||||
fileContent: `package test
|
||||
|
||||
type MyInterface interface {
|
||||
Method()
|
||||
}
|
||||
|
||||
type AliasType BaseType
|
||||
`,
|
||||
wantAliases: map[string]string{
|
||||
"AliasType": "BaseType",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "handles multiple files",
|
||||
fileContent: `package test
|
||||
|
||||
type Alias1 Base1
|
||||
type Alias2 Base2
|
||||
`,
|
||||
wantAliases: map[string]string{
|
||||
"Alias1": "Base1",
|
||||
"Alias2": "Base2",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// create temp dir
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// write test file
|
||||
testFile := filepath.Join(tmpDir, "test.go")
|
||||
err := os.WriteFile(testFile, []byte(tt.fileContent), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
// run function
|
||||
aliases := findTypeAliases(tmpDir)
|
||||
|
||||
// verify
|
||||
assert.Equal(t, tt.wantAliases, aliases)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasDescriptionInAlternatives(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
schema *jsonschema.Schema
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "returns true when oneOf has description",
|
||||
schema: &jsonschema.Schema{
|
||||
OneOf: []*jsonschema.Schema{
|
||||
{Description: "First alternative"},
|
||||
{Type: "null"},
|
||||
},
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "returns true when anyOf has description",
|
||||
schema: &jsonschema.Schema{
|
||||
AnyOf: []*jsonschema.Schema{
|
||||
{Description: "First alternative"},
|
||||
{Type: "null"},
|
||||
},
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "returns false when no alternatives have descriptions",
|
||||
schema: &jsonschema.Schema{
|
||||
OneOf: []*jsonschema.Schema{
|
||||
{Type: "integer"},
|
||||
{Type: "null"},
|
||||
},
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "returns false when no oneOf or anyOf",
|
||||
schema: &jsonschema.Schema{
|
||||
Type: "string",
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "returns true when any alternative in oneOf has description",
|
||||
schema: &jsonschema.Schema{
|
||||
OneOf: []*jsonschema.Schema{
|
||||
{Type: "integer"},
|
||||
{Type: "string", Description: "Second alternative"},
|
||||
{Type: "null"},
|
||||
},
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := hasDescriptionInAlternatives(tt.schema)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWarnMissingDescriptions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
schema *jsonschema.Schema
|
||||
metadataNames []string
|
||||
wantTypeWarnings int
|
||||
wantFieldWarnings int
|
||||
}{
|
||||
{
|
||||
name: "no warnings when all types have descriptions",
|
||||
schema: &jsonschema.Schema{
|
||||
Definitions: map[string]*jsonschema.Schema{
|
||||
"TypeA": {
|
||||
Description: "Type A description",
|
||||
Properties: newOrderedMap(map[string]*jsonschema.Schema{
|
||||
"field1": {Type: "string", Description: "Field 1"},
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
metadataNames: []string{"TypeA"},
|
||||
wantTypeWarnings: 0,
|
||||
wantFieldWarnings: 0,
|
||||
},
|
||||
{
|
||||
name: "warns about missing type description",
|
||||
schema: &jsonschema.Schema{
|
||||
Definitions: map[string]*jsonschema.Schema{
|
||||
"TypeA": {
|
||||
Properties: newOrderedMap(map[string]*jsonschema.Schema{
|
||||
"field1": {Type: "string", Description: "Field 1"},
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
metadataNames: []string{"TypeA"},
|
||||
wantTypeWarnings: 1,
|
||||
wantFieldWarnings: 0,
|
||||
},
|
||||
{
|
||||
name: "warns about missing field description",
|
||||
schema: &jsonschema.Schema{
|
||||
Definitions: map[string]*jsonschema.Schema{
|
||||
"TypeA": {
|
||||
Description: "Type A description",
|
||||
Properties: newOrderedMap(map[string]*jsonschema.Schema{
|
||||
"field1": {Type: "string"},
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
metadataNames: []string{"TypeA"},
|
||||
wantTypeWarnings: 0,
|
||||
wantFieldWarnings: 1,
|
||||
},
|
||||
{
|
||||
name: "skips fields with references",
|
||||
schema: &jsonschema.Schema{
|
||||
Definitions: map[string]*jsonschema.Schema{
|
||||
"TypeA": {
|
||||
Description: "Type A description",
|
||||
Properties: newOrderedMap(map[string]*jsonschema.Schema{
|
||||
"field1": {Ref: "#/$defs/OtherType"},
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
metadataNames: []string{"TypeA"},
|
||||
wantTypeWarnings: 0,
|
||||
wantFieldWarnings: 0,
|
||||
},
|
||||
{
|
||||
name: "skips fields with items that are references",
|
||||
schema: &jsonschema.Schema{
|
||||
Definitions: map[string]*jsonschema.Schema{
|
||||
"TypeA": {
|
||||
Description: "Type A description",
|
||||
Properties: newOrderedMap(map[string]*jsonschema.Schema{
|
||||
"field1": {
|
||||
Type: "array",
|
||||
Items: &jsonschema.Schema{Ref: "#/$defs/OtherType"},
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
metadataNames: []string{"TypeA"},
|
||||
wantTypeWarnings: 0,
|
||||
wantFieldWarnings: 0,
|
||||
},
|
||||
{
|
||||
name: "skips fields with oneOf containing descriptions",
|
||||
schema: &jsonschema.Schema{
|
||||
Definitions: map[string]*jsonschema.Schema{
|
||||
"TypeA": {
|
||||
Description: "Type A description",
|
||||
Properties: newOrderedMap(map[string]*jsonschema.Schema{
|
||||
"field1": {
|
||||
OneOf: []*jsonschema.Schema{
|
||||
{Type: "integer", Description: "Integer value"},
|
||||
{Type: "null"},
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
metadataNames: []string{"TypeA"},
|
||||
wantTypeWarnings: 0,
|
||||
wantFieldWarnings: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// capture stderr output would require more complex testing
|
||||
// for now, just verify the function runs without panicking
|
||||
require.NotPanics(t, func() {
|
||||
warnMissingDescriptions(tt.schema, tt.metadataNames)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// helper to create an ordered map from a regular map
|
||||
func newOrderedMap(m map[string]*jsonschema.Schema) *orderedmap.OrderedMap {
|
||||
om := orderedmap.New()
|
||||
for k, v := range m {
|
||||
om.Set(k, v)
|
||||
}
|
||||
return om
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/iancoleman/strcase"
|
||||
"github.com/invopop/jsonschema"
|
||||
|
||||
"github.com/anchore/syft/internal"
|
||||
"github.com/anchore/syft/internal/packagemetadata"
|
||||
syftJsonModel "github.com/anchore/syft/syft/format/syftjson/model"
|
||||
)
|
||||
|
||||
/*
|
||||
This method of creating the JSON schema only captures strongly typed fields for the purpose of integrations between syft
|
||||
JSON output and integrations. The downside to this approach is that any values and types used on weakly typed fields
|
||||
are not captured (empty interfaces). This means that pkg.Package.Metadata is not validated at this time. This approach
|
||||
can be extended to include specific package metadata struct shapes in the future.
|
||||
*/
|
||||
|
||||
var repoRoot string
|
||||
|
||||
func init() {
|
||||
var err error
|
||||
repoRoot, err = packagemetadata.RepoRoot()
|
||||
if err != nil {
|
||||
fmt.Println("unable to determine repo root")
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
write(encode(build()))
|
||||
}
|
||||
|
||||
func schemaID() jsonschema.ID {
|
||||
// Today we do not host the schemas at this address, but per the JSON schema spec we should be referencing
|
||||
// the schema by a URL in a domain we control. This is a placeholder for now.
|
||||
return jsonschema.ID(fmt.Sprintf("anchore.io/schema/syft/json/%s", internal.JSONSchemaVersion))
|
||||
}
|
||||
|
||||
func assembleTypeContainer(items []any) (any, map[string]string) {
|
||||
structFields := make([]reflect.StructField, len(items))
|
||||
mapping := make(map[string]string, len(items))
|
||||
typesMissingNames := make([]reflect.Type, 0)
|
||||
for i, item := range items {
|
||||
itemType := reflect.TypeOf(item)
|
||||
|
||||
jsonName := packagemetadata.JSONName(item)
|
||||
fieldName := strcase.ToCamel(jsonName)
|
||||
|
||||
if jsonName == "" {
|
||||
typesMissingNames = append(typesMissingNames, itemType)
|
||||
continue
|
||||
}
|
||||
|
||||
mapping[itemType.Name()] = fieldName
|
||||
|
||||
structFields[i] = reflect.StructField{
|
||||
Name: fieldName,
|
||||
Type: itemType,
|
||||
}
|
||||
}
|
||||
|
||||
if len(typesMissingNames) > 0 {
|
||||
fmt.Println("the following types are missing JSON names (manually curated in ./internal/packagemetadata/names.go):")
|
||||
for _, t := range typesMissingNames {
|
||||
fmt.Println(" - ", t.Name())
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
structType := reflect.StructOf(structFields)
|
||||
return reflect.New(structType).Elem().Interface(), mapping
|
||||
}
|
||||
|
||||
func build() *jsonschema.Schema {
|
||||
reflector := &jsonschema.Reflector{
|
||||
BaseSchemaID: schemaID(),
|
||||
AllowAdditionalProperties: true,
|
||||
Namer: func(r reflect.Type) string {
|
||||
return strings.TrimPrefix(r.Name(), "JSON")
|
||||
},
|
||||
CommentMap: make(map[string]string),
|
||||
}
|
||||
|
||||
// extract comments from Go source files to enrich schema descriptions
|
||||
//
|
||||
// note: AddGoComments parses from the module root and creates keys like "syft/pkg.TypeName",
|
||||
// but the reflector expects fully qualified paths like "github.com/anchore/syft/syft/pkg.TypeName".
|
||||
// We fix up the keys after extraction to match the expected format.
|
||||
if err := reflector.AddGoComments("github.com/anchore/syft", repoRoot); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: failed to extract Go comments: %v\n", err)
|
||||
} else {
|
||||
// fix up comment map keys to use fully qualified import paths
|
||||
// note: AddGoComments includes the absolute repo path WITHOUT the leading slash
|
||||
repoRootNoSlash := strings.TrimPrefix(repoRoot, "/")
|
||||
fixedMap := make(map[string]string)
|
||||
for k, v := range reflector.CommentMap {
|
||||
newKey := k
|
||||
if !strings.HasPrefix(k, "github.com/") {
|
||||
// key doesn't have module prefix, add it
|
||||
newKey = "github.com/anchore/syft/" + k
|
||||
} else if strings.Contains(k, repoRootNoSlash) {
|
||||
// key has the absolute repo path embedded, strip it
|
||||
// format: github.com/anchore/syft/Users/wagoodman/code/syft-manual/syft/pkg.Type
|
||||
// should be: github.com/anchore/syft/syft/pkg.Type
|
||||
newKey = strings.Replace(k, repoRootNoSlash+"/", "", 1)
|
||||
}
|
||||
fixedMap[newKey] = v
|
||||
}
|
||||
reflector.CommentMap = fixedMap
|
||||
|
||||
// copy field comments for type aliases (e.g., type RpmArchive RpmDBEntry)
|
||||
copyAliasFieldComments(reflector.CommentMap, repoRoot)
|
||||
}
|
||||
|
||||
pkgMetadataContainer, pkgMetadataMapping := assembleTypeContainer(packagemetadata.AllTypes())
|
||||
pkgMetadataContainerType := reflect.TypeOf(pkgMetadataContainer)
|
||||
|
||||
// srcMetadataContainer := assembleTypeContainer(sourcemetadata.AllTypes())
|
||||
// srcMetadataContainerType := reflect.TypeOf(srcMetadataContainer)
|
||||
|
||||
documentSchema := reflector.ReflectFromType(reflect.TypeOf(&syftJsonModel.Document{}))
|
||||
pkgMetadataSchema := reflector.ReflectFromType(reflect.TypeOf(pkgMetadataContainer))
|
||||
// srcMetadataSchema := reflector.ReflectFromType(reflect.TypeOf(srcMetadataContainer))
|
||||
|
||||
// TODO: add source metadata types
|
||||
|
||||
// inject the definitions of all packages metadata into the schema definitions
|
||||
|
||||
var metadataNames []string
|
||||
for typeName, definition := range pkgMetadataSchema.Definitions {
|
||||
if typeName == pkgMetadataContainerType.Name() {
|
||||
// ignore the definition for the fake container
|
||||
continue
|
||||
}
|
||||
|
||||
displayName, ok := pkgMetadataMapping[typeName]
|
||||
if ok {
|
||||
// this is a package metadata type...
|
||||
documentSchema.Definitions[displayName] = definition
|
||||
metadataNames = append(metadataNames, displayName)
|
||||
} else {
|
||||
// this is a type that the metadata type uses (e.g. DpkgFileRecord)
|
||||
documentSchema.Definitions[typeName] = definition
|
||||
}
|
||||
}
|
||||
|
||||
// ensure the generated list of names is stable between runs
|
||||
sort.Strings(metadataNames)
|
||||
|
||||
metadataTypes := []map[string]string{
|
||||
// allow for no metadata to be provided
|
||||
{"type": "null"},
|
||||
}
|
||||
for _, name := range metadataNames {
|
||||
metadataTypes = append(metadataTypes, map[string]string{
|
||||
"$ref": fmt.Sprintf("#/$defs/%s", name),
|
||||
})
|
||||
}
|
||||
|
||||
// set the "anyOf" field for Package.Metadata to be a conjunction of several types
|
||||
documentSchema.Definitions["Package"].Properties.Set("metadata", map[string][]map[string]string{
|
||||
"anyOf": metadataTypes,
|
||||
})
|
||||
|
||||
// warn about missing descriptions
|
||||
warnMissingDescriptions(documentSchema, metadataNames)
|
||||
|
||||
return documentSchema
|
||||
}
|
||||
|
||||
func encode(schema *jsonschema.Schema) []byte {
|
||||
newSchemaBuffer := new(bytes.Buffer)
|
||||
enc := json.NewEncoder(newSchemaBuffer)
|
||||
// prevent > and < from being escaped in the payload
|
||||
enc.SetEscapeHTML(false)
|
||||
enc.SetIndent("", " ")
|
||||
err := enc.Encode(&schema)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return newSchemaBuffer.Bytes()
|
||||
}
|
||||
|
||||
func write(schema []byte) {
|
||||
schemaPath := filepath.Join(repoRoot, "schema", "json", fmt.Sprintf("schema-%s.json", internal.JSONSchemaVersion))
|
||||
latestSchemaPath := filepath.Join(repoRoot, "schema", "json", "schema-latest.json")
|
||||
|
||||
if _, err := os.Stat(schemaPath); !os.IsNotExist(err) {
|
||||
// check if the schema is the same...
|
||||
existingFh, err := os.Open(schemaPath)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
existingSchemaBytes, err := io.ReadAll(existingFh)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if bytes.Equal(existingSchemaBytes, schema) {
|
||||
// the generated schema is the same, bail with no error :)
|
||||
fmt.Println("No change to the existing schema!")
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// the generated schema is different, bail with error :(
|
||||
fmt.Printf("Cowardly refusing to overwrite existing schema (%s)!\nSee the schema/json/README.md for how to increment\n", schemaPath)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fh, err := os.Create(schemaPath)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer fh.Close()
|
||||
|
||||
_, err = fh.Write(schema)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
latestFile, err := os.Create(latestSchemaPath)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer latestFile.Close()
|
||||
|
||||
_, err = latestFile.Write(schema)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
fmt.Printf("Wrote new schema to %q\n", schemaPath)
|
||||
}
|
||||
Reference in New Issue
Block a user