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
146 changes: 146 additions & 0 deletions metadata/ctipackage/examples.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package ctipackage

import (
"fmt"
"os"
"path"
"strings"

"github.com/acronis/go-cti/metadata"
cmetadata "github.com/acronis/go-cti/metadata/collector/ctimetadata"
cramlx "github.com/acronis/go-cti/metadata/collector/ramlx"
"github.com/acronis/go-cti/metadata/registry"
"github.com/acronis/go-cti/metadata/validator"
"github.com/acronis/go-raml/v2"
)

func (pkg *Package) generateExamplesRAML() string {
var sb strings.Builder
sb.WriteString("#%RAML 1.0 Library\nuses:")
for i, example := range pkg.Index.Examples {
if strings.HasSuffix(example, RAMLExt) {
sb.WriteString(fmt.Sprintf("\n x%d: %s", i+1, example))
}
}
return sb.String()
}

func (pkg *Package) parseExamplesRAML() (*registry.MetadataRegistry, error) {
r, err := raml.ParseFromString(pkg.generateExamplesRAML(), "index_examples.raml", pkg.BaseDir, raml.OptWithValidate())
if err != nil {
return nil, fmt.Errorf("parse index_examples.raml: %w", err)
}
c, err := cramlx.NewRAMLXCollector(r)
if err != nil {
return nil, fmt.Errorf("create ramlx collector: %w", err)
}
return c.Collect()
}

func (pkg *Package) parseExamplesCTIMetadata() (*registry.MetadataRegistry, error) {
fragments := make(map[string][]byte, len(pkg.Index.Examples))
for _, example := range pkg.Index.Examples {
if !strings.HasSuffix(example, YAMLExt) {
continue
}
b, err := os.ReadFile(path.Join(pkg.BaseDir, example))
if err != nil {
return nil, fmt.Errorf("read example %s: %w", example, err)
}
fragments[example] = b
}
return cmetadata.NewCTIMetadataCollector(fragments, pkg.BaseDir).Collect()
}

// ParseExamples parses all example files listed in Index.Examples into ExamplesRegistry.
// It implicitly calls Parse if the package has not been parsed yet.
// Returns an error if any example CTI expression collides with an entity in GlobalRegistry.
func (pkg *Package) ParseExamples() error {
if !pkg.Parsed {
if err := pkg.Parse(); err != nil {
return fmt.Errorf("parse package: %w", err)
}
}

if len(pkg.Index.Examples) == 0 {
return nil
}

examplesRAMLReg, err := pkg.parseExamplesRAML()
if err != nil {
return fmt.Errorf("parse examples RAML: %w", err)
}

examplesYAMLReg, err := pkg.parseExamplesCTIMetadata()
if err != nil {
return fmt.Errorf("parse examples CTI metadata: %w", err)
}

if err = examplesRAMLReg.CopyFrom(examplesYAMLReg); err != nil {
return fmt.Errorf("merge examples registries: %w", err)
}

for ctiExpr := range examplesRAMLReg.Index {
if _, exists := pkg.GlobalRegistry.Index[ctiExpr]; exists {
return fmt.Errorf("example entity %q collides with an existing entity in the package", ctiExpr)
}
}

pkg.ExamplesRegistry = examplesRAMLReg
return nil
}

// ValidateExamples validates all example entities.
// It implicitly calls ParseExamples if the ExamplesRegistry has not been populated yet.
// Example entities are validated against a context that includes the package's GlobalRegistry
// and the ExamplesRegistry itself, so examples can reference each other.
// Optional ValidatorOptions (e.g. custom type/instance rules) are forwarded to the validator.
func (pkg *Package) ValidateExamples(opts ...validator.ValidatorOption) error {
if pkg.ExamplesRegistry == nil {
if err := pkg.ParseExamples(); err != nil {
return fmt.Errorf("parse examples: %w", err)
}
}

if pkg.ExamplesRegistry == nil || len(pkg.ExamplesRegistry.Index) == 0 {
return nil
}

contextReg := registry.New()
if err := contextReg.CopyFrom(pkg.GlobalRegistry); err != nil {
return fmt.Errorf("copy global registry into context: %w", err)
}
if err := contextReg.CopyFrom(pkg.ExamplesRegistry); err != nil {
return fmt.Errorf("copy examples registry into context: %w", err)
}

// Link example entities to their parent types. Parse() runs transformer.Transform()
// on GlobalRegistry, but ExamplesRegistry is built afterwards, so parent pointers
// on example entities are never set by the transformer. We set them here manually.
for ctiExpr, entity := range pkg.ExamplesRegistry.Index {
parentID := metadata.GetParentCTI(ctiExpr)
if parentID == "" {
continue
}
parent, ok := contextReg.Types[parentID]
if !ok {
return fmt.Errorf("parent type %s not found for example entity %s", parentID, ctiExpr)
}
if err := entity.SetParent(parent); err != nil {
return fmt.Errorf("set parent for example entity %s: %w", ctiExpr, err)
}
}

v, err := validator.New(pkg.Index.Vendor, pkg.Index.Pkg, contextReg, pkg.ExamplesRegistry, append(opts, validator.WithSkipOwnershipCheck())...)
if err != nil {
return fmt.Errorf("create validator: %w", err)
}

if pass, err := v.ValidateAll(); err != nil {
if !pass {
return fmt.Errorf("validate examples: %w", err)
}
}

return nil
}
212 changes: 212 additions & 0 deletions metadata/ctipackage/examples_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
package ctipackage

import (
"strings"
"testing"

"github.com/stretchr/testify/require"

"github.com/acronis/go-cti/metadata/testsupp"
)

func newExamplePkg(t *testing.T, name string, tc testsupp.PackageTestCase, examples []string) *Package {
t.Helper()
testDir := testsupp.InitTestPackageFiles(t, name, tc)
pkg, err := New(testDir,
WithRamlxVersion("1.0"),
WithID(tc.PkgId),
WithEntities(tc.Entities),
WithExamples(examples),
)
require.NoError(t, err)
require.NoError(t, pkg.Initialize())
require.NoError(t, pkg.Read())
return pkg
}

func Test_ParseExamples_Empty(t *testing.T) {
pkg := newExamplePkg(t, "examples_empty", testsupp.PackageTestCase{
PkgId: "x.y",
Files: map[string]string{},
}, nil)

require.NoError(t, pkg.Parse())
require.NoError(t, pkg.ParseExamples())
require.Nil(t, pkg.ExamplesRegistry)
}

func Test_ParseExamples_RAML(t *testing.T) {
tc := testsupp.PackageTestCase{
PkgId: "x.y",
Entities: []string{"entities/entity.raml"},
Files: map[string]string{
"entities/entity.raml": strings.TrimSpace(`
#%RAML 1.0 Library

uses:
cti: ../.ramlx/cti.raml

types:
SampleEntity:
(cti.cti): cti.x.y.sample_entity.v1.0
(cti.final): false
properties:
name: string
`),
"examples/example.raml": strings.TrimSpace(`
#%RAML 1.0 Library

uses:
cti: ../.ramlx/cti.raml

types:
SampleEntityExample:
(cti.cti): cti.x.y.sample_entity_example.v1.0
(cti.final): false
properties:
name: string
`),
},
}

pkg := newExamplePkg(t, "examples_raml", tc, []string{"examples/example.raml"})

require.NoError(t, pkg.ParseExamples())
require.NotNil(t, pkg.ExamplesRegistry)
require.Len(t, pkg.ExamplesRegistry.Index, 1)
require.Contains(t, pkg.ExamplesRegistry.Types, "cti.x.y.sample_entity_example.v1.0")
}

func Test_ParseExamples_YAML(t *testing.T) {
tc := testsupp.PackageTestCase{
PkgId: "x.y",
Files: map[string]string{
"examples/example_type.yaml": strings.TrimSpace(`
#%CTI Type 1.0
cti: cti.x.y.yaml_example.v1.0
final: false
access: public
schema:
$schema: http://json-schema.org/draft-07/schema#
type: object
properties:
name:
type: string
`),
},
}

pkg := newExamplePkg(t, "examples_yaml", tc, []string{"examples/example_type.yaml"})

require.NoError(t, pkg.ParseExamples())
require.NotNil(t, pkg.ExamplesRegistry)
require.Len(t, pkg.ExamplesRegistry.Index, 1)
require.Contains(t, pkg.ExamplesRegistry.Types, "cti.x.y.yaml_example.v1.0")
}

func Test_ParseExamples_CollisionWithMainEntity(t *testing.T) {
tc := testsupp.PackageTestCase{
PkgId: "x.y",
Entities: []string{"entities/entity.raml"},
Files: map[string]string{
"entities/entity.raml": strings.TrimSpace(`
#%RAML 1.0 Library

uses:
cti: ../.ramlx/cti.raml

types:
SampleEntity:
(cti.cti): cti.x.y.sample_entity.v1.0
(cti.final): false
properties:
name: string
`),
// Example reuses the same CTI expression as the main entity.
"examples/example.raml": strings.TrimSpace(`
#%RAML 1.0 Library

uses:
cti: ../.ramlx/cti.raml

types:
SampleEntity:
(cti.cti): cti.x.y.sample_entity.v1.0
(cti.final): false
properties:
name: string
`),
},
}

pkg := newExamplePkg(t, "examples_collision", tc, []string{"examples/example.raml"})

err := pkg.ParseExamples()
require.Error(t, err)
require.Contains(t, err.Error(), "collides")
}

// Test_ValidateExamples_Valid tests the canonical example pattern from real packages:
// the entity file (in "entities") defines both a CTI type and a library-level annotation type,
// and the example file (in "examples") imports that annotation type and uses it to create
// concrete instances. This matches patterns seen in real packages (e.g. wr/examples, tm/examples).
func Test_ValidateExamples_Valid(t *testing.T) {
tc := testsupp.PackageTestCase{
PkgId: "x.y",
Entities: []string{"entities/entity.raml"},
Files: map[string]string{
// Entity file defines both the CTI type and the annotation type used to create instances.
"entities/entity.raml": strings.TrimSpace(`
#%RAML 1.0 Library

uses:
cti: ../.ramlx/cti.raml

annotationTypes:
SampleEntities:
type: SampleEntity[]
allowedTargets: [Library]

types:
SampleEntity:
(cti.cti): cti.x.y.sample_entity.v1.0
(cti.final): false
properties:
id:
(cti.id): true
name: string
`),
// Example file imports the entity file and uses its annotation type to create
// a concrete instance that refers back to the entity type in the main package.
// The relative path resolves from examples/ to the entity file at the package root.
"examples/example.raml": strings.TrimSpace(`
#%RAML 1.0 Library

uses:
cti: ../.ramlx/cti.raml
entities: ../entities/entity.raml

(entities.SampleEntities):
- id: cti.x.y.sample_entity.v1.0~x.y.example_data.v1.0
name: Example Name
`),
},
}

pkg := newExamplePkg(t, "examples_validate_valid", tc, []string{"examples/example.raml"})

require.NoError(t, pkg.ValidateExamples())
require.NotNil(t, pkg.ExamplesRegistry)
require.Len(t, pkg.ExamplesRegistry.Index, 1)
require.Contains(t, pkg.ExamplesRegistry.Instances, "cti.x.y.sample_entity.v1.0~x.y.example_data.v1.0")
}

func Test_ValidateExamples_Empty(t *testing.T) {
pkg := newExamplePkg(t, "examples_validate_empty", testsupp.PackageTestCase{
PkgId: "x.y",
Files: map[string]string{},
}, nil)

require.NoError(t, pkg.Parse())
require.NoError(t, pkg.ValidateExamples())
}
14 changes: 12 additions & 2 deletions metadata/ctipackage/package.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ type Package struct {
Index *Index
IndexLock *IndexLock

LocalRegistry *registry.MetadataRegistry
GlobalRegistry *registry.MetadataRegistry
LocalRegistry *registry.MetadataRegistry
GlobalRegistry *registry.MetadataRegistry
ExamplesRegistry *registry.MetadataRegistry

Parsed bool

Expand Down Expand Up @@ -85,6 +86,15 @@ func WithDependencies(deps map[string]string) InitializeOption {
}
}

func WithExamples(examples []string) InitializeOption {
return func(pkg *Package) error {
if examples != nil {
pkg.Index.Examples = examples
}
return nil
}
}

func (pkg *Package) Read() error {
idx, err := ReadIndex(pkg.BaseDir)
if err != nil {
Expand Down
Loading
Loading