Repository: apigee/registry
Pull request #488 (2022-03-10 23:18:02)

Update manifest validate function to support advanced patterns
Fixes: https://github.com/apigee/registry/issues/464

shrutiparabgoogle: Bumping this up for review.

Patch:
diff --git a/cmd/registry/cmd/upload/artifact.go b/cmd/registry/cmd/upload/artifact.go
index 255da9d..d558e12 100644
--- a/cmd/registry/cmd/upload/artifact.go
+++ b/cmd/registry/cmd/upload/artifact.go
@@ -84,7 +84,7 @@ func buildArtifact(ctx context.Context, parent string, filename string) (*rpc.Ar
 	var artifact *rpc.Artifact
 	switch header.Kind {
 	case "Manifest", patch.ManifestMimeType:
-		artifact, err = buildManifestArtifact(ctx, jsonBytes)
+		artifact, err = buildManifestArtifact(ctx, parent, jsonBytes)
 	case "TaxonomyList", "google.cloud.apigeeregistry.v1.apihub.TaxonomyList":
 		artifact, err = buildTaxonomyListArtifact(ctx, jsonBytes)
 	case "Lifecycle", "google.cloud.apigeeregistry.v1.apihub.Lifecycle":
@@ -101,16 +101,20 @@ func buildArtifact(ctx context.Context, parent string, filename string) (*rpc.Ar
 	return artifact, nil
 }
 
-func buildManifestArtifact(ctx context.Context, jsonBytes []byte) (*rpc.Artifact, error) {
+func buildManifestArtifact(ctx context.Context, parent string, jsonBytes []byte) (*rpc.Artifact, error) {
 	m := &rpc.Manifest{}
 	err := protojson.Unmarshal(jsonBytes, m)
 	if err != nil {
 		return nil, err
 	}
-	err = validateManifest(ctx, m)
-	if err != nil {
-		return nil, err
+	errs := controller.ValidateManifest(ctx, parent, m)
+	if count := len(errs); count > 0 {
+		for _, err = range errs {
+			log.FromContext(ctx).WithError(err).Error("Manifest error")
+		}
+		return nil, fmt.Errorf("manifest definition contains %d error(s): see logs for details", count)
 	}
+
 	artifactBytes, err := proto.Marshal(m)
 	if err != nil {
 		return nil, err
@@ -121,20 +125,6 @@ func buildManifestArtifact(ctx context.Context, jsonBytes []byte) (*rpc.Artifact
 	}, nil
 }
 
-func validateManifest(ctx context.Context, m *rpc.Manifest) error {
-	isValid := true
-	for _, resource := range m.GeneratedResources {
-		if err := controller.ValidateResourceEntry(resource); err != nil {
-			log.FromContext(ctx).WithError(err).Errorf("Invalid manifest entry %v", resource)
-			isValid = false
-		}
-	}
-	if !isValid {
-		return fmt.Errorf("manifest contains errors")
-	}
-	return nil
-}
-
 func buildTaxonomyListArtifact(ctx context.Context, jsonBytes []byte) (*rpc.Artifact, error) {
 	m := &rpc.TaxonomyList{}
 	err := protojson.Unmarshal(jsonBytes, m)
diff --git a/cmd/registry/cmd/upload/manifest.go b/cmd/registry/cmd/upload/manifest.go
index 94ffb66..80ed45e 100644
--- a/cmd/registry/cmd/upload/manifest.go
+++ b/cmd/registry/cmd/upload/manifest.go
@@ -66,14 +66,11 @@ func manifestCommand(ctx context.Context) *cobra.Command {
 			}
 
 			// validate the manifest
-			isValid := true
-			for _, resource := range manifest.GeneratedResources {
-				if err := controller.ValidateResourceEntry(resource); err != nil {
-					log.FromContext(ctx).WithError(err).Errorf("Invalid manifest entry %v", resource)
-					isValid = false
+			errs := controller.ValidateManifest(ctx, fmt.Sprintf("projects/%s/locations/global", projectID), manifest)
+			if len(errs) > 0 {
+				for _, err := range errs {
+					log.FromContext(ctx).WithError(err).Errorf("Invalid manifest entry")
 				}
-			}
-			if !isValid {
 				log.Fatal(ctx, "Manifest definition contains errors")
 			}
 
diff --git a/cmd/registry/controller/controller.go b/cmd/registry/controller/controller.go
index cd1c756..9ae62b1 100644
--- a/cmd/registry/controller/controller.go
+++ b/cmd/registry/controller/controller.go
@@ -38,12 +38,20 @@ func ProcessManifest(
 	manifest *rpc.Manifest) []*Action {
 
 	var actions []*Action
+	//Check for errors in manifest
+	errs := ValidateManifest(ctx, fmt.Sprintf("projects/%s", projectID), manifest)
+	if len(errs) > 0 {
+		for _, err := range errs {
+			log.FromContext(ctx).WithError(err).Debugf("Error in manifest")
+		}
+	}
+
 	for _, resource := range manifest.GeneratedResources {
 		log.Debugf(ctx, "Processing entry: %v", resource)
 
-		err := ValidateResourceEntry(resource)
-		if err != nil {
-			log.FromContext(ctx).WithError(err).Debugf("Skipping resource: %q invalid resource pattern", resource)
+		errs := validateGeneratedResourceEntry(fmt.Sprintf("projects/%s/locations/global", projectID), resource)
+		if len(errs) > 0 {
+			log.FromContext(ctx).Debugf("Skipping resource: %q", resource)
 			continue
 		}
 
@@ -106,18 +114,18 @@ func generateDependencyMap(
 	}
 
 	// Fetch resources using the extDependencyQuery
-	sourceList, err := ListResources(ctx, client, extDependencyQuery, dependency.Filter)
+	sourceList, err := listResources(ctx, client, extDependencyQuery, dependency.Filter)
 	if err != nil {
 		return nil, err
 	}
 
 	for _, source := range sourceList {
-		group, err := getEntityKey(dependency.Pattern, source.GetResourceName())
+		group, err := getEntityKey(dependency.Pattern, source.ResourceName())
 		if err != nil {
 			return nil, err
 		}
 
-		sourceTime := source.GetUpdateTimestamp()
+		sourceTime := source.UpdateTimestamp()
 		maxUpdateTime, exists := sourceMap[group]
 		if !exists || maxUpdateTime.Before(sourceTime) {
 			sourceMap[group] = sourceTime
@@ -174,18 +182,18 @@ func generateUpdateActions(
 	actions := make([]*Action, 0)
 
 	// Generate resource list
-	resourceList, err := ListResources(ctx, client, resourcePattern, filter)
+	resourceList, err := listResources(ctx, client, resourcePattern, filter)
 	if err != nil {
 		return nil, nil, err
 	}
 
 	// Iterate over a list of existing target resources to generate update actions
 	for _, targetResource := range resourceList {
-		visited[targetResource.GetResourceName().GetParent()] = true
+		visited[targetResource.ResourceName().Parent()] = true
 
 		takeAction, err := needsUpdate(
-			targetResource.GetResourceName(),
-			targetResource.GetUpdateTimestamp(),
+			targetResource.ResourceName(),
+			targetResource.UpdateTimestamp(),
 			dependencyMaps,
 			generatedResource,
 			false,
@@ -197,13 +205,13 @@ func generateUpdateActions(
 		}
 
 		if takeAction {
-			cmd, err := generateCommand(generatedResource.Action, targetResource.GetResourceName().String())
+			cmd, err := generateCommand(generatedResource.Action, targetResource.ResourceName().String())
 			if err != nil {
 				return nil, nil, fmt.Errorf("Cannot generate command: %s", err)
 			}
 			a := &Action{
 				Command:           cmd,
-				GeneratedResource: targetResource.GetResourceName().String(),
+				GeneratedResource: targetResource.ResourceName().String(),
 				RequiresReceipt:   generatedResource.Receipt,
 			}
 			actions = append(actions, a)
@@ -228,7 +236,7 @@ func generateCreateActions(
 	if err != nil {
 		return nil, err
 	}
-	parentName := parsedResourcePattern.GetParent()
+	parentName := parsedResourcePattern.Parent()
 
 	// If parent is a project, we can't list projects since this is registry client command.
 	// Since the manifest definition is scoped  only for a particular project,
@@ -279,7 +287,7 @@ func generateCreateActions(
 
 	// If parent resource is not a project, then go through all the non-visited parents.
 	// We don't pass the filter here because the filter is for the target resource and not it's parent.
-	parentList, err := ListResources(ctx, client, parentName, "")
+	parentList, err := listResources(ctx, client, parentName, "")
 	if err != nil {
 		return nil, err
 	}
@@ -288,12 +296,12 @@ func generateCreateActions(
 
 	for _, parent := range parentList {
 		// Skip if this parent was already visited.
-		if visited[parent.GetResourceName().String()] {
+		if visited[parent.ResourceName().String()] {
 			continue
 		}
 		// Since the GeneratedResource is non-existent here,
 		// we will have to derive the exact name of the target resource
-		targetResourceName, err := resourceNameFromParent(resourcePattern, parent.GetResourceName().String())
+		targetResourceName, err := resourceNameFromParent(resourcePattern, parent.ResourceName().String())
 		if err != nil {
 			return nil, err
 		}
@@ -326,7 +334,7 @@ func generateCreateActions(
 }
 
 func needsUpdate(
-	targetResourceName ResourceName,
+	targetResourceName resourceName,
 	targetResourceTime time.Time,
 	dependencyMaps []map[string]time.Time,
 	generatedResource *rpc.GeneratedResource,
@@ -354,7 +362,7 @@ func needsUpdate(
 }
 
 func needsCreate(
-	targetResourceName ResourceName,
+	targetResourceName resourceName,
 	dependencyMaps []map[string]time.Time,
 	generatedResource *rpc.GeneratedResource) (bool, error) {
 	for i, dependency := range generatedResource.Dependencies {
diff --git a/cmd/registry/controller/list.go b/cmd/registry/controller/list.go
index d6a6529..c8bbe65 100644
--- a/cmd/registry/controller/list.go
+++ b/cmd/registry/controller/list.go
@@ -23,8 +23,8 @@ import (
 	"github.com/apigee/registry/server/registry/names"
 )
 
-func ListResources(ctx context.Context, client connection.Client, pattern, filter string) ([]ResourceInstance, error) {
-	var result []ResourceInstance
+func listResources(ctx context.Context, client connection.Client, pattern, filter string) ([]resourceInstance, error) {
+	var result []resourceInstance
 	var err2 error
 
 	// First try to match collection names.
@@ -56,57 +56,57 @@ func ListResources(ctx context.Context, client connection.Client, pattern, filte
 	return result, nil
 }
 
-func generateApiHandler(result *[]ResourceInstance) func(*rpc.Api) {
+func generateApiHandler(result *[]resourceInstance) func(*rpc.Api) {
 	return func(api *rpc.Api) {
-		apiName, err := names.ParseApi(api.GetName())
+		parsedApiName, err := names.ParseApi(api.GetName())
 		if err != nil {
 			panic(err)
 		}
-		resource := ApiResource{
-			ApiName:         ApiName{Api: apiName},
-			UpdateTimestamp: api.UpdateTime.AsTime(),
+		resource := apiResource{
+			apiName:         apiName{api: parsedApiName},
+			updateTimestamp: api.UpdateTime.AsTime(),
 		}
 		(*result) = append((*result), resource)
 	}
 }
 
-func generateVersionHandler(result *[]ResourceInstance) func(*rpc.ApiVersion) {
+func generateVersionHandler(result *[]resourceInstance) func(*rpc.ApiVersion) {
 	return func(version *rpc.ApiVersion) {
-		versionName, err := names.ParseVersion(version.GetName())
+		parsedVersionName, err := names.ParseVersion(version.GetName())
 		if err != nil {
 			panic(err)
 		}
-		resource := VersionResource{
-			VersionName:     VersionName{Version: versionName},
-			UpdateTimestamp: version.UpdateTime.AsTime(),
+		resource := versionResource{
+			versionName:     versionName{version: parsedVersionName},
+			updateTimestamp: version.UpdateTime.AsTime(),
 		}
 		(*result) = append((*result), resource)
 	}
 }
 
-func generateSpecHandler(result *[]ResourceInstance) func(*rpc.ApiSpec) {
+func generateSpecHandler(result *[]resourceInstance) func(*rpc.ApiSpec) {
 	return func(spec *rpc.ApiSpec) {
-		specName, err := names.ParseSpec(spec.GetName())
+		parsedSpecName, err := names.ParseSpec(spec.GetName())
 		if err != nil {
 			panic(err)
 		}
-		resource := SpecResource{
-			SpecName:        SpecName{Spec: specName},
-			UpdateTimestamp: spec.RevisionUpdateTime.AsTime(),
+		resource := specResource{
+			specName:        specName{spec: parsedSpecName},
+			updateTimestamp: spec.RevisionUpdateTime.AsTime(),
 		}
 		(*result) = append((*result), resource)
 	}
 }
 
-func generateArtifactHandler(result *[]ResourceInstance) func(*rpc.Artifact) {
+func generateArtifactHandler(result *[]resourceInstance) func(*rpc.Artifact) {
 	return func(artifact *rpc.Artifact) {
-		artifactName, err := names.ParseArtifact(artifact.GetName())
+		parsedArtifactName, err := names.ParseArtifact(artifact.GetName())
 		if err != nil {
 			panic(err)
 		}
-		resource := ArtifactResource{
-			ArtifactName:    ArtifactName{Artifact: artifactName},
-			UpdateTimestamp: artifact.UpdateTime.AsTime(),
+		resource := artifactResource{
+			artifactName:    artifactName{artifact: parsedArtifactName},
+			updateTimestamp: artifact.UpdateTime.AsTime(),
 		}
 		(*result) = append((*result), resource)
 	}
diff --git a/cmd/registry/controller/parser.go b/cmd/registry/controller/parser.go
index 8d96205..4b42ace 100644
--- a/cmd/registry/controller/parser.go
+++ b/cmd/registry/controller/parser.go
@@ -15,6 +15,7 @@
 package controller
 
 import (
+	"context"
 	"fmt"
 	"regexp"
 	"strings"
@@ -25,35 +26,35 @@ import (
 
 const resourceKW = "$resource"
 
-func parseResourceCollection(resourcePattern string) (ResourceName, error) {
+func parseResourceCollection(resourcePattern string) (resourceName, error) {
 	if api, err := names.ParseApiCollection(resourcePattern); err == nil {
-		return ApiName{Api: api}, nil
+		return apiName{api: api}, nil
 	} else if version, err := names.ParseVersionCollection(resourcePattern); err == nil {
-		return VersionName{Version: version}, nil
+		return versionName{version: version}, nil
 	} else if spec, err := names.ParseSpecCollection(resourcePattern); err == nil {
-		return SpecName{Spec: spec}, nil
+		return specName{spec: spec}, nil
 	} else if artifact, err := names.ParseArtifactCollection(resourcePattern); err == nil {
-		return ArtifactName{Artifact: artifact}, nil
+		return artifactName{artifact: artifact}, nil
 	}
 
 	return nil, fmt.Errorf("invalid resourcePattern: %s", resourcePattern)
 }
 
-func parseResource(resourcePattern string) (ResourceName, error) {
+func parseResource(resourcePattern string) (resourceName, error) {
 	if api, err := names.ParseApi(resourcePattern); err == nil {
-		return ApiName{Api: api}, nil
+		return apiName{api: api}, nil
 	} else if version, err := names.ParseVersion(resourcePattern); err == nil {
-		return VersionName{Version: version}, nil
+		return versionName{version: version}, nil
 	} else if spec, err := names.ParseSpec(resourcePattern); err == nil {
-		return SpecName{Spec: spec}, nil
+		return specName{spec: spec}, nil
 	} else if artifact, err := names.ParseArtifact(resourcePattern); err == nil {
-		return ArtifactName{Artifact: artifact}, nil
+		return artifactName{artifact: artifact}, nil
 	}
 
 	return nil, fmt.Errorf("invalid resourcePattern: %s", resourcePattern)
 }
 
-func parseResourcePattern(resourcePattern string) (ResourceName, error) {
+func parseResourcePattern(resourcePattern string) (resourceName, error) {
 
 	// First try to match resource collections.
 	resource, err := parseResourceCollection(resourcePattern)
@@ -109,13 +110,13 @@ func extendDependencyPattern(
 	entityVal := ""
 	switch entityType {
 	case "api":
-		entityVal = resourceName.GetApi()
+		entityVal = resourceName.Api()
 	case "version":
-		entityVal = resourceName.GetVersion()
+		entityVal = resourceName.Version()
 	case "spec":
-		entityVal = resourceName.GetSpec()
+		entityVal = resourceName.Spec()
 	case "artifact":
-		entityVal = resourceName.GetArtifact()
+		entityVal = resourceName.Artifact()
 	default:
 		return "", fmt.Errorf("invalid combination resourcePattern: %q dependencyPattern: %q", resourcePattern, dependencyPattern)
 	}
@@ -128,9 +129,7 @@ func extendDependencyPattern(
 
 }
 
-func resourceNameFromParent(
-	resourcePattern string,
-	parent string) (ResourceName, error) {
+func resourceNameFromParent(resourcePattern string, parent string) (resourceName, error) {
 	// Derives the resource name from the provided resourcePattern and it's parent.
 	// Example:
 	// 1) resourcePattern: projects/demo/locations/global/apis/-/versions/-/specs/openapi.yaml
@@ -146,7 +145,7 @@ func resourceNameFromParent(
 	}
 
 	// Replace the parent pattern in the resourcePattern with the supplied pattern name
-	resourceName := strings.Replace(resourcePattern, parsedResourcePattern.GetParent(), parent, 1)
+	resourceName := strings.Replace(resourcePattern, parsedResourcePattern.Parent(), parent, 1)
 
 	//Validate generated resourceName
 	resource, err := parseResource(resourceName)
@@ -157,39 +156,79 @@ func resourceNameFromParent(
 	return resource, nil
 }
 
-func ValidateResourceEntry(resource *rpc.GeneratedResource) error {
-	var group string
+func ValidateManifest(ctx context.Context, parent string, manifest *rpc.Manifest) []error {
+	totalErrors := make([]error, 0)
+	for _, resource := range manifest.GeneratedResources {
+		errs := validateGeneratedResourceEntry(parent, resource)
+		for _, err := range errs {
+			totalErrors = append(totalErrors, fmt.Errorf("invalid entry: %v, %s", resource, err))
+		}
+	}
+	return totalErrors
+}
+
+func validateGeneratedResourceEntry(parent string, generatedResource *rpc.GeneratedResource) []error {
+	parsedTargetResource, err := parseResourcePattern(
+		fmt.Sprintf("%s/%s", parent, generatedResource.Pattern))
 
-	for _, dependency := range resource.Dependencies {
-		// Validate that all the dependencies are grouped at the same level
-		groupEntity, err := getEntityType(dependency.Pattern)
+	// Check that the target resource pattern should be a valid pattern.
+	// Return for errors in target resource pattern since we can't verify action and dependencies based off an incorrect pattern.
+	if err != nil {
+		return []error{
+			fmt.Errorf("invalid pattern for generatedResource %v, %s", generatedResource.Pattern, err),
+		}
+	}
+	// Check that generatedResource pattern doesn't end with a "-".
+	// We require a name for the target resource.
+	if strings.HasSuffix(parsedTargetResource.String(), "/-") {
+		return []error{
+			fmt.Errorf("invalid generatedResource pattern: %q, it should end with a name and not a \"-\"", generatedResource.Pattern),
+		}
+	}
+
+	validateEntityReference := func(resourceName resourceName, entityType string) bool {
+		switch entityType {
+		case "api":
+			return resourceName.Api() != ""
+		case "version":
+			return resourceName.Version() != ""
+		case "spec":
+			return resourceName.Spec() != ""
+		case "artifact":
+			return resourceName.Artifact() != ""
+		case "default":
+			return true
+		default:
+			return false
+		}
+	}
+
+	errs := make([]error, 0)
+	for _, dependency := range generatedResource.Dependencies {
+		// Validate that all the dependencies have valid $resource references.
+		entityType, err := getEntityType(dependency.Pattern)
 		if err != nil {
-			return err
+			errs = append(errs, fmt.Errorf("invalid dependency pattern %s: %s", dependency.Pattern, err))
 		}
-		if len(group) == 0 {
-			group = groupEntity
-		} else {
-			if groupEntity != group {
-				return fmt.Errorf("invalid matching: all the dependencies should be matched at the same level from $resource")
-			}
+
+		if !validateEntityReference(parsedTargetResource, entityType) {
+			errs = append(errs, fmt.Errorf("invalid reference in dependency pattern: %s", dependency.Pattern))
 		}
+
 	}
 
-	// Validate that the action contains reference to valid entities.
-	// Same as the group entity
-	entity, entityType, err := getCommandEntity(resource.Action)
+	//Validate that all the action References are valid
+	references, err := getReferencesFromAction(generatedResource.Action)
 	if err != nil {
-		return err
-	}
-	// no $resource reference present
-	if len(entity) == 0 {
-		return nil
+		errs = append(errs, err)
 	}
-	if entityType != group {
-		return fmt.Errorf("invalid reference ($resource.entity) in %q , entity should be same as the match in the dependencies", resource.Action)
+	for _, r := range references {
+		if !validateEntityReference(parsedTargetResource, r.entityType) {
+			errs = append(errs, fmt.Errorf("invalid reference in action: %s", generatedResource.Action))
+		}
 	}
 
-	return nil
+	return errs
 }
 
 func getEntityType(sourcePattern string) (string, error) {
@@ -216,7 +255,7 @@ func getEntityType(sourcePattern string) (string, error) {
 	return matches[1], nil
 }
 
-func getEntityKey(sourcePattern string, resource ResourceName) (string, error) {
+func getEntityKey(sourcePattern string, resource resourceName) (string, error) {
 	// Reads the sourcePattern and returns the entity value for the $resource reference
 	// Example:
 	// pattern: $resource.api/versions/-/specs/-
@@ -230,13 +269,13 @@ func getEntityKey(sourcePattern string, resource ResourceName) (string, error) {
 
 	switch entityType {
 	case "api":
-		return resource.GetApi(), nil
+		return resource.Api(), nil
 	case "version":
-		return resource.GetVersion(), nil
+		return resource.Version(), nil
 	case "spec":
-		return resource.GetSpec(), nil
+		return resource.Spec(), nil
 	case "artifact":
-		return resource.GetArtifact(), nil
+		return resource.Artifact(), nil
 	case "default":
 		return "default", nil
 	default:
@@ -245,49 +284,53 @@ func getEntityKey(sourcePattern string, resource ResourceName) (string, error) {
 
 }
 
-func getCommandEntity(action string) (string, string, error) {
-	// Check if there is a reference to $n in the action
+type reference struct {
+	entity     string
+	entityType string
+}
+
+func getReferencesFromAction(action string) ([]*reference, error) {
+	references := make([]*reference, 0)
+
+	// Check if there is a reference to $resource in the action
 	isMatch, err := regexp.MatchString(fmt.Sprintf(`\%s`, resourceKW), action)
 	if err != nil {
-		return "", "", err
+		return nil, err
 	}
 	// No $resource references in the action string
 	if !isMatch {
-		return "", "", nil
+		return references, nil
 	}
 
 	// Extract the $resource patterns from action
 	// action = "compute lintstats $resource.spec"
 	// This expression will match $resource.spec
-	re := regexp.MustCompile(fmt.Sprintf(`\%s(\.api|\.version|\.spec|\.artifact)($|/| )`, resourceKW))
+	re := regexp.MustCompile(fmt.Sprintf(`\%s\.(api|version|spec|artifact)($|/| )`, resourceKW))
 	match := re.FindAllString(action, -1)
 	if len(match) == 0 {
-		return "", "", fmt.Errorf("invalid action: %s missing or incorrect entity in the reference", action)
+		return nil, fmt.Errorf("invalid action: %s missing or incorrect entity in the reference", action)
 	}
 
-	// Check if all the references are at the same level
-	entity := strings.TrimRight(match[0], " /")
+	// Construct a list of entity: entityType for all the references
 	for _, m := range match {
-		if strings.Trim(m, " /") != entity {
-			return "", "", fmt.Errorf("invalid action: %s All the $resource references must be at the same level", action)
-		}
+		// entity = $resource.api, extract the entityType as "api"
+		entity := strings.TrimRight(m, " /")
+		entityType := entity[len(resourceKW)+1:]
+		references = append(references, &reference{entity: entity, entityType: entityType})
 	}
 
-	// entity = $resource.api, extract the  entityType as "api"
-	entityType := entity[len(resourceKW)+1:]
-
-	return entity, entityType, nil
+	return references, nil
 }
 
 func generateCommand(action string, resourceName string) (string, error) {
 
-	entity, entityType, err := getCommandEntity(action)
+	references, err := getReferencesFromAction(action)
 	if err != nil {
 		return "", err
 	}
 
 	// no $resource reference, return the original action
-	if len(entity) == 0 {
+	if len(references) == 0 {
 		return action, nil
 	}
 
@@ -296,24 +339,24 @@ func generateCommand(action string, resourceName string) (string, error) {
 		return "", fmt.Errorf("error generating command, invalid resourceName: %s", resourceName)
 	}
 
-	entityVal := ""
-	switch entityType {
-	case "api":
-		entityVal = resource.GetApi()
-	case "version":
-		entityVal = resource.GetVersion()
-	case "spec":
-		entityVal = resource.GetSpec()
-	case "artifact":
-		entityVal = resource.GetArtifact()
-	default:
-		entityVal = ""
-	}
+	for _, r := range references {
+		entityVal := ""
+		switch r.entityType {
+		case "api":
+			entityVal = resource.Api()
+		case "version":
+			entityVal = resource.Version()
+		case "spec":
+			entityVal = resource.Spec()
+		case "artifact":
+			entityVal = resource.Artifact()
+		}
 
-	if len(entityVal) == 0 {
-		return "", fmt.Errorf("error generating command, cannot derive args for action. Invalid action: %s", action)
+		if len(entityVal) == 0 {
+			return "", fmt.Errorf("error generating command, cannot derive args for action. Invalid action: %s", action)
+		}
+		action = strings.ReplaceAll(action, r.entity, entityVal)
 	}
-	action = strings.ReplaceAll(action, entity, entityVal)
 
 	return action, nil
 }
diff --git a/cmd/registry/controller/resource-name.go b/cmd/registry/controller/resource-name.go
index 7def94c..b352e9e 100644
--- a/cmd/registry/controller/resource-name.go
+++ b/cmd/registry/controller/resource-name.go
@@ -22,113 +22,113 @@ import (
 // Example:
 // projects/demo/locations/global/apis/petstore/versions/1.0.0/specs/openapi.yaml
 // projects/demo/locations/global/apis/-/versions/-/specs/-/artifacts/-
-type ResourceName interface {
-	GetArtifact() string
-	GetSpec() string
-	GetVersion() string
-	GetApi() string
+type resourceName interface {
+	Artifact() string
+	Spec() string
+	Version() string
+	Api() string
 	String() string
-	GetParent() string
+	Parent() string
 }
 
-type SpecName struct {
-	Spec names.Spec
+type specName struct {
+	spec names.Spec
 }
 
-func (s SpecName) GetArtifact() string {
+func (s specName) Artifact() string {
 	return ""
 }
 
-func (s SpecName) GetSpec() string {
-	return s.Spec.String()
+func (s specName) Spec() string {
+	return s.spec.String()
 }
 
-func (s SpecName) GetVersion() string {
-	return s.Spec.Version().String()
+func (s specName) Version() string {
+	return s.spec.Version().String()
 }
 
-func (s SpecName) GetApi() string {
-	return s.Spec.Api().String()
+func (s specName) Api() string {
+	return s.spec.Api().String()
 }
 
-func (s SpecName) String() string {
-	return s.Spec.String()
+func (s specName) String() string {
+	return s.spec.String()
 }
 
-func (s SpecName) GetParent() string {
-	return s.Spec.Parent()
+func (s specName) Parent() string {
+	return s.spec.Parent()
 }
 
-type VersionName struct {
-	Version names.Version
+type versionName struct {
+	version names.Version
 }
 
-func (v VersionName) GetArtifact() string {
+func (v versionName) Artifact() string {
 	return ""
 }
 
-func (v VersionName) GetSpec() string {
+func (v versionName) Spec() string {
 	return ""
 }
 
-func (v VersionName) GetVersion() string {
-	return v.Version.String()
+func (v versionName) Version() string {
+	return v.version.String()
 }
 
-func (v VersionName) GetApi() string {
-	return v.Version.Api().String()
+func (v versionName) Api() string {
+	return v.version.Api().String()
 }
 
-func (v VersionName) String() string {
-	return v.Version.String()
+func (v versionName) String() string {
+	return v.version.String()
 }
 
-func (v VersionName) GetParent() string {
-	return v.Version.Parent()
+func (v versionName) Parent() string {
+	return v.version.Parent()
 }
 
-type ApiName struct {
-	Api names.Api
+type apiName struct {
+	api names.Api
 }
 
-func (a ApiName) GetArtifact() string {
+func (a apiName) Artifact() string {
 	return ""
 }
 
-func (a ApiName) GetSpec() string {
+func (a apiName) Spec() string {
 	return ""
 }
 
-func (a ApiName) GetVersion() string {
+func (a apiName) Version() string {
 	return ""
 }
 
-func (a ApiName) GetApi() string {
-	return a.Api.String()
+func (a apiName) Api() string {
+	return a.api.String()
 }
 
-func (a ApiName) String() string {
-	return a.Api.String()
+func (a apiName) String() string {
+	return a.api.String()
 }
 
-func (a ApiName) GetParent() string {
-	return a.Api.Parent()
+func (a apiName) Parent() string {
+	return a.api.Parent()
 }
 
-type ArtifactName struct {
-	Artifact names.Artifact
+type artifactName struct {
+	artifact names.Artifact
 }
 
-func (ar ArtifactName) GetArtifact() string {
-	return ar.Artifact.String()
+func (ar artifactName) Artifact() string {
+	return ar.artifact.String()
 }
 
-func (ar ArtifactName) GetSpec() string {
+func (ar artifactName) Spec() string {
 	specPattern := names.Spec{
-		ProjectID: ar.Artifact.ProjectID(),
-		ApiID:     ar.Artifact.ApiID(),
-		VersionID: ar.Artifact.VersionID(),
-		SpecID:    ar.Artifact.SpecID(),
+		ProjectID: ar.artifact.ProjectID(),
+		ApiID:     ar.artifact.ApiID(),
+		VersionID: ar.artifact.VersionID(),
+		SpecID:    ar.artifact.SpecID(),
 	}
 
 	// Validate the generated name
@@ -141,11 +141,11 @@ func (ar ArtifactName) GetSpec() string {
 	return ""
 }
 
-func (ar ArtifactName) GetVersion() string {
+func (ar artifactName) Version() string {
 	versionPattern := names.Version{
-		ProjectID: ar.Artifact.ProjectID(),
-		ApiID:     ar.Artifact.ApiID(),
-		VersionID: ar.Artifact.VersionID(),
+		ProjectID: ar.artifact.ProjectID(),
+		ApiID:     ar.artifact.ApiID(),
+		VersionID: ar.artifact.VersionID(),
 	}
 	// Validate the generated name
 	if version, err := names.ParseVersion(versionPattern.String()); err == nil {
@@ -157,10 +157,10 @@ func (ar ArtifactName) GetVersion() string {
 	return ""
 }
 
-func (ar ArtifactName) GetApi() string {
+func (ar artifactName) Api() string {
 	apiPattern := names.Api{
-		ProjectID: ar.Artifact.ProjectID(),
-		ApiID:     ar.Artifact.ApiID(),
+		ProjectID: ar.artifact.ProjectID(),
+		ApiID:     ar.artifact.ApiID(),
 	}
 	// Validate the generated name
 	if _, err := names.ParseApi(apiPattern.String()); err == nil {
@@ -172,10 +172,10 @@ func (ar ArtifactName) GetApi() string {
 	return ""
 }
 
-func (ar ArtifactName) String() string {
-	return ar.Artifact.String()
+func (ar artifactName) String() string {
+	return ar.artifact.String()
 }
 
-func (ar ArtifactName) GetParent() string {
-	return ar.Artifact.Parent()
+func (ar artifactName) Parent() string {
+	return ar.artifact.Parent()
 }
diff --git a/cmd/registry/controller/resource.go b/cmd/registry/controller/resource.go
index 14631b1..0ae2763 100644
--- a/cmd/registry/controller/resource.go
+++ b/cmd/registry/controller/resource.go
@@ -20,59 +20,59 @@ import (
 
 // This interface is used to describe resource instances
 // ResourceName is embeded, the only additional field is the UpdateTimestamp
-type ResourceInstance interface {
-	GetResourceName() ResourceName
-	GetUpdateTimestamp() time.Time
+type resourceInstance interface {
+	ResourceName() resourceName
+	UpdateTimestamp() time.Time
 }
 
-type SpecResource struct {
-	SpecName        ResourceName
-	UpdateTimestamp time.Time
+type specResource struct {
+	specName        resourceName
+	updateTimestamp time.Time
 }
 
-func (s SpecResource) GetUpdateTimestamp() time.Time {
-	return s.UpdateTimestamp
+func (s specResource) UpdateTimestamp() time.Time {
+	return s.updateTimestamp
 }
 
-func (s SpecResource) GetResourceName() ResourceName {
-	return s.SpecName
+func (s specResource) ResourceName() resourceName {
+	return s.specName
 }
 
-type VersionResource struct {
-	VersionName     ResourceName
-	UpdateTimestamp time.Time
+type versionResource struct {
+	versionName     resourceName
+	updateTimestamp time.Time
 }
 
-func (v VersionResource) GetUpdateTimestamp() time.Time {
-	return v.UpdateTimestamp
+func (v versionResource) UpdateTimestamp() time.Time {
+	return v.updateTimestamp
 }
 
-func (v VersionResource) GetResourceName() ResourceName {
-	return v.VersionName
+func (v versionResource) ResourceName() resourceName {
+	return v.versionName
 }
 
-type ApiResource struct {
-	ApiName         ResourceName
-	UpdateTimestamp time.Time
+type apiResource struct {
+	apiName         resourceName
+	updateTimestamp time.Time
 }
 
-func (a ApiResource) GetUpdateTimestamp() time.Time {
-	return a.UpdateTimestamp
+func (a apiResource) UpdateTimestamp() time.Time {
+	return a.updateTimestamp
 }
 
-func (a ApiResource) GetResourceName() ResourceName {
-	return a.ApiName
+func (a apiResource) ResourceName() resourceName {
+	return a.apiName
 }
 
-type ArtifactResource struct {
-	ArtifactName    ResourceName
-	UpdateTimestamp time.Time
+type artifactResource struct {
+	artifactName    resourceName
+	updateTimestamp time.Time
 }
 
-func (ar ArtifactResource) GetUpdateTimestamp() time.Time {
-	return ar.UpdateTimestamp
+func (ar artifactResource) UpdateTimestamp() time.Time {
+	return ar.updateTimestamp
 }
 
-func (ar ArtifactResource) GetResourceName() ResourceName {
-	return ar.ArtifactName
+func (ar artifactResource) ResourceName() resourceName {
+	return ar.artifactName
 }


Test patch:
diff --git a/cmd/registry/controller/controller_test.go b/cmd/registry/controller/controller_test.go
index bdec662..71bb353 100644
--- a/cmd/registry/controller/controller_test.go
+++ b/cmd/registry/controller/controller_test.go
@@ -26,6 +26,24 @@ import (
 )
 
 var sortActions = cmpopts.SortSlices(func(a, b *Action) bool { return a.Command < b.Command })
+var styleguide = &rpc.StyleGuide{
+	Id:        "registry-styleguide",
+	MimeTypes: []string{gzipOpenAPIv3},
+	Guidelines: []*rpc.Guideline{
+		{
+			Id: "Operation",
+			Rules: []*rpc.Rule{
+				{
+					Id:             "OperationIdValidInURL",
+					Linter:         "spectral",
+					LinterRulename: "operation-operationId-valid-in-url",
+					Severity:       rpc.Rule_WARNING,
+				},
+			},
+			Status: rpc.Guideline_ACTIVE,
+		},
+	},
+}
 
 // Tests for artifacts as resources and specs as dependencies
 func TestArtifacts(t *testing.T) {
@@ -697,3 +715,171 @@ func TestReceiptAggArtifacts(t *testing.T) {
 	}
 
 }
+
+// Tests for manifest with multiple entity references
+func TestMultipleEntitiesArtifacts(t *testing.T) {
+	tests := []struct {
+		desc  string
+		setup func(context.Context, connection.Client, connection.AdminClient)
+		want  []*Action
+	}{
+		{
+			desc: "create artifacts",
+			setup: func(ctx context.Context, client connection.Client, adminClient connection.AdminClient) {
+				deleteProject(ctx, adminClient, t, "controller-test")
+				createProject(ctx, adminClient, t, "controller-test")
+
+				uploadStyleguide(ctx, client, t, "controller-test", styleguide)
+
+				createApi(ctx, client, t, "projects/controller-test/locations/global", "petstore")
+				// Version 1.0.0
+				createVersion(ctx, client, t, "projects/controller-test/locations/global/apis/petstore", "1.0.0")
+				createSpec(ctx, client, t, "projects/controller-test/locations/global/apis/petstore/versions/1.0.0", "openapi.yaml", gzipOpenAPIv3)
+				// Version 1.0.1
+				createVersion(ctx, client, t, "projects/controller-test/locations/global/apis/petstore", "1.0.1")
+				createSpec(ctx, client, t, "projects/controller-test/locations/global/apis/petstore/versions/1.0.1", "openapi.yaml", gzipOpenAPIv3)
+				// Version 1.1.0
+				createVersion(ctx, client, t, "projects/controller-test/locations/global/apis/petstore", "1.1.0")
+				createSpec(ctx, client, t, "projects/controller-test/locations/global/apis/petstore/versions/1.1.0", "openapi.yaml", gzipOpenAPIv3)
+			},
+			want: []*Action{
+				{
+					Command:           "registry compute conformance projects/controller-test/locations/global/apis/petstore/versions/1.0.0/specs/openapi.yaml",
+					GeneratedResource: "projects/controller-test/locations/global/apis/petstore/versions/1.0.0/specs/openapi.yaml/artifacts/conformance-registry-styleguide",
+					RequiresReceipt:   true,
+				},
+				{
+					Command:           "registry compute conformance projects/controller-test/locations/global/apis/petstore/versions/1.0.1/specs/openapi.yaml",
+					GeneratedResource: "projects/controller-test/locations/global/apis/petstore/versions/1.0.1/specs/openapi.yaml/artifacts/conformance-registry-styleguide",
+					RequiresReceipt:   true,
+				},
+				{
+					Command:           "registry compute conformance projects/controller-test/locations/global/apis/petstore/versions/1.1.0/specs/openapi.yaml",
+					GeneratedResource: "projects/controller-test/locations/global/apis/petstore/versions/1.1.0/specs/openapi.yaml/artifacts/conformance-registry-styleguide",
+					RequiresReceipt:   true,
+				},
+			},
+		},
+		{
+			desc: "outdated artifacts",
+			setup: func(ctx context.Context, client connection.Client, adminClient connection.AdminClient) {
+				deleteProject(ctx, adminClient, t, "controller-test")
+				createProject(ctx, adminClient, t, "controller-test")
+				createApi(ctx, client, t, "projects/controller-test/locations/global", "petstore")
+
+				// Version 1.0.0
+				createVersion(ctx, client, t, "projects/controller-test/locations/global/apis/petstore", "1.0.0")
+				createSpec(ctx, client, t, "projects/controller-test/locations/global/apis/petstore/versions/1.0.0", "openapi.yaml", gzipOpenAPIv3)
+				createUpdateArtifact(ctx, client, t, "projects/controller-test/locations/global/apis/petstore/versions/1.0.0/specs/openapi.yaml/artifacts/conformance-registry-styleguide")
+				// Version 1.0.1
+				createVersion(ctx, client, t, "projects/controller-test/locations/global/apis/petstore", "1.0.1")
+				createSpec(ctx, client, t, "projects/controller-test/locations/global/apis/petstore/versions/1.0.1", "openapi.yaml", gzipOpenAPIv3)
+				createUpdateArtifact(ctx, client, t, "projects/controller-test/locations/global/apis/petstore/versions/1.0.1/specs/openapi.yaml/artifacts/conformance-registry-styleguide")
+				// Version 1.1.0
+				createVersion(ctx, client, t, "projects/controller-test/locations/global/apis/petstore", "1.1.0")
+				createSpec(ctx, client, t, "projects/controller-test/locations/global/apis/petstore/versions/1.1.0", "openapi.yaml", gzipOpenAPIv3)
+				createUpdateArtifact(ctx, client, t, "projects/controller-test/locations/global/apis/petstore/versions/1.1.0/specs/openapi.yaml/artifacts/conformance-registry-styleguide")
+
+				//Update styleguide definition to make sure conformance artifacts are outdated
+				uploadStyleguide(ctx, client, t, "controller-test", styleguide)
+			},
+			want: []*Action{
+				{
+					Command:           "registry compute conformance projects/controller-test/locations/global/apis/petstore/versions/1.0.0/specs/openapi.yaml",
+					GeneratedResource: "projects/controller-test/locations/global/apis/petstore/versions/1.0.0/specs/openapi.yaml/artifacts/conformance-registry-styleguide",
+					RequiresReceipt:   true,
+				},
+				{
+					Command:           "registry compute conformance projects/controller-test/locations/global/apis/petstore/versions/1.0.1/specs/openapi.yaml",
+					GeneratedResource: "projects/controller-test/locations/global/apis/petstore/versions/1.0.1/specs/openapi.yaml/artifacts/conformance-registry-styleguide",
+					RequiresReceipt:   true,
+				},
+				{
+					Command:           "registry compute conformance projects/controller-test/locations/global/apis/petstore/versions/1.1.0/specs/openapi.yaml",
+					GeneratedResource: "projects/controller-test/locations/global/apis/petstore/versions/1.1.0/specs/openapi.yaml/artifacts/conformance-registry-styleguide",
+					RequiresReceipt:   true,
+				},
+			},
+		},
+		{
+			desc: "missing dependencies",
+			setup: func(ctx context.Context, client connection.Client, adminClient connection.AdminClient) {
+				deleteProject(ctx, adminClient, t, "controller-test")
+				createProject(ctx, adminClient, t, "controller-test")
+				createApi(ctx, client, t, "projects/controller-test/locations/global", "petstore")
+
+				uploadStyleguide(ctx, client, t, "controller-test", styleguide)
+
+				// Version 1.0.0
+				createVersion(ctx, client, t, "projects/controller-test/locations/global/apis/petstore", "1.0.0")
+				createSpec(ctx, client, t, "projects/controller-test/locations/global/apis/petstore/versions/1.0.0", "openapi.yaml", gzipOpenAPIv3)
+				// Version 1.0.1
+				createVersion(ctx, client, t, "projects/controller-test/locations/global/apis/petstore", "1.0.1")
+				// Version 1.1.0
+				createVersion(ctx, client, t, "projects/controller-test/locations/global/apis/petstore", "1.1.0")
+				createSpec(ctx, client, t, "projects/controller-test/locations/global/apis/petstore/versions/1.1.0", "openapi.yaml", gzipOpenAPIv3)
+
+			},
+			want: []*Action{
+				{
+					Command:           "registry compute conformance projects/controller-test/locations/global/apis/petstore/versions/1.0.0/specs/openapi.yaml",
+					GeneratedResource: "projects/controller-test/locations/global/apis/petstore/versions/1.0.0/specs/openapi.yaml/artifacts/conformance-registry-styleguide",
+					RequiresReceipt:   true,
+				},
+				{
+					Command:           "registry compute conformance projects/controller-test/locations/global/apis/petstore/versions/1.1.0/specs/openapi.yaml",
+					GeneratedResource: "projects/controller-test/locations/global/apis/petstore/versions/1.1.0/specs/openapi.yaml/artifacts/conformance-registry-styleguide",
+					RequiresReceipt:   true,
+				},
+			},
+		},
+	}
+
+	const projectID = "controller-test"
+	for _, test := range tests {
+		t.Run(test.desc, func(t *testing.T) {
+			ctx := context.Background()
+			registryClient, err := connection.NewClient(ctx)
+			if err != nil {
+				t.Logf("Failed to create client: %+v", err)
+				t.FailNow()
+			}
+			defer registryClient.Close()
+			adminClient, err := connection.NewAdminClient(ctx)
+			if err != nil {
+				t.Logf("Failed to create client: %+v", err)
+				t.FailNow()
+			}
+			defer adminClient.Close()
+
+			test.setup(ctx, registryClient, adminClient)
+
+			manifest := &rpc.Manifest{
+				Id: "controller-test",
+				GeneratedResources: []*rpc.GeneratedResource{
+					{
+						Pattern: "apis/-/versions/-/specs/-/artifacts/conformance-registry-styleguide",
+						Receipt: true,
+						Dependencies: []*rpc.Dependency{
+							{
+								Pattern: "$resource.spec",
+							},
+							{
+								Pattern: "artifacts/registry-styleguide",
+							},
+						},
+						Action: "registry compute conformance $resource.spec",
+					},
+				},
+			}
+			actions := ProcessManifest(ctx, registryClient, projectID, manifest)
+
+			if diff := cmp.Diff(test.want, actions, sortActions); diff != "" {
+				t.Errorf("ProcessManifest(%+v) returned unexpected diff (-want +got):\n%s", manifest, diff)
+			}
+
+			deleteProject(ctx, adminClient, t, "controller-test")
+		})
+	}
+
+}
diff --git a/cmd/registry/controller/parser_test.go b/cmd/registry/controller/parser_test.go
index 7ede80e..0df6934 100644
--- a/cmd/registry/controller/parser_test.go
+++ b/cmd/registry/controller/parser_test.go
@@ -18,6 +18,7 @@ import (
 	"fmt"
 	"testing"
 
+	"github.com/apigee/registry/rpc"
 	"github.com/apigee/registry/server/registry/names"
 )
 
@@ -140,30 +141,30 @@ func TestResourceNameFromParent(t *testing.T) {
 		desc            string
 		resourcePattern string
 		parent          string
-		want            ResourceName
+		want            resourceName
 	}{
 		{
 			desc:            "version pattern",
 			resourcePattern: "projects/demo/locations/global/apis/-/versions/1.0.0",
 			parent:          "projects/demo/locations/global/apis/petstore",
-			want: VersionName{
-				Version: generateVersion(t, "projects/demo/locations/global/apis/petstore/versions/1.0.0"),
+			want: versionName{
+				version: generateVersion(t, "projects/demo/locations/global/apis/petstore/versions/1.0.0"),
 			},
 		},
 		{
 			desc:            "spec pattern",
 			resourcePattern: "projects/demo/locations/global/apis/-/versions/-/specs/openapi.yaml",
 			parent:          "projects/demo/locations/global/apis/petstore/versions/1.0.0",
-			want: SpecName{
-				Spec: generateSpec(t, "projects/demo/locations/global/apis/petstore/versions/1.0.0/specs/openapi.yaml"),
+			want: specName{
+				spec: generateSpec(t, "projects/demo/locations/global/apis/petstore/versions/1.0.0/specs/openapi.yaml"),
 			},
 		},
 		{
 			desc:            "artifact pattern",
 			resourcePattern: "projects/demo/locations/global/apis/-/versions/-/specs/-/artifacts/complexity",
 			parent:          "projects/demo/locations/global/apis/petstore/versions/1.0.0/specs/openapi.yaml",
-			want: ArtifactName{
-				Artifact: generateArtifact(t, "projects/demo/locations/global/apis/petstore/versions/1.0.0/specs/openapi.yaml/artifacts/complexity"),
+			want: artifactName{
+				artifact: generateArtifact(t, "projects/demo/locations/global/apis/petstore/versions/1.0.0/specs/openapi.yaml/artifacts/complexity"),
 			},
 		},
 	}
@@ -215,46 +216,46 @@ func TestGetEntityKey(t *testing.T) {
 	tests := []struct {
 		desc     string
 		pattern  string
-		resource ResourceInstance
+		resource resourceInstance
 		want     string
 	}{
 		{
 			desc:    "api group",
 			pattern: "$resource.api/versions/-/specs/-",
-			resource: SpecResource{
-				SpecName: SpecName{Spec: generateSpec(t, "projects/demo/locations/global/apis/petstore/versions/1.0.0/specs/openapi.yaml")},
+			resource: specResource{
+				specName: specName{spec: generateSpec(t, "projects/demo/locations/global/apis/petstore/versions/1.0.0/specs/openapi.yaml")},
 			},
 			want: "projects/demo/locations/global/apis/petstore",
 		},
 		{
 			desc:    "version group",
 			pattern: "$resource.version/specs/-",
-			resource: SpecResource{
-				SpecName: SpecName{Spec: generateSpec(t, "projects/demo/locations/global/apis/petstore/versions/1.0.0/specs/openapi.yaml")},
+			resource: specResource{
+				specName: specName{spec: generateSpec(t, "projects/demo/locations/global/apis/petstore/versions/1.0.0/specs/openapi.yaml")},
 			},
 			want: "projects/demo/locations/global/apis/petstore/versions/1.0.0",
 		},
 		{
 			desc:    "spec group",
 			pattern: "$resource.spec",
-			resource: SpecResource{
-				SpecName: SpecName{Spec: generateSpec(t, "projects/demo/locations/global/apis/petstore/versions/1.0.0/specs/openapi.yaml")},
+			resource: specResource{
+				specName: specName{spec: generateSpec(t, "projects/demo/locations/global/apis/petstore/versions/1.0.0/specs/openapi.yaml")},
 			},
 			want: "projects/demo/locations/global/apis/petstore/versions/1.0.0/specs/openapi.yaml",
 		},
 		{
 			desc:    "artifact group",
 			pattern: "$resource.artifact",
-			resource: ArtifactResource{
-				ArtifactName: ArtifactName{Artifact: generateArtifact(t, "projects/demo/locations/global/apis/petstore/versions/1.0.0/specs/openapi.yaml/artifacts/lint-gnostic")},
+			resource: artifactResource{
+				artifactName: artifactName{artifact: generateArtifact(t, "projects/demo/locations/global/apis/petstore/versions/1.0.0/specs/openapi.yaml/artifacts/lint-gnostic")},
 			},
 			want: "projects/demo/locations/global/apis/petstore/versions/1.0.0/specs/openapi.yaml/artifacts/lint-gnostic",
 		},
 		{
 			desc:    "no group",
 			pattern: "apis/-/versions/-/specs/-",
-			resource: ArtifactResource{
-				ArtifactName: ArtifactName{Artifact: generateArtifact(t, "projects/demo/locations/global/apis/petstore/versions/1.0.0/specs/openapi.yaml/artifacts/lint-gnostic")},
+			resource: artifactResource{
+				artifactName: artifactName{artifact: generateArtifact(t, "projects/demo/locations/global/apis/petstore/versions/1.0.0/specs/openapi.yaml/artifacts/lint-gnostic")},
 			},
 			want: "default",
 		},
@@ -262,7 +263,7 @@ func TestGetEntityKey(t *testing.T) {
 
 	for _, test := range tests {
 		t.Run(test.desc, func(t *testing.T) {
-			got, err := getEntityKey(test.pattern, test.resource.GetResourceName())
+			got, err := getEntityKey(test.pattern, test.resource.ResourceName())
 			if err != nil {
 				t.Errorf("getEntityKey returned unexpected error: %s", err)
 			}
@@ -277,34 +278,34 @@ func TestGetEntityKeyError(t *testing.T) {
 	tests := []struct {
 		desc     string
 		pattern  string
-		resource ResourceInstance
+		resource resourceInstance
 	}{
 		{
 			desc:    "typo",
 			pattern: "$resource.apis/versions/-/specs/-",
-			resource: SpecResource{
-				SpecName: SpecName{Spec: generateSpec(t, "projects/demo/locations/global/apis/petstore/versions/1.0.0/specs/openapi.yaml")},
+			resource: specResource{
+				specName: specName{spec: generateSpec(t, "projects/demo/locations/global/apis/petstore/versions/1.0.0/specs/openapi.yaml")},
 			},
 		},
 		{
 			desc:    "incorrect reference",
 			pattern: "$resource.name/versions/-/specs/-",
-			resource: SpecResource{
-				SpecName: SpecName{Spec: generateSpec(t, "projects/demo/locations/global/apis/petstore/versions/1.0.0/specs/openapi.yaml")},
+			resource: specResource{
+				specName: specName{spec: generateSpec(t, "projects/demo/locations/global/apis/petstore/versions/1.0.0/specs/openapi.yaml")},
 			},
 		},
 		{
 			desc:    "incorrect resourceKW",
 			pattern: "$resources.api/versions/-/specs/-",
-			resource: SpecResource{
-				SpecName: SpecName{Spec: generateSpec(t, "projects/demo/locations/global/apis/petstore/versions/1.0.0/specs/openapi.yaml")},
+			resource: specResource{
+				specName: specName{spec: generateSpec(t, "projects/demo/locations/global/apis/petstore/versions/1.0.0/specs/openapi.yaml")},
 			},
 		},
 	}
 
 	for _, test := range tests {
 		t.Run(test.desc, func(t *testing.T) {
-			got, err := getEntityKey(test.pattern, test.resource.GetResourceName())
+			got, err := getEntityKey(test.pattern, test.resource.ResourceName())
 			if err == nil {
 				t.Errorf("expected getEntityKey to return error, got: %q", got)
 			}
@@ -400,3 +401,153 @@ func TestGenerateCommandError(t *testing.T) {
 	}
 
 }
+
+func TestValidateGeneratedResourceEntry(t *testing.T) {
+	tests := []struct {
+		desc              string
+		generatedResource *rpc.GeneratedResource
+	}{
+		{
+			desc: "single entity reference",
+			generatedResource: &rpc.GeneratedResource{
+				Pattern: "apis/-/versions/-/specs/-/artifacts/complexity",
+				Dependencies: []*rpc.Dependency{
+					{
+						Pattern: "$resource.spec",
+					},
+				},
+				Action: "registry compute complexity $resource.spec",
+			},
+		},
+		{
+			desc: "multiple entity reference",
+			generatedResource: &rpc.GeneratedResource{
+				Pattern: "apis/-/versions/-/artifacts/compliance",
+				Dependencies: []*rpc.Dependency{
+					{
+						Pattern: "$resource.version",
+					},
+					{
+						Pattern: "$resource.api/artifacts/recommended-version",
+					},
+				},
+				Action: "registry compute compliance $resource.version $resource.api/artifacts/recommended-version",
+			},
+		},
+		{
+			desc: "present/absent entity reference",
+			generatedResource: &rpc.GeneratedResource{
+				Pattern: "apis/-/versions/-/specs/-/artifacts/conformance",
+				Dependencies: []*rpc.Dependency{
+					{
+						Pattern: "$resource.spec",
+					},
+					{
+						Pattern: "artifacts/registry-styleguide",
+					},
+				},
+				Action: "registry compute conformance $resource.spec",
+			},
+		},
+		{
+			desc: "present/absent entity and multiple reference",
+			generatedResource: &rpc.GeneratedResource{
+				Pattern: "apis/-/versions/-/artifacts/version-summary",
+				Dependencies: []*rpc.Dependency{
+					{
+						Pattern: "$resource.api/versions/-",
+					},
+					{
+						Pattern: "$resource.api/artifacts/prod-version-metadata",
+					},
+					{
+						Pattern: "artifacts/summary-config",
+					},
+				},
+				Action: "registry generate summary $resource.version",
+			},
+		},
+	}
+	for _, test := range tests {
+		t.Run(test.desc, func(t *testing.T) {
+			gotErrs := validateGeneratedResourceEntry("projects/demo/locations/global", test.generatedResource)
+			if len(gotErrs) > 0 {
+				t.Errorf("ValidateGeneratedResourceEntry() returned unexpected errors: %s", gotErrs)
+			}
+		})
+	}
+}
+
+func TestValidateGeneratedResourceEntryError(t *testing.T) {
+	tests := []struct {
+		desc              string
+		generatedResource *rpc.GeneratedResource
+	}{
+		{
+			desc: "invalid target pattern",
+			generatedResource: &rpc.GeneratedResource{
+				Pattern: "apis/-/versions/-/specs/-/complexity", // Correct pattern: apis/-/versions/-/specs/-/artifacts/complexity
+				Dependencies: []*rpc.Dependency{
+					{
+						Pattern: "$resource.spec",
+					},
+				},
+				Action: "registry compute complexity $resource.spec",
+			},
+		},
+		{
+			desc: "no target resource name",
+			generatedResource: &rpc.GeneratedResource{
+				Pattern: "apis/-/versions/-/specs/-", // Correct pattern: apis/-/versions/-/specs/openapi.yaml
+				Dependencies: []*rpc.Dependency{
+					{
+						Pattern: "$resource.spec",
+						Filter:  "mime_type.contains('openapi')",
+					},
+				},
+				Action: "registry generate openapispec $resource.spec",
+			},
+		},
+		{
+			desc: "invalid reference in deps",
+			generatedResource: &rpc.GeneratedResource{
+				Pattern: "apis/-/versions/-/artifacts/version-summary",
+				Dependencies: []*rpc.Dependency{
+					{
+						Pattern: "$resource.version",
+					},
+					{
+						Pattern: "$resource.spec", // Correct pattern: $resource.version/specs/-
+					},
+				},
+				Action: "registry compute conformance $resource.spec",
+			},
+		},
+		{
+			desc: "invalid reference in action",
+			generatedResource: &rpc.GeneratedResource{
+				Pattern: "apis/-/versions/-/artifacts/version-summary",
+				Dependencies: []*rpc.Dependency{
+					{
+						Pattern: "$resource.api/versions/-",
+					},
+					{
+						Pattern: "$resource.api/artifacts/prod-version-metadata",
+					},
+					{
+						Pattern: "artifacts/summary-config",
+					},
+				},
+				Action: "registry generate summary $resource.spec", // Correct pattern: registry generate summary $resource.version
+			},
+		},
+	}
+	for _, test := range tests {
+		t.Run(test.desc, func(t *testing.T) {
+			gotErrs := validateGeneratedResourceEntry("projects/demo/locations/global", test.generatedResource)
+			if len(gotErrs) == 0 {
+				t.Errorf("Expected ValidateGeneratedResourceEntry() to return errors")
+			}
+		})
+	}
+}
diff --git a/cmd/registry/controller/test_helpers.go b/cmd/registry/controller/test_helpers.go
index 44285a9..0716fd3 100644
--- a/cmd/registry/controller/test_helpers.go
+++ b/cmd/registry/controller/test_helpers.go
@@ -24,6 +24,7 @@ import (
 	"github.com/apigee/registry/rpc"
 	"google.golang.org/grpc/codes"
 	"google.golang.org/grpc/status"
+	"google.golang.org/protobuf/proto"
 	"google.golang.org/protobuf/types/known/fieldmaskpb"
 )
 
@@ -162,3 +163,26 @@ func createUpdateArtifact(
 		t.Fatalf("Failed SetArtifact(%v): %s", artifact, err.Error())
 	}
 }
+
+func uploadStyleguide(
+	ctx context.Context,
+	client connection.Client,
+	t *testing.T,
+	projectID string,
+	styleguide *rpc.StyleGuide) {
+	t.Helper()
+	// Upload the styleguide definition to registry
+	styleGuideMarshalled, err := proto.Marshal(styleguide)
+	if err != nil {
+		t.Fatal("Failed to encode style guide")
+	}
+
+	err = core.SetArtifact(ctx, client, &rpc.Artifact{
+		Name:     fmt.Sprintf("projects/%s/locations/global/artifacts/%s", projectID, styleguide.GetId()),
+		MimeType: core.MimeTypeForMessageType("google.cloud.apigeeregistry.applications.v1alpha1.StyleGuide"),
+		Contents: styleGuideMarshalled,
+	})
+	if err != nil {
+		t.Fatalf("Failed to upload styleguide")
+	}
+}