Repository: apigee/registry (go)

Issue:
Registry tool: include `parent` in exported yaml of non-top-level resources
`registry apply` supports a metadata field that specifies the parent of resources that are not top-level (i.e. directly under `project/location`). When exporting these resources with `registry export yaml`, we should include this field so that resources can be exported and reimported. To make exported resources portable across projects, these names should exclude the `project/location` segments. 

Related: this includes verification that all resource types can be exported and reimported using YAML.

Pull request:
Registry tool: improve `export yaml` to support all artifact types and to include artifact parent when needed
Fixes #856

`registry export yaml` now exports all resource types and accepts a `--nested` (`-n`) flag that includes child resources in a "nested" YAML representation of the resource (e.g. `--nested` with an API resource causes the versions, specs, deployments, and artifacts of that API to be included in a single file). Note that `--nested` now is *not* the default.

Exported resources include `parent` metadata when resource parents are not the project/location where the registry resides. `parent` fields exclude the project and location to allow exported resources to be easily moved from one project to another. Also, only the current revisions of specs and deployments are exported (there's no mechanism for importing a revision with a specified revision ID).  

This PR includes command-level tests that verify that `registry export yaml` can be called, but most of the functional verification is in the tests for `registry apply`, which import and reexport a registry and verify that representations match. #863 proposes test restructuring that would move the functional tests to the `patch` package, where YAML i/o is implemented.


Patch:
diff --git a/cmd/registry/cmd/export/yaml.go b/cmd/registry/cmd/export/yaml.go
index bd17ede..1c94fd6 100644
--- a/cmd/registry/cmd/export/yaml.go
+++ b/cmd/registry/cmd/export/yaml.go
@@ -19,7 +19,6 @@ import (
 
 	"github.com/apigee/registry/cmd/registry/core"
 	"github.com/apigee/registry/cmd/registry/patch"
-	"github.com/apigee/registry/log"
 	"github.com/apigee/registry/pkg/connection"
 	"github.com/apigee/registry/rpc"
 	"github.com/apigee/registry/server/registry/names"
@@ -28,21 +27,22 @@ import (
 
 func yamlCommand() *cobra.Command {
 	var jobs int
+	var nested bool
 	cmd := &cobra.Command{
 		Use:   "yaml RESOURCE",
 		Short: "Export a subtree of the registry as YAML",
 		Args:  cobra.ExactArgs(1),
-		Run: func(cmd *cobra.Command, args []string) {
+		RunE: func(cmd *cobra.Command, args []string) error {
 			ctx := cmd.Context()
 			c, err := connection.ActiveConfig()
 			if err != nil {
-				log.FromContext(ctx).WithError(err).Fatal("Failed to get config")
+				return err
 			}
 			args[0] = c.FQName(args[0])
 
 			client, err := connection.NewRegistryClientWithSettings(ctx, c)
 			if err != nil {
-				log.FromContext(ctx).WithError(err).Fatal("Failed to get client")
+				return err
 			}
 
 			taskQueue, wait := core.WorkerPool(ctx, jobs)
@@ -51,38 +51,75 @@ func yamlCommand() *cobra.Command {
 			if project, err := names.ParseProject(args[0]); err == nil {
 				err = patch.ExportProject(ctx, client, project, taskQueue)
 				if err != nil {
-					log.FromContext(ctx).WithError(err).Fatal("Failed to export project YAML")
+					return err
 				}
 			} else if api, err := names.ParseApi(c.FQName(args[0])); err == nil {
 				err = core.GetAPI(ctx, client, api, func(message *rpc.Api) error {
-					bytes, _, err := patch.ExportAPI(ctx, client, message)
+					bytes, _, err := patch.ExportAPI(ctx, client, message, nested)
 					if err != nil {
-						log.FromContext(ctx).WithError(err).Fatal("Failed to export API")
+						return err
 					}
-					fmt.Println(string(bytes))
-					return nil
+					_, err = cmd.OutOrStdout().Write(bytes)
+					return err
 				})
 				if err != nil {
-					log.FromContext(ctx).WithError(err).Fatal("Failed to export API YAML")
+					return err
+				}
+			} else if version, err := names.ParseVersion(c.FQName(args[0])); err == nil {
+				err = core.GetVersion(ctx, client, version, func(message *rpc.ApiVersion) error {
+					bytes, _, err := patch.ExportAPIVersion(ctx, client, message, nested)
+					if err != nil {
+						return err
+					}
+					_, err = cmd.OutOrStdout().Write(bytes)
+					return err
+				})
+				if err != nil {
+					return err
+				}
+			} else if spec, err := names.ParseSpec(c.FQName(args[0])); err == nil {
+				err = core.GetSpec(ctx, client, spec, false, func(message *rpc.ApiSpec) error {
+					bytes, _, err := patch.ExportAPISpec(ctx, client, message, nested)
+					if err != nil {
+						return err
+					}
+					_, err = cmd.OutOrStdout().Write(bytes)
+					return err
+				})
+				if err != nil {
+					return err
+				}
+			} else if deployment, err := names.ParseDeployment(c.FQName(args[0])); err == nil {
+				err = core.GetDeployment(ctx, client, deployment, func(message *rpc.ApiDeployment) error {
+					bytes, _, err := patch.ExportAPIDeployment(ctx, client, message, nested)
+					if err != nil {
+						return err
+					}
+					_, err = cmd.OutOrStdout().Write(bytes)
+					return err
+				})
+				if err != nil {
+					return err
 				}
 			} else if artifact, err := names.ParseArtifact(c.FQName(args[0])); err == nil {
 				err = core.GetArtifact(ctx, client, artifact, false, func(message *rpc.Artifact) error {
 					bytes, _, err := patch.ExportArtifact(ctx, client, message)
 					if err != nil {
-						log.FromContext(ctx).WithError(err).Fatal("Failed to export artifact")
+						return err
 					}
-
-					fmt.Println(string(bytes))
-					return nil
+					_, err = cmd.OutOrStdout().Write(bytes)
+					return err
 				})
 				if err != nil {
-					log.FromContext(ctx).WithError(err).Fatal("Failed to export artifact YAML")
+					return err
 				}
 			} else {
-				log.Fatalf(ctx, "Unsupported entity %+s", args[0])
+				return fmt.Errorf("Unsupported entity %+s", args[0])
 			}
+			return nil
 		},
 	}
 	cmd.Flags().IntVarP(&jobs, "jobs", "j", 10, "Number of file exports to perform simultaneously")
+	cmd.Flags().BoolVarP(&nested, "nested", "n", false, "Nest child resources in parents")
 	return cmd
 }
diff --git a/cmd/registry/patch/api.go b/cmd/registry/patch/api.go
index 1906a54..efbd3da 100644
--- a/cmd/registry/patch/api.go
+++ b/cmd/registry/patch/api.go
@@ -28,7 +28,7 @@ import (
 	"gopkg.in/yaml.v3"
 )
 
-func newApi(ctx context.Context, client *gapic.RegistryClient, message *rpc.Api) (*models.Api, error) {
+func newApi(ctx context.Context, client *gapic.RegistryClient, message *rpc.Api, nested bool) (*models.Api, error) {
 	apiName, err := names.ParseApi(message.Name)
 	if err != nil {
 		return nil, err
@@ -41,54 +41,48 @@ func newApi(ctx context.Context, client *gapic.RegistryClient, message *rpc.Api)
 	if err != nil {
 		return nil, err
 	}
-	versions := make([]*models.ApiVersion, 0)
-	if err = core.ListVersions(ctx, client, apiName.Version("-"), "", func(message *rpc.ApiVersion) error {
-		var version *models.ApiVersion
-		version, err := newApiVersion(ctx, client, message)
-		if err != nil {
-			return err
-		}
-		// unset these because they can be inferred
-		version.ApiVersion = ""
-		version.Kind = ""
-		versions = append(versions, version)
-		return nil
-	}); err != nil {
-		return nil, err
-	}
-	deployments := make([]*models.ApiDeployment, 0)
-	if err = core.ListDeployments(ctx, client, apiName.Deployment("-"), "", func(message *rpc.ApiDeployment) error {
-		var deployment *models.ApiDeployment
-		deployment, err = newApiDeployment(message)
-		if err != nil {
-			return err
-		}
-		// unset these because they can be inferred
-		deployment.ApiVersion = ""
-		deployment.Kind = ""
-		deployments = append(deployments, deployment)
-		return nil
-	}); err != nil {
-		return nil, err
-	}
-	artifacts := make([]*models.Artifact, 0)
-	if err = core.ListArtifacts(ctx, client, apiName.Artifact("-"), "", true, func(message *rpc.Artifact) error {
-		var artifact *models.Artifact
-		artifact, err = newArtifact(message)
-		if err != nil {
-			log.FromContext(ctx).Warnf("Skipping %s: %s", message.Name, err)
+	var versions []*models.ApiVersion
+	var deployments []*models.ApiDeployment
+	var artifacts []*models.Artifact
+	if nested {
+		versions = make([]*models.ApiVersion, 0)
+		if err = core.ListVersions(ctx, client, apiName.Version("-"), "", func(message *rpc.ApiVersion) error {
+			var version *models.ApiVersion
+			version, err := newApiVersion(ctx, client, message, true)
+			if err != nil {
+				return err
+			}
+			// unset these because they can be inferred
+			version.ApiVersion = ""
+			version.Kind = ""
+			version.Metadata.Parent = ""
+			versions = append(versions, version)
 			return nil
+		}); err != nil {
+			return nil, err
 		}
-		if artifact.Kind == "Artifact" { // "Artifact" is the generic artifact type
-			log.FromContext(ctx).Warnf("Skipping %s", message.Name)
+		deployments = make([]*models.ApiDeployment, 0)
+		if err = core.ListDeployments(ctx, client, apiName.Deployment("-"), "", func(message *rpc.ApiDeployment) error {
+			var deployment *models.ApiDeployment
+			deployment, err = newApiDeployment(ctx, client, message, true)
+			if err != nil {
+				return err
+			}
+			// unset these because they can be inferred
+			deployment.ApiVersion = ""
+			deployment.Kind = ""
+			deployment.Metadata.Parent = ""
+			deployments = append(deployments, deployment)
 			return nil
+		}); err != nil {
+			return nil, err
+		}
+		artifacts, err = collectChildArtifacts(ctx, client, apiName.Artifact("-"))
+		if err != nil {
+			return nil, err
 		}
-		artifact.ApiVersion = ""
-		artifacts = append(artifacts, artifact)
-		return nil
-	}); err != nil {
-		return nil, err
 	}
+
 	return &models.Api{
 		Header: models.Header{
 			ApiVersion: RegistryV1,
@@ -112,9 +106,32 @@ func newApi(ctx context.Context, client *gapic.RegistryClient, message *rpc.Api)
 	}, err
 }
 
+func collectChildArtifacts(ctx context.Context, client *gapic.RegistryClient, artifactPattern names.Artifact) ([]*models.Artifact, error) {
+	artifacts := make([]*models.Artifact, 0)
+	if err := core.ListArtifacts(ctx, client, artifactPattern, "", true, func(message *rpc.Artifact) error {
+		artifact, err := newArtifact(message)
+		if err != nil {
+			log.FromContext(ctx).Warnf("Skipping %s: %s", message.Name, err)
+			return nil
+		}
+		if artifact.Kind == "Artifact" { // "Artifact" is the generic artifact type
+			log.FromContext(ctx).Warnf("Skipping %s", message.Name)
+			return nil
+		}
+		// unset these because they can be inferred
+		artifact.ApiVersion = ""
+		artifact.Metadata.Parent = ""
+		artifacts = append(artifacts, artifact)
+		return nil
+	}); err != nil {
+		return nil, err
+	}
+	return artifacts, nil
+}
+
 // ExportAPI allows an API to be individually exported as a YAML file.
-func ExportAPI(ctx context.Context, client *gapic.RegistryClient, message *rpc.Api) ([]byte, *models.Header, error) {
-	api, err := newApi(ctx, client, message)
+func ExportAPI(ctx context.Context, client *gapic.RegistryClient, message *rpc.Api, nested bool) ([]byte, *models.Header, error) {
+	api, err := newApi(ctx, client, message, nested)
 	if err != nil {
 		return nil, nil, err
 	}
diff --git a/cmd/registry/patch/artifact.go b/cmd/registry/patch/artifact.go
index f11035e..befb96e 100644
--- a/cmd/registry/patch/artifact.go
+++ b/cmd/registry/patch/artifact.go
@@ -146,6 +146,7 @@ func newArtifact(message *rpc.Artifact) (*models.Artifact, error) {
 			Kind:       kindForMimeType(message.MimeType),
 			Metadata: models.Metadata{
 				Name:        artifactName.ArtifactID(),
+				Parent:      names.ExportableName(artifactName.Parent(), artifactName.ProjectID()),
 				Labels:      message.Labels,
 				Annotations: message.Annotations,
 			},
diff --git a/cmd/registry/patch/deployment.go b/cmd/registry/patch/deployment.go
index 603d1e9..2ad94d8 100644
--- a/cmd/registry/patch/deployment.go
+++ b/cmd/registry/patch/deployment.go
@@ -15,9 +15,11 @@
 package patch
 
 import (
+	"bytes"
 	"context"
 	"strings"
 
+	"github.com/apigee/registry/gapic"
 	"github.com/apigee/registry/pkg/connection"
 	"github.com/apigee/registry/pkg/models"
 	"github.com/apigee/registry/rpc"
@@ -25,6 +27,20 @@ import (
 	"gopkg.in/yaml.v3"
 )
 
+// ExportAPIDeployment allows an API deployment to be individually exported as a YAML file.
+func ExportAPIDeployment(ctx context.Context, client *gapic.RegistryClient, message *rpc.ApiDeployment, nested bool) ([]byte, *models.Header, error) {
+	api, err := newApiDeployment(ctx, client, message, nested)
+	if err != nil {
+		return nil, nil, err
+	}
+	var b bytes.Buffer
+	err = yamlEncoder(&b).Encode(api)
+	if err != nil {
+		return nil, nil, err
+	}
+	return b.Bytes(), &api.Header, nil
+}
+
 // relativeSpecRevisionName returns the versionid+specid if the spec is within the specified API
 func relativeSpecRevisionName(apiName names.Api, spec string) string {
 	if spec == "" {
@@ -44,18 +60,26 @@ func optionalSpecRevisionName(deploymentName names.Deployment, subpath string) s
 	return deploymentName.Api().String() + "/versions/" + subpath
 }
 
-func newApiDeployment(message *rpc.ApiDeployment) (*models.ApiDeployment, error) {
+func newApiDeployment(ctx context.Context, client *gapic.RegistryClient, message *rpc.ApiDeployment, nested bool) (*models.ApiDeployment, error) {
 	deploymentName, err := names.ParseDeployment(message.Name)
 	if err != nil {
 		return nil, err
 	}
 	revisionName := relativeSpecRevisionName(deploymentName.Api(), message.ApiSpecRevision)
+	var artifacts []*models.Artifact
+	if nested {
+		artifacts, err = collectChildArtifacts(ctx, client, deploymentName.Artifact("-"))
+		if err != nil {
+			return nil, err
+		}
+	}
 	return &models.ApiDeployment{
 		Header: models.Header{
 			ApiVersion: RegistryV1,
-			Kind:       "ApiDeployment",
+			Kind:       "Deployment",
 			Metadata: models.Metadata{
 				Name:        deploymentName.DeploymentID,
+				Parent:      names.ExportableName(deploymentName.Parent(), deploymentName.ProjectID),
 				Labels:      message.Labels,
 				Annotations: message.Annotations,
 			},
@@ -68,6 +92,7 @@ func newApiDeployment(message *rpc.ApiDeployment) (*models.ApiDeployment, error)
 			IntendedAudience:   message.IntendedAudience,
 			AccessGuidance:     message.AccessGuidance,
 			ApiSpecRevision:    revisionName,
+			Artifacts:          artifacts,
 		},
 	}, nil
 }
@@ -117,5 +142,14 @@ func applyApiDeploymentPatch(
 		return err
 	}
 	_, err = client.UpdateApiDeployment(ctx, req)
-	return err
+	if err != nil {
+		return err
+	}
+	for _, artifactPatch := range deployment.Data.Artifacts {
+		err = applyArtifactPatch(ctx, client, artifactPatch, name.String())
+		if err != nil {
+			return err
+		}
+	}
+	return nil
 }
diff --git a/cmd/registry/patch/project.go b/cmd/registry/patch/project.go
index e02082d..f03caed 100644
--- a/cmd/registry/patch/project.go
+++ b/cmd/registry/patch/project.go
@@ -72,7 +72,7 @@ func (task *exportAPITask) String() string {
 }
 
 func (task *exportAPITask) Run(ctx context.Context) error {
-	bytes, header, err := ExportAPI(ctx, task.client, task.message)
+	bytes, header, err := ExportAPI(ctx, task.client, task.message, true)
 	if err != nil {
 		return err
 	}
diff --git a/cmd/registry/patch/spec.go b/cmd/registry/patch/spec.go
index 808efe5..846009f 100644
--- a/cmd/registry/patch/spec.go
+++ b/cmd/registry/patch/spec.go
@@ -15,6 +15,7 @@
 package patch
 
 import (
+	"bytes"
 	"context"
 	"io"
 	"net/http"
@@ -24,6 +25,7 @@ import (
 	"strings"
 
 	"github.com/apigee/registry/cmd/registry/core"
+	"github.com/apigee/registry/gapic"
 	"github.com/apigee/registry/pkg/connection"
 	"github.com/apigee/registry/pkg/models"
 	"github.com/apigee/registry/rpc"
@@ -31,17 +33,39 @@ import (
 	"gopkg.in/yaml.v3"
 )
 
-func newApiSpec(message *rpc.ApiSpec) (*models.ApiSpec, error) {
+// ExportAPISpec allows an API spec to be individually exported as a YAML file.
+func ExportAPISpec(ctx context.Context, client *gapic.RegistryClient, message *rpc.ApiSpec, nested bool) ([]byte, *models.Header, error) {
+	api, err := newApiSpec(ctx, client, message, nested)
+	if err != nil {
+		return nil, nil, err
+	}
+	var b bytes.Buffer
+	err = yamlEncoder(&b).Encode(api)
+	if err != nil {
+		return nil, nil, err
+	}
+	return b.Bytes(), &api.Header, nil
+}
+
+func newApiSpec(ctx context.Context, client *gapic.RegistryClient, message *rpc.ApiSpec, nested bool) (*models.ApiSpec, error) {
 	specName, err := names.ParseSpec(message.Name)
 	if err != nil {
 		return nil, err
 	}
+	var artifacts []*models.Artifact
+	if nested {
+		artifacts, err = collectChildArtifacts(ctx, client, specName.Artifact("-"))
+		if err != nil {
+			return nil, err
+		}
+	}
 	return &models.ApiSpec{
 		Header: models.Header{
 			ApiVersion: RegistryV1,
-			Kind:       "ApiSpec",
+			Kind:       "Spec",
 			Metadata: models.Metadata{
 				Name:        specName.SpecID,
+				Parent:      names.ExportableName(specName.Parent(), specName.ProjectID),
 				Labels:      message.Labels,
 				Annotations: message.Annotations,
 			},
@@ -51,6 +75,7 @@ func newApiSpec(message *rpc.ApiSpec) (*models.ApiSpec, error) {
 			Description: message.Description,
 			MimeType:    message.MimeType,
 			SourceURI:   message.SourceUri,
+			Artifacts:   artifacts,
 		},
 	}, nil
 }
@@ -159,5 +184,14 @@ func applyApiSpecPatch(
 		}
 	}
 	_, err = client.UpdateApiSpec(ctx, req)
-	return err
+	if err != nil {
+		return err
+	}
+	for _, artifactPatch := range spec.Data.Artifacts {
+		err = applyArtifactPatch(ctx, client, artifactPatch, name.String())
+		if err != nil {
+			return err
+		}
+	}
+	return nil
 }
diff --git a/cmd/registry/patch/version.go b/cmd/registry/patch/version.go
index 97dd8bf..071f8b0 100644
--- a/cmd/registry/patch/version.go
+++ b/cmd/registry/patch/version.go
@@ -15,6 +15,7 @@
 package patch
 
 import (
+	"bytes"
 	"context"
 
 	"github.com/apigee/registry/cmd/registry/core"
@@ -26,33 +27,56 @@ import (
 	"gopkg.in/yaml.v3"
 )
 
-func newApiVersion(ctx context.Context, client *gapic.RegistryClient, message *rpc.ApiVersion) (*models.ApiVersion, error) {
+// ExportAPIVersion allows an API version to be individually exported as a YAML file.
+func ExportAPIVersion(ctx context.Context, client *gapic.RegistryClient, message *rpc.ApiVersion, nested bool) ([]byte, *models.Header, error) {
+	api, err := newApiVersion(ctx, client, message, nested)
+	if err != nil {
+		return nil, nil, err
+	}
+	var b bytes.Buffer
+	err = yamlEncoder(&b).Encode(api)
+	if err != nil {
+		return nil, nil, err
+	}
+	return b.Bytes(), &api.Header, nil
+}
+
+func newApiVersion(ctx context.Context, client *gapic.RegistryClient, message *rpc.ApiVersion, nested bool) (*models.ApiVersion, error) {
 	versionName, err := names.ParseVersion(message.Name)
 	if err != nil {
 		return nil, err
 	}
 
-	specs := make([]*models.ApiSpec, 0)
-	if err = core.ListSpecs(ctx, client, versionName.Spec("-"), "", func(message *rpc.ApiSpec) error {
-		spec, err := newApiSpec(message)
+	var specs []*models.ApiSpec
+	var artifacts []*models.Artifact
+	if nested {
+		specs = make([]*models.ApiSpec, 0)
+		if err = core.ListSpecs(ctx, client, versionName.Spec("-"), "", func(message *rpc.ApiSpec) error {
+			spec, err := newApiSpec(ctx, client, message, true)
+			if err != nil {
+				return err
+			}
+			// unset these because they can be inferred
+			spec.ApiVersion = ""
+			spec.Kind = ""
+			spec.Metadata.Parent = ""
+			specs = append(specs, spec)
+			return nil
+		}); err != nil {
+			return nil, err
+		}
+		artifacts, err = collectChildArtifacts(ctx, client, versionName.Artifact("-"))
 		if err != nil {
-			return err
+			return nil, err
 		}
-		// unset these because they can be inferred
-		spec.ApiVersion = ""
-		spec.Kind = ""
-		specs = append(specs, spec)
-		return nil
-	}); err != nil {
-		return nil, err
 	}
-
 	return &models.ApiVersion{
 		Header: models.Header{
 			ApiVersion: RegistryV1,
-			Kind:       "ApiVersion",
+			Kind:       "Version",
 			Metadata: models.Metadata{
 				Name:        versionName.VersionID,
+				Parent:      names.ExportableName(versionName.Parent(), versionName.ProjectID),
 				Labels:      message.Labels,
 				Annotations: message.Annotations,
 			},
@@ -62,6 +86,7 @@ func newApiVersion(ctx context.Context, client *gapic.RegistryClient, message *r
 			Description: message.Description,
 			State:       message.State,
 			ApiSpecs:    specs,
+			Artifacts:   artifacts,
 		},
 	}, nil
 }
@@ -117,5 +142,11 @@ func applyApiVersionPatch(
 			return err
 		}
 	}
+	for _, artifactPatch := range version.Data.Artifacts {
+		err = applyArtifactPatch(ctx, client, artifactPatch, name.String())
+		if err != nil {
+			return err
+		}
+	}
 	return nil
 }
diff --git a/pkg/models/deployment.go b/pkg/models/deployment.go
index dfc5a2e..3ce3862 100644
--- a/pkg/models/deployment.go
+++ b/pkg/models/deployment.go
@@ -20,11 +20,12 @@ type ApiDeployment struct {
 }
 
 type ApiDeploymentData struct {
-	DisplayName        string `yaml:"displayName,omitempty"`
-	Description        string `yaml:"description,omitempty"`
-	ApiSpecRevision    string `yaml:"apiSpecRevision,omitempty"`
-	EndpointURI        string `yaml:"endpointURI,omitempty"`
-	ExternalChannelURI string `yaml:"externalChannelURI,omitempty"`
-	IntendedAudience   string `yaml:"intendedAudience,omitempty"`
-	AccessGuidance     string `yaml:"accessGuidance,omitempty"`
+	DisplayName        string      `yaml:"displayName,omitempty"`
+	Description        string      `yaml:"description,omitempty"`
+	ApiSpecRevision    string      `yaml:"apiSpecRevision,omitempty"`
+	EndpointURI        string      `yaml:"endpointURI,omitempty"`
+	ExternalChannelURI string      `yaml:"externalChannelURI,omitempty"`
+	IntendedAudience   string      `yaml:"intendedAudience,omitempty"`
+	AccessGuidance     string      `yaml:"accessGuidance,omitempty"`
+	Artifacts          []*Artifact `yaml:"artifacts,omitempty"`
 }
diff --git a/pkg/models/spec.go b/pkg/models/spec.go
index 6459b25..e9eddf6 100644
--- a/pkg/models/spec.go
+++ b/pkg/models/spec.go
@@ -20,8 +20,9 @@ type ApiSpec struct {
 }
 
 type ApiSpecData struct {
-	FileName    string `yaml:"filename,omitempty"`
-	Description string `yaml:"description,omitempty"`
-	MimeType    string `yaml:"mimeType,omitempty"`
-	SourceURI   string `yaml:"sourceURI,omitempty"`
+	FileName    string      `yaml:"filename,omitempty"`
+	Description string      `yaml:"description,omitempty"`
+	MimeType    string      `yaml:"mimeType,omitempty"`
+	SourceURI   string      `yaml:"sourceURI,omitempty"`
+	Artifacts   []*Artifact `yaml:"artifacts,omitempty"`
 }
diff --git a/pkg/models/version.go b/pkg/models/version.go
index 95a647c..d2d7844 100644
--- a/pkg/models/version.go
+++ b/pkg/models/version.go
@@ -20,8 +20,9 @@ type ApiVersion struct {
 }
 
 type ApiVersionData struct {
-	DisplayName string     `yaml:"displayName,omitempty"`
-	Description string     `yaml:"description,omitempty"`
-	State       string     `yaml:"state,omitempty"`
-	ApiSpecs    []*ApiSpec `yaml:"specs,omitempty"`
+	DisplayName string      `yaml:"displayName,omitempty"`
+	Description string      `yaml:"description,omitempty"`
+	State       string      `yaml:"state,omitempty"`
+	ApiSpecs    []*ApiSpec  `yaml:"specs,omitempty"`
+	Artifacts   []*Artifact `yaml:"artifacts,omitempty"`
 }
diff --git a/server/registry/names/common.go b/server/registry/names/common.go
index 710d09e..d046b32 100644
--- a/server/registry/names/common.go
+++ b/server/registry/names/common.go
@@ -85,3 +85,27 @@ const Location = "global"
 type Name interface {
 	String() string // all names have a string representation.
 }
+
+// ExportableName returns a name suitable for export.
+// Its project and location are removed, making it project-local,
+// and any revision ID is removed since revision ids cannot be imported.
+//
+// Note that currently revision ids are only removed from trailing segments.
+// That's all that we need now, but support for removing internal revision ids
+// could be added if needed.
+//
+// Also note: this would be a nice function to add to the Name interface
+// but that might require improving the regularity of access to ProjectIDs
+// for different types of Names.
+func ExportableName(name string, projectID string) string {
+	// first remove the located project
+	name = strings.TrimPrefix(name, "projects/"+projectID+"/locations/global")
+	// if there's anything left, trim the leading slash
+	name = strings.TrimPrefix(name, "/")
+	// if there's a revision id, remove it (we only export the current revisions)
+	parts := strings.Split(name, "@")
+	if len(parts) > 1 {
+		name = parts[0]
+	}
+	return name
+}


Test patch:
diff --git a/cmd/registry/cmd/apply/apply_test.go b/cmd/registry/cmd/apply/apply_test.go
index 7394376..0b18cd3 100644
--- a/cmd/registry/cmd/apply/apply_test.go
+++ b/cmd/registry/cmd/apply/apply_test.go
@@ -18,6 +18,7 @@ import (
 	"context"
 	"fmt"
 	"os"
+	"strings"
 	"testing"
 
 	"github.com/apigee/registry/cmd/registry/patch"
@@ -96,7 +97,7 @@ func TestApply(t *testing.T) {
 			t.Fatalf("Failed to verify API existence: %s", err)
 		}
 
-		actual, _, err := patch.ExportAPI(ctx, registryClient, got)
+		actual, _, err := patch.ExportAPI(ctx, registryClient, got, true)
 		if err != nil {
 			t.Fatalf("ExportApi(%+v) returned an error: %s", got, err)
 		}
@@ -226,7 +227,7 @@ func TestApplyProject(t *testing.T) {
 				t.Fatalf("Failed to get API: %s", err)
 			}
 
-			actual, _, err := patch.ExportAPI(ctx, registryClient, got)
+			actual, _, err := patch.ExportAPI(ctx, registryClient, got, true)
 			if err != nil {
 				t.Fatalf("ExportApi(%+v) returned an error: %s", got, err)
 			}
@@ -271,3 +272,174 @@ func TestApplyProject(t *testing.T) {
 		})
 	}
 }
+
+func TestUnbundledExport(t *testing.T) {
+	ctx := context.Background()
+	adminClient, err := connection.NewAdminClient(ctx)
+	if err != nil {
+		t.Fatalf("Setup: failed to create client: %+v", err)
+	}
+	defer adminClient.Close()
+	project := names.Project{ProjectID: "apply-project-test"}
+	if err = adminClient.DeleteProject(ctx, &rpc.DeleteProjectRequest{
+		Name:  project.String(),
+		Force: true,
+	}); err != nil && status.Code(err) != codes.NotFound {
+		t.Errorf("Setup: failed to delete test project: %s", err)
+	}
+
+	if _, err := adminClient.CreateProject(ctx, &rpc.CreateProjectRequest{
+		ProjectId: project.ProjectID,
+		Project:   &rpc.Project{},
+	}); err != nil {
+		t.Fatalf("Setup: Failed to create test project: %s", err)
+	}
+
+	// set the configured registry.project to the test project
+	config, err := connection.ActiveConfig()
+	if err != nil {
+		t.Fatalf("Setup: Failed to get registry configuration: %s", err)
+	}
+	config.Project = project.ProjectID
+	connection.SetConfig(config)
+
+	registryClient, err := connection.NewRegistryClient(ctx)
+	if err != nil {
+		t.Fatalf("Setup: Failed to create registry client: %s", err)
+	}
+	defer registryClient.Close()
+
+	sampleDir := "testdata/unbundled-flat"
+
+	cmd := Command()
+	cmd.SetArgs([]string{"-f", sampleDir, "-R"})
+	if err := cmd.Execute(); err != nil {
+		t.Fatalf("Execute() with args %+v returned error: %s", cmd.Args, err)
+	}
+	artifacts := []string{
+		"artifacts/lifecycle",
+		"artifacts/manifest",
+		"artifacts/styleguide",
+		"artifacts/taxonomies",
+		"apis/registry/artifacts/api-references",
+		"apis/registry/deployments/prod/artifacts/deployment-references",
+		"apis/registry/versions/v1/artifacts/version-references",
+		"apis/registry/versions/v1/specs/openapi/artifacts/spec-references",
+	}
+	for _, a := range artifacts {
+		t.Run("read "+a, func(t *testing.T) {
+			filename := fmt.Sprintf("%s/%s.yaml", sampleDir, strings.ReplaceAll(a, "/", "-"))
+			expected, err := os.ReadFile(filename)
+			if err != nil {
+				t.Fatalf("Failed to read artifact YAML %s", err)
+			}
+			message, err := registryClient.GetArtifact(ctx, &rpc.GetArtifactRequest{
+				Name: "projects/apply-project-test/locations/global/" + a,
+			})
+			if status.Code(err) == codes.NotFound {
+				t.Fatalf("Expected artifact doesn't exist: %s", err)
+			} else if err != nil {
+				t.Fatalf("Failed to verify artifact existence: %s", err)
+			}
+			actual, _, err := patch.ExportArtifact(ctx, registryClient, message)
+			if err != nil {
+				t.Fatalf("ExportArtifact(%+v) returned an error: %s", message, err)
+			}
+			if diff := cmp.Diff(expected, actual); diff != "" {
+				t.Errorf("GetArtifact(%q) returned unexpected diff: (-want +got):\n%s", message, diff)
+			}
+		})
+	}
+	s := "apis/registry/versions/v1/specs/openapi"
+	t.Run("read "+s, func(t *testing.T) {
+		filename := fmt.Sprintf("%s/%s.yaml", sampleDir, strings.ReplaceAll(s, "/", "-"))
+		expected, err := os.ReadFile(filename)
+		if err != nil {
+			t.Fatalf("Failed to read spec YAML %s", err)
+		}
+		message, err := registryClient.GetApiSpec(ctx, &rpc.GetApiSpecRequest{
+			Name: "projects/apply-project-test/locations/global/" + s,
+		})
+		if status.Code(err) == codes.NotFound {
+			t.Fatalf("Expected spec doesn't exist: %s", err)
+		} else if err != nil {
+			t.Fatalf("Failed to verify spec existence: %s", err)
+		}
+		actual, _, err := patch.ExportAPISpec(ctx, registryClient, message, false)
+		if err != nil {
+			t.Fatalf("ExportAPISpec(%+v) returned an error: %s", message, err)
+		}
+		if diff := cmp.Diff(expected, actual); diff != "" {
+			t.Errorf("ExportAPISpec(%q) returned unexpected diff: (-want +got):\n%s", message, diff)
+		}
+	})
+	v := "apis/registry/versions/v1"
+	t.Run("read "+v, func(t *testing.T) {
+		filename := fmt.Sprintf("%s/%s.yaml", sampleDir, strings.ReplaceAll(v, "/", "-"))
+		expected, err := os.ReadFile(filename)
+		if err != nil {
+			t.Fatalf("Failed to read spec YAML %s", err)
+		}
+		message, err := registryClient.GetApiVersion(ctx, &rpc.GetApiVersionRequest{
+			Name: "projects/apply-project-test/locations/global/" + v,
+		})
+		if status.Code(err) == codes.NotFound {
+			t.Fatalf("Expected version doesn't exist: %s", err)
+		} else if err != nil {
+			t.Fatalf("Failed to verify version existence: %s", err)
+		}
+		actual, _, err := patch.ExportAPIVersion(ctx, registryClient, message, false)
+		if err != nil {
+			t.Fatalf("ExportAPIVersion(%+v) returned an error: %s", message, err)
+		}
+		if diff := cmp.Diff(expected, actual); diff != "" {
+			t.Errorf("ExportAPIVersion(%q) returned unexpected diff: (-want +got):\n%s", message, diff)
+		}
+	})
+	d := "apis/registry/deployments/prod"
+	t.Run("read "+d, func(t *testing.T) {
+		filename := fmt.Sprintf("%s/%s.yaml", sampleDir, strings.ReplaceAll(d, "/", "-"))
+		expected, err := os.ReadFile(filename)
+		if err != nil {
+			t.Fatalf("Failed to read deployment YAML %s", err)
+		}
+		message, err := registryClient.GetApiDeployment(ctx, &rpc.GetApiDeploymentRequest{
+			Name: "projects/apply-project-test/locations/global/" + d,
+		})
+		if status.Code(err) == codes.NotFound {
+			t.Fatalf("Expected version doesn't exist: %s", err)
+		} else if err != nil {
+			t.Fatalf("Failed to verify version existence: %s", err)
+		}
+		actual, _, err := patch.ExportAPIDeployment(ctx, registryClient, message, false)
+		if err != nil {
+			t.Fatalf("ExportAPIDeployment(%+v) returned an error: %s", message, err)
+		}
+		if diff := cmp.Diff(expected, actual); diff != "" {
+			t.Errorf("ExportAPIDeployment(%q) returned unexpected diff: (-want +got):\n%s", message, diff)
+		}
+	})
+	a := "apis/registry"
+	t.Run("read "+a, func(t *testing.T) {
+		filename := fmt.Sprintf("%s/%s.yaml", sampleDir, strings.ReplaceAll(a, "/", "-"))
+		expected, err := os.ReadFile(filename)
+		if err != nil {
+			t.Fatalf("Failed to read api YAML %s", err)
+		}
+		message, err := registryClient.GetApi(ctx, &rpc.GetApiRequest{
+			Name: "projects/apply-project-test/locations/global/" + a,
+		})
+		if status.Code(err) == codes.NotFound {
+			t.Fatalf("Expected api doesn't exist: %s", err)
+		} else if err != nil {
+			t.Fatalf("Failed to verify api existence: %s", err)
+		}
+		actual, _, err := patch.ExportAPI(ctx, registryClient, message, false)
+		if err != nil {
+			t.Fatalf("ExportAPIDeployment(%+v) returned an error: %s", message, err)
+		}
+		if diff := cmp.Diff(expected, actual); diff != "" {
+			t.Errorf("ExportAPIDeployment(%q) returned unexpected diff: (-want +got):\n%s", message, diff)
+		}
+	})
+}
diff --git a/cmd/registry/cmd/apply/testdata/sample/apis/registry.yaml b/cmd/registry/cmd/apply/testdata/sample/apis/registry.yaml
index 159bc6a..312a0bb 100644
--- a/cmd/registry/cmd/apply/testdata/sample/apis/registry.yaml
+++ b/cmd/registry/cmd/apply/testdata/sample/apis/registry.yaml
@@ -25,7 +25,7 @@ data:
         state: Staging
         specs:
           - metadata:
-              name: openapi.yaml
+              name: openapi
               labels:
                 openapi-verified: "true"
               annotations:
@@ -35,6 +35,32 @@ data:
               description: OpenAPI description of the Registry API
               mimeType: application/x.openapi+gzip;version=3
               sourceURI: https://raw.githubusercontent.com/apigee/registry/main/openapi.yaml
+              artifacts:
+                - kind: ReferenceList
+                  metadata:
+                    name: spec-references
+                  data:
+                    displayName: ""
+                    description: spec-related references
+                    references:
+                      - id: github
+                        displayName: GitHub Repo
+                        category: apihub-source-code
+                        resource: ""
+                        uri: https://github.com/apigee/registry
+        artifacts:
+          - kind: ReferenceList
+            metadata:
+              name: version-references
+            data:
+              displayName: ""
+              description: version-related references
+              references:
+                - id: github
+                  displayName: GitHub Repo
+                  category: apihub-source-code
+                  resource: ""
+                  uri: https://github.com/apigee/registry
   deployments:
     - metadata:
         name: prod
@@ -45,22 +71,35 @@ data:
       data:
         displayName: Production
         description: The hosted deployment of the Registry API
-        apiSpecRevision: v1/specs/openapi.yaml@latest
+        apiSpecRevision: v1/specs/openapi@latest
         endpointURI: https://apigeeregistry.googleapis.com
         externalChannelURI: https://apigee.github.io/registry/
         intendedAudience: Public
         accessGuidance: See https://github.com/apigee/registry for tools and usage information.
+        artifacts:
+          - kind: ReferenceList
+            metadata:
+              name: deployment-references
+            data:
+              displayName: ""
+              description: deployment-related references
+              references:
+                - id: github
+                  displayName: GitHub Repo
+                  category: apihub-source-code
+                  resource: ""
+                  uri: https://github.com/apigee/registry
   artifacts:
     - kind: ReferenceList
       metadata:
-        name: apihub-related
+        name: api-references
         labels:
           label1: label
         annotations:
           annotation1: annotation
       data:
         displayName: ""
-        description: Defines a list of related resources
+        description: api-related references
         references:
           - id: github
             displayName: GitHub Repo
diff --git a/cmd/registry/cmd/apply/testdata/unbundled-flat/apis-registry-artifacts-apihub-related.yaml b/cmd/registry/cmd/apply/testdata/unbundled-flat/apis-registry-artifacts-api-references.yaml
similarity index 78%
rename from cmd/registry/cmd/apply/testdata/unbundled-flat/apis-registry-artifacts-apihub-related.yaml
rename to cmd/registry/cmd/apply/testdata/unbundled-flat/apis-registry-artifacts-api-references.yaml
index da382b0..80b941a 100644
--- a/cmd/registry/cmd/apply/testdata/unbundled-flat/apis-registry-artifacts-apihub-related.yaml
+++ b/cmd/registry/cmd/apply/testdata/unbundled-flat/apis-registry-artifacts-api-references.yaml
@@ -1,7 +1,7 @@
 apiVersion: apigeeregistry/v1
 kind: ReferenceList
 metadata:
-  name: apihub-related
+  name: api-references
   parent: apis/registry
   labels:
     label1: label
@@ -9,7 +9,7 @@ metadata:
     annotation1: annotation
 data:
   displayName: ""
-  description: Defines a list of related resources
+  description: api-related references
   references:
     - id: github
       displayName: GitHub Repo
@@ -20,4 +20,4 @@ data:
       displayName: GitHub Documentation
       category: apihub-other
       resource: ""
-      uri: https://apigee.github.io/registry/
\ No newline at end of file
+      uri: https://apigee.github.io/registry/
diff --git a/cmd/registry/cmd/apply/testdata/unbundled-flat/apis-registry-deployments-prod-artifacts-deployment-references.yaml b/cmd/registry/cmd/apply/testdata/unbundled-flat/apis-registry-deployments-prod-artifacts-deployment-references.yaml
new file mode 100644
index 0000000..dfc3590
--- /dev/null
+++ b/cmd/registry/cmd/apply/testdata/unbundled-flat/apis-registry-deployments-prod-artifacts-deployment-references.yaml
@@ -0,0 +1,14 @@
+apiVersion: apigeeregistry/v1
+kind: ReferenceList
+metadata:
+  name: deployment-references
+  parent: apis/registry/deployments/prod
+data:
+  displayName: ""
+  description: deployment-related references
+  references:
+    - id: github
+      displayName: GitHub Repo
+      category: apihub-source-code
+      resource: ""
+      uri: https://github.com/apigee/registry
diff --git a/cmd/registry/cmd/apply/testdata/unbundled-flat/apis-registry-deployments-prod.yaml b/cmd/registry/cmd/apply/testdata/unbundled-flat/apis-registry-deployments-prod.yaml
index 8fed69c..5e6e29c 100644
--- a/cmd/registry/cmd/apply/testdata/unbundled-flat/apis-registry-deployments-prod.yaml
+++ b/cmd/registry/cmd/apply/testdata/unbundled-flat/apis-registry-deployments-prod.yaml
@@ -10,9 +10,8 @@ metadata:
 data:
   displayName: Production
   description: The hosted deployment of the Registry API
-  apiSpecRevision: v1/specs/openapi.yaml@latest
+  apiSpecRevision: v1/specs/openapi@latest
   endpointURI: https://apigeeregistry.googleapis.com
   externalChannelURI: https://apigee.github.io/registry/
   intendedAudience: Public
   accessGuidance: See https://github.com/apigee/registry for tools and usage information.
-  
\ No newline at end of file
diff --git a/cmd/registry/cmd/apply/testdata/unbundled-flat/apis-registry-versions-v1-artifacts-version-references.yaml b/cmd/registry/cmd/apply/testdata/unbundled-flat/apis-registry-versions-v1-artifacts-version-references.yaml
new file mode 100644
index 0000000..ddfae7b
--- /dev/null
+++ b/cmd/registry/cmd/apply/testdata/unbundled-flat/apis-registry-versions-v1-artifacts-version-references.yaml
@@ -0,0 +1,14 @@
+apiVersion: apigeeregistry/v1
+kind: ReferenceList
+metadata:
+  name: version-references
+  parent: apis/registry/versions/v1
+data:
+  displayName: ""
+  description: version-related references
+  references:
+    - id: github
+      displayName: GitHub Repo
+      category: apihub-source-code
+      resource: ""
+      uri: https://github.com/apigee/registry
diff --git a/cmd/registry/cmd/apply/testdata/unbundled-flat/apis-registry-versions-v1-specs-openapi-artifacts-spec-references.yaml b/cmd/registry/cmd/apply/testdata/unbundled-flat/apis-registry-versions-v1-specs-openapi-artifacts-spec-references.yaml
new file mode 100644
index 0000000..95e5a3a
--- /dev/null
+++ b/cmd/registry/cmd/apply/testdata/unbundled-flat/apis-registry-versions-v1-specs-openapi-artifacts-spec-references.yaml
@@ -0,0 +1,14 @@
+apiVersion: apigeeregistry/v1
+kind: ReferenceList
+metadata:
+  name: spec-references
+  parent: apis/registry/versions/v1/specs/openapi
+data:
+  displayName: ""
+  description: spec-related references
+  references:
+    - id: github
+      displayName: GitHub Repo
+      category: apihub-source-code
+      resource: ""
+      uri: https://github.com/apigee/registry
diff --git a/cmd/registry/cmd/apply/testdata/unbundled-flat/apis-registry-versions-v1-specs-openapi.yaml b/cmd/registry/cmd/apply/testdata/unbundled-flat/apis-registry-versions-v1-specs-openapi.yaml
index c578806..33b5ff5 100644
--- a/cmd/registry/cmd/apply/testdata/unbundled-flat/apis-registry-versions-v1-specs-openapi.yaml
+++ b/cmd/registry/cmd/apply/testdata/unbundled-flat/apis-registry-versions-v1-specs-openapi.yaml
@@ -1,7 +1,7 @@
 apiVersion: apigeeregistry/v1
 kind: Spec
 metadata:
-  name: openapi.yaml
+  name: openapi
   parent: apis/registry/versions/v1
   labels:
     openapi-verified: "true"
@@ -11,4 +11,4 @@ data:
   filename: openapi.yaml
   description: OpenAPI description of the Registry API
   mimeType: application/x.openapi+gzip;version=3
-  sourceURI: https://raw.githubusercontent.com/apigee/registry/main/openapi.yaml
\ No newline at end of file
+  sourceURI: https://raw.githubusercontent.com/apigee/registry/main/openapi.yaml
diff --git a/cmd/registry/cmd/apply/testdata/unbundled-flat/apis-registry-versions-v1.yaml b/cmd/registry/cmd/apply/testdata/unbundled-flat/apis-registry-versions-v1.yaml
index 37e7d8d..f3bda41 100644
--- a/cmd/registry/cmd/apply/testdata/unbundled-flat/apis-registry-versions-v1.yaml
+++ b/cmd/registry/cmd/apply/testdata/unbundled-flat/apis-registry-versions-v1.yaml
@@ -11,4 +11,3 @@ data:
   displayName: v1
   description: New in 2022
   state: Staging
-        
\ No newline at end of file
diff --git a/cmd/registry/cmd/apply/testdata/unbundled-flat/apis-registry.yaml b/cmd/registry/cmd/apply/testdata/unbundled-flat/apis-registry.yaml
index 09b594f..328c19f 100644
--- a/cmd/registry/cmd/apply/testdata/unbundled-flat/apis-registry.yaml
+++ b/cmd/registry/cmd/apply/testdata/unbundled-flat/apis-registry.yaml
@@ -11,4 +11,4 @@ data:
   description: The Registry API allows teams to track and manage machine-readable descriptions of APIs.
   availability: Preview
   recommendedVersion: v1
-  recommendedDeployment: prod
\ No newline at end of file
+  recommendedDeployment: prod
diff --git a/cmd/registry/cmd/apply/testdata/unbundled-hierarchical/apis/registry/artifacts/apihub-related.yaml b/cmd/registry/cmd/apply/testdata/unbundled-hierarchical/apis/registry/artifacts/api-references.yaml
similarity index 78%
rename from cmd/registry/cmd/apply/testdata/unbundled-hierarchical/apis/registry/artifacts/apihub-related.yaml
rename to cmd/registry/cmd/apply/testdata/unbundled-hierarchical/apis/registry/artifacts/api-references.yaml
index da382b0..536cee9 100644
--- a/cmd/registry/cmd/apply/testdata/unbundled-hierarchical/apis/registry/artifacts/apihub-related.yaml
+++ b/cmd/registry/cmd/apply/testdata/unbundled-hierarchical/apis/registry/artifacts/api-references.yaml
@@ -1,7 +1,7 @@
 apiVersion: apigeeregistry/v1
 kind: ReferenceList
 metadata:
-  name: apihub-related
+  name: api-references
   parent: apis/registry
   labels:
     label1: label
@@ -9,7 +9,7 @@ metadata:
     annotation1: annotation
 data:
   displayName: ""
-  description: Defines a list of related resources
+  description: api-related references
   references:
     - id: github
       displayName: GitHub Repo
diff --git a/cmd/registry/cmd/apply/testdata/unbundled-hierarchical/apis/registry/deployments/prod.yaml b/cmd/registry/cmd/apply/testdata/unbundled-hierarchical/apis/registry/deployments/prod.yaml
index 8fed69c..452cd72 100644
--- a/cmd/registry/cmd/apply/testdata/unbundled-hierarchical/apis/registry/deployments/prod.yaml
+++ b/cmd/registry/cmd/apply/testdata/unbundled-hierarchical/apis/registry/deployments/prod.yaml
@@ -10,9 +10,8 @@ metadata:
 data:
   displayName: Production
   description: The hosted deployment of the Registry API
-  apiSpecRevision: v1/specs/openapi.yaml@latest
+  apiSpecRevision: v1/specs/openapi@latest
   endpointURI: https://apigeeregistry.googleapis.com
   externalChannelURI: https://apigee.github.io/registry/
   intendedAudience: Public
-  accessGuidance: See https://github.com/apigee/registry for tools and usage information.
-  
\ No newline at end of file
+  accessGuidance: See https://github.com/apigee/registry for tools and usage information.
\ No newline at end of file
diff --git a/cmd/registry/cmd/apply/testdata/unbundled-hierarchical/apis/registry/deployments/prod/artifacts/deployment-references.yaml b/cmd/registry/cmd/apply/testdata/unbundled-hierarchical/apis/registry/deployments/prod/artifacts/deployment-references.yaml
new file mode 100644
index 0000000..dfc3590
--- /dev/null
+++ b/cmd/registry/cmd/apply/testdata/unbundled-hierarchical/apis/registry/deployments/prod/artifacts/deployment-references.yaml
@@ -0,0 +1,14 @@
+apiVersion: apigeeregistry/v1
+kind: ReferenceList
+metadata:
+  name: deployment-references
+  parent: apis/registry/deployments/prod
+data:
+  displayName: ""
+  description: deployment-related references
+  references:
+    - id: github
+      displayName: GitHub Repo
+      category: apihub-source-code
+      resource: ""
+      uri: https://github.com/apigee/registry
diff --git a/cmd/registry/cmd/apply/testdata/unbundled-hierarchical/apis/registry/versions/v1/artifacts/version-references.yaml b/cmd/registry/cmd/apply/testdata/unbundled-hierarchical/apis/registry/versions/v1/artifacts/version-references.yaml
new file mode 100644
index 0000000..ddfae7b
--- /dev/null
+++ b/cmd/registry/cmd/apply/testdata/unbundled-hierarchical/apis/registry/versions/v1/artifacts/version-references.yaml
@@ -0,0 +1,14 @@
+apiVersion: apigeeregistry/v1
+kind: ReferenceList
+metadata:
+  name: version-references
+  parent: apis/registry/versions/v1
+data:
+  displayName: ""
+  description: version-related references
+  references:
+    - id: github
+      displayName: GitHub Repo
+      category: apihub-source-code
+      resource: ""
+      uri: https://github.com/apigee/registry
diff --git a/cmd/registry/cmd/apply/testdata/unbundled-hierarchical/apis/registry/versions/v1/specs/openapi.yaml b/cmd/registry/cmd/apply/testdata/unbundled-hierarchical/apis/registry/versions/v1/specs/openapi.yaml
index c578806..33b5ff5 100644
--- a/cmd/registry/cmd/apply/testdata/unbundled-hierarchical/apis/registry/versions/v1/specs/openapi.yaml
+++ b/cmd/registry/cmd/apply/testdata/unbundled-hierarchical/apis/registry/versions/v1/specs/openapi.yaml
@@ -1,7 +1,7 @@
 apiVersion: apigeeregistry/v1
 kind: Spec
 metadata:
-  name: openapi.yaml
+  name: openapi
   parent: apis/registry/versions/v1
   labels:
     openapi-verified: "true"
@@ -11,4 +11,4 @@ data:
   filename: openapi.yaml
   description: OpenAPI description of the Registry API
   mimeType: application/x.openapi+gzip;version=3
-  sourceURI: https://raw.githubusercontent.com/apigee/registry/main/openapi.yaml
\ No newline at end of file
+  sourceURI: https://raw.githubusercontent.com/apigee/registry/main/openapi.yaml
diff --git a/cmd/registry/cmd/apply/testdata/unbundled-hierarchical/apis/registry/versions/v1/specs/openapi/artifacts/spec-references.yaml b/cmd/registry/cmd/apply/testdata/unbundled-hierarchical/apis/registry/versions/v1/specs/openapi/artifacts/spec-references.yaml
new file mode 100644
index 0000000..95e5a3a
--- /dev/null
+++ b/cmd/registry/cmd/apply/testdata/unbundled-hierarchical/apis/registry/versions/v1/specs/openapi/artifacts/spec-references.yaml
@@ -0,0 +1,14 @@
+apiVersion: apigeeregistry/v1
+kind: ReferenceList
+metadata:
+  name: spec-references
+  parent: apis/registry/versions/v1/specs/openapi
+data:
+  displayName: ""
+  description: spec-related references
+  references:
+    - id: github
+      displayName: GitHub Repo
+      category: apihub-source-code
+      resource: ""
+      uri: https://github.com/apigee/registry
diff --git a/cmd/registry/cmd/export/yaml_test.go b/cmd/registry/cmd/export/yaml_test.go
new file mode 100644
index 0000000..4ccebb6
--- /dev/null
+++ b/cmd/registry/cmd/export/yaml_test.go
@@ -0,0 +1,100 @@
+package export
+
+import (
+	"context"
+	"testing"
+
+	"github.com/apigee/registry/pkg/connection"
+	"github.com/apigee/registry/rpc"
+	"github.com/apigee/registry/server/registry/test/seeder"
+)
+
+func TestExportYAML(t *testing.T) {
+	// Seed a registry with a list of leaf-level artifacts.
+	const scoreType = "application/octet-stream;type=google.cloud.apigeeregistry.v1.scoring.Score"
+	artifacts := []*rpc.Artifact{
+		{Name: "projects/my-project/locations/global/artifacts/x", MimeType: scoreType},
+		{Name: "projects/my-project/locations/global/apis/a/artifacts/x", MimeType: scoreType},
+		{Name: "projects/my-project/locations/global/apis/a/versions/v/artifacts/x", MimeType: scoreType},
+		{Name: "projects/my-project/locations/global/apis/a/versions/v/specs/s/artifacts/x", MimeType: scoreType},
+		{Name: "projects/my-project/locations/global/apis/a/deployments/d/artifacts/x", MimeType: scoreType},
+	}
+	ctx := context.Background()
+	registryClient, err := connection.NewRegistryClient(ctx)
+	if err != nil {
+		t.Fatalf("Failed to create client: %+v", err)
+	}
+	t.Cleanup(func() { registryClient.Close() })
+	adminClient, err := connection.NewAdminClient(ctx)
+	if err != nil {
+		t.Fatalf("Failed to create client: %+v", err)
+	}
+	t.Cleanup(func() { adminClient.Close() })
+	client := seeder.Client{
+		RegistryClient: registryClient,
+		AdminClient:    adminClient,
+	}
+	if err := seeder.SeedArtifacts(ctx, client, artifacts...); err != nil {
+		t.Fatalf("Setup/Seeding: Failed to seed registry: %s", err)
+	}
+
+	// Verify that yaml export runs for each resource.
+	resources := []string{
+		"projects/my-project/locations/global/artifacts/x",
+		"projects/my-project/locations/global/apis/a/versions/v",
+		"projects/my-project/locations/global/apis/a/versions/v/specs/s",
+		"projects/my-project/locations/global/apis/a/deployments/d",
+		"projects/my-project/locations/global/apis/a",
+		"projects/my-project/locations/global/apis/a/versions/v",
+		"projects/my-project/locations/global/apis/a/versions/v/specs/s",
+		"projects/my-project/locations/global/apis/a/deployments/d",
+		"projects/my-project/locations/global/apis/a/artifacts/x",
+		"projects/my-project/locations/global/apis/a/versions/v/artifacts/x",
+		"projects/my-project/locations/global/apis/a/versions/v/specs/s/artifacts/x",
+		"projects/my-project/locations/global/apis/a/deployments/d/artifacts/x",
+	}
+	for _, r := range resources {
+		cmd := Command()
+		args := []string{"yaml", r}
+		cmd.SetArgs(args)
+		if err := cmd.Execute(); err != nil {
+			t.Fatalf("Execute() with args %v returned error: %s", args, err)
+		}
+	}
+
+	// Repeat with --nested export enabled.
+	for _, r := range resources {
+		cmd := Command()
+		args := []string{"yaml", r, "--nested"}
+		cmd.SetArgs(args)
+		if err := cmd.Execute(); err != nil {
+			t.Fatalf("Execute() with args %v returned error: %s", args, err)
+		}
+	}
+
+	// Verify that invalid exports fail.
+	invalid := []string{
+		"projects/my-project/locations/global/artifacts/xx",
+		"projects/my-project/locations/global/apis/a/versions/vv",
+		"projects/my-project/locations/global/apis/a/versions/v/specs/ss",
+		"projects/my-project/locations/global/apis/a/deployments/dd",
+		"projects/my-project/locations/global/apis/aa",
+		"projects/my-project/locations/global/apis/a/versions/vv",
+		"projects/my-project/locations/global/apis/a/versions/v/specs/ss",
+		"projects/my-project/locations/global/apis/a/deployments/dd",
+		"projects/my-project/locations/global/apis/a/artifacts/xx",
+		"projects/my-project/locations/global/apis/a/versions/v/artifacts/xx",
+		"projects/my-project/locations/global/apis/a/versions/v/specs/s/artifacts/xx",
+		"projects/my-project/locations/global/apis/a/deployments/d/artifacts/xx",
+	}
+	for _, r := range invalid {
+		cmd := Command()
+		cmd.SilenceUsage = true
+		cmd.SilenceErrors = true
+		args := []string{"yaml", r}
+		cmd.SetArgs(args)
+		if err := cmd.Execute(); err == nil {
+			t.Fatalf("Execute() with args %v succeeded but should have failed", args)
+		}
+	}
+}
diff --git a/server/registry/names/names_test.go b/server/registry/names/names_test.go
index 54e338b..0bd0ef8 100644
--- a/server/registry/names/names_test.go
+++ b/server/registry/names/names_test.go
@@ -347,3 +347,55 @@ func TestRevisionTags(t *testing.T) {
 		})
 	}
 }
+
+func TestExportableName(t *testing.T) {
+	tests := []struct {
+		name       string
+		projectID  string
+		exportable string
+	}{
+		{
+			name:       "projects/my-project/locations/global/apis/a",
+			projectID:  "my-project",
+			exportable: "apis/a",
+		},
+		{
+			name:       "projects/my-project/locations/global/apis/a/versions/v",
+			projectID:  "my-project",
+			exportable: "apis/a/versions/v",
+		},
+		{
+			name:       "projects/my-project/locations/global/apis/a/versions/v/specs/s",
+			projectID:  "my-project",
+			exportable: "apis/a/versions/v/specs/s",
+		},
+		{
+			name:       "projects/my-project/locations/global/apis/a/versions/v/specs/s@123",
+			projectID:  "my-project",
+			exportable: "apis/a/versions/v/specs/s",
+		},
+		{
+			name:       "projects/my-project/locations/global/apis/a/deployments/d",
+			projectID:  "my-project",
+			exportable: "apis/a/deployments/d",
+		},
+		{
+			name:       "projects/my-project/locations/global/apis/a/deployments/d@123",
+			projectID:  "my-project",
+			exportable: "apis/a/deployments/d",
+		},
+		{
+			name:       "projects/another-project/locations/global/artifacts/x",
+			projectID:  "another-project",
+			exportable: "artifacts/x",
+		},
+	}
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			exportable := ExportableName(test.name, test.projectID)
+			if exportable != test.exportable {
+				t.Errorf("exportable name of %s should be %s but was %s", test.name, test.exportable, exportable)
+			}
+		})
+	}
+}