Repository: apigee/registry
Pull request #896 (2022-12-23 05:09:16)

Improve `registry get` to include listing capabilities and yaml output.
The `registry get` interface is simplified to the following:
```
Usage:
  registry get PATTERN [flags]

Flags:
      --filter string   Filter selected resources
  -h, --help            help for get
  -o, --output string   Output type (name, yaml, contents) (default "name")

Global Flags:
  -c, --config string              name of a configuration profile or path to config file
      --registry.address string    the server and port of the registry api (eg. localhost:8080)
      --registry.insecure          if specified, client connects via http (not https)
      --registry.location string   the registry location
      --registry.project string    the registry project
      --registry.token string      the token to use for authorization to registry
```

When `PATTERN` is a collection or a resource name with wildcards, `registry get` finds all matching resources (using a `--filter` if provided). Names of matching resources are returned by default, and the `--output` (`-o`) flag can be used to select other output formats. Initially `name` (the default), `yaml`, and `contents` are provided. `contents` is only supported for specs and artifacts and returns the contents of these resources. When `contents` is specified, `PATTERN` must match a single resource. For other response types, when multiple resources match, an appropriate list is returned. `yaml` lists follow the `kubectl` pattern and look like this:
```
% registry get projects/googleapis/locations/global/apis -o yaml 
apiVersion: apigeeregistry/v1
items:
  - apiVersion: apigeeregistry/v1
    kind: API
    metadata:
      name: abusiveexperiencereport
    data:
      displayName: Abusive Experience Report API
      description: Views Abusive Experience Report data, and gets a list of sites that have a significant number of abusive experiences.
  - apiVersion: apigeeregistry/v1
    kind: API
    metadata:
      name: acceleratedmobilepageurl
    data:
      displayName: Accelerated Mobile Pages (AMP) URL API
      description: 'Retrieves the list of AMP URLs (and equivalent AMP Cache URLs) for a given list of public URL(s). '
  - apiVersion: apigeeregistry/v1
    kind: API
    metadata:
      name: accessapproval
    data:
      displayName: Access Approval API
      description: An API for controlling access to data by Google personnel.
...
```
 
This will eliminate the need for `registry list` and following this change, `registry export yaml` will be changed to only write files -- all of the cases that write YAML to stdout will be supported by `registry get`.

See #884 and #741 for justification.

codecov[bot]: # [Codecov](https://codecov.io/gh/apigee/registry/pull/896?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apigee) Report
> Merging [#896](https://codecov.io/gh/apigee/registry/pull/896?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apigee) (4ecc5b3) into [main](https://codecov.io/gh/apigee/registry/commit/5dca1d6cbddc8e3c7d530fe7c88d50cbcc50ef31?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apigee) (5dca1d6) will **decrease** coverage by `0.82%`.
> The diff coverage is `33.08%`.

```diff
@@            Coverage Diff             @@
##             main     #896      +/-   ##
==========================================
- Coverage   60.04%   59.21%   -0.83%     
==========================================
  Files         102      103       +1     
  Lines        9576     9857     +281     
==========================================
+ Hits         5750     5837      +87     
- Misses       3193     3357     +164     
- Partials      633      663      +30     
```


| [Impacted Files](https://codecov.io/gh/apigee/registry/pull/896?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apigee) | Coverage Δ | |
|---|---|---|
| [cmd/registry/patch/project.go](https://codecov.io/gh/apigee/registry/pull/896/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apigee#diff-Y21kL3JlZ2lzdHJ5L3BhdGNoL3Byb2plY3QuZ28=) | `50.57% <26.82%> (-6.38%)` | :arrow_down: |
| [cmd/registry/cmd/get/get.go](https://codecov.io/gh/apigee/registry/pull/896/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apigee#diff-Y21kL3JlZ2lzdHJ5L2NtZC9nZXQvZ2V0Lmdv) | `31.81% <27.96%> (ø)` | |
| [cmd/registry/cmd/export/yaml.go](https://codecov.io/gh/apigee/registry/pull/896/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apigee#diff-Y21kL3JlZ2lzdHJ5L2NtZC9leHBvcnQveWFtbC5nbw==) | `67.41% <100.00%> (ø)` | |
| [cmd/registry/patch/api.go](https://codecov.io/gh/apigee/registry/pull/896/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apigee#diff-Y21kL3JlZ2lzdHJ5L3BhdGNoL2FwaS5nbw==) | `56.35% <100.00%> (ø)` | |
| [cmd/registry/patch/artifact.go](https://codecov.io/gh/apigee/registry/pull/896/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apigee#diff-Y21kL3JlZ2lzdHJ5L3BhdGNoL2FydGlmYWN0Lmdv) | `74.74% <100.00%> (ø)` | |
| [cmd/registry/patch/deployment.go](https://codecov.io/gh/apigee/registry/pull/896/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apigee#diff-Y21kL3JlZ2lzdHJ5L3BhdGNoL2RlcGxveW1lbnQuZ28=) | `65.42% <100.00%> (ø)` | |
| [cmd/registry/patch/spec.go](https://codecov.io/gh/apigee/registry/pull/896/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apigee#diff-Y21kL3JlZ2lzdHJ5L3BhdGNoL3NwZWMuZ28=) | `49.32% <100.00%> (ø)` | |
| [cmd/registry/patch/version.go](https://codecov.io/gh/apigee/registry/pull/896/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apigee#diff-Y21kL3JlZ2lzdHJ5L3BhdGNoL3ZlcnNpb24uZ28=) | `65.04% <100.00%> (ø)` | |
| [cmd/registry/types/types.go](https://codecov.io/gh/apigee/registry/pull/896/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apigee#diff-Y21kL3JlZ2lzdHJ5L3R5cGVzL3R5cGVzLmdv) | `100.00% <100.00%> (ø)` | |

:mega: We’re building smart automated test selection to slash your CI/CD build times. [Learn more](https://about.codecov.io/iterative-testing/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apigee)


Patch:
diff --git a/cmd/registry/cmd/export/yaml.go b/cmd/registry/cmd/export/yaml.go
index ff074d9..c479ef5 100644
--- a/cmd/registry/cmd/export/yaml.go
+++ b/cmd/registry/cmd/export/yaml.go
@@ -55,7 +55,11 @@ func yamlCommand() *cobra.Command {
 				}
 			} 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, nested)
+					api, err := patch.NewApi(ctx, client, message, nested)
+					if err != nil {
+						return err
+					}
+					bytes, err := patch.Encode(api)
 					if err != nil {
 						return err
 					}
@@ -67,7 +71,11 @@ func yamlCommand() *cobra.Command {
 				}
 			} 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)
+					version, err := patch.NewApiVersion(ctx, client, message, nested)
+					if err != nil {
+						return err
+					}
+					bytes, err := patch.Encode(version)
 					if err != nil {
 						return err
 					}
@@ -79,7 +87,11 @@ func yamlCommand() *cobra.Command {
 				}
 			} 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)
+					spec, err := patch.NewApiSpec(ctx, client, message, nested)
+					if err != nil {
+						return err
+					}
+					bytes, err := patch.Encode(spec)
 					if err != nil {
 						return err
 					}
@@ -91,7 +103,11 @@ func yamlCommand() *cobra.Command {
 				}
 			} 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)
+					deployment, err := patch.NewApiDeployment(ctx, client, message, nested)
+					if err != nil {
+						return err
+					}
+					bytes, err := patch.Encode(deployment)
 					if err != nil {
 						return err
 					}
@@ -103,7 +119,11 @@ func yamlCommand() *cobra.Command {
 				}
 			} 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)
+					artifact, err := patch.NewArtifact(ctx, client, message)
+					if err != nil {
+						return err
+					}
+					bytes, err := patch.Encode(artifact)
 					if err != nil {
 						return err
 					}
diff --git a/cmd/registry/cmd/get/get.go b/cmd/registry/cmd/get/get.go
index 6cf3e8f..e5524d3 100644
--- a/cmd/registry/cmd/get/get.go
+++ b/cmd/registry/cmd/get/get.go
@@ -15,90 +15,329 @@
 package get
 
 import (
+	"context"
+	"errors"
+	"fmt"
+	"io"
+	"strings"
+
 	"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/pkg/models"
+	"github.com/apigee/registry/rpc"
 	"github.com/apigee/registry/server/registry/names"
 	"github.com/spf13/cobra"
 )
 
 func Command() *cobra.Command {
-	var getContents bool
-	var getRawContents bool
-	var getPrintedContents bool
+	var filter string
+	var output string
 
 	cmd := &cobra.Command{
-		Use:   "get",
+		Use:   "get PATTERN",
 		Short: "Get resources from the API Registry",
 		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")
 			}
 			args[0] = c.FQName(args[0])
-			if getContents && getRawContents || getContents && getPrintedContents || getRawContents && getPrintedContents {
-				log.FromContext(ctx).Fatal("Please use at most one of --print, --raw, and --contents.")
-			}
-			if getContents {
-				getPrintedContents = true
-				log.FromContext(ctx).Warn("--contents is deprecated, please use --print or --raw instead.")
-			}
-
 			client, err := connection.NewRegistryClientWithSettings(ctx, c)
 			if err != nil {
-				log.FromContext(ctx).WithError(err).Fatal("Failed to get client")
+				return err
 			}
 			adminClient, err := connection.NewAdminClientWithSettings(ctx, c)
 			if err != nil {
-				log.FromContext(ctx).WithError(err).Fatal("Failed to get client")
-			}
-
-			var err2 error
-			if project, err := names.ParseProject(args[0]); err == nil {
-				err2 = core.GetProject(ctx, adminClient, project, core.PrintProjectDetail)
-			} else if api, err := names.ParseApi(args[0]); err == nil {
-				err2 = core.GetAPI(ctx, client, api, core.PrintAPIDetail)
-			} else if deployment, err := names.ParseDeployment(args[0]); err == nil {
-				err2 = core.GetDeployment(ctx, client, deployment, core.PrintDeploymentDetail)
-			} else if deployment, err := names.ParseDeploymentRevision(args[0]); err == nil {
-				err2 = core.GetDeploymentRevision(ctx, client, deployment, core.PrintDeploymentDetail)
-			} else if version, err := names.ParseVersion(args[0]); err == nil {
-				err2 = core.GetVersion(ctx, client, version, core.PrintVersionDetail)
-			} else if spec, err := names.ParseSpec(args[0]); err == nil {
-				// for specs, these options are synonymous
-				if getPrintedContents || getRawContents {
-					err2 = core.GetSpec(ctx, client, spec, true, core.WriteSpecContents)
-				} else {
-					err2 = core.GetSpec(ctx, client, spec, false, core.PrintSpecDetail)
-				}
-			} else if spec, err := names.ParseSpecRevision(args[0]); err == nil {
-				// for specs, these options are synonymous
-				if getPrintedContents || getRawContents {
-					err2 = core.GetSpecRevision(ctx, client, spec, true, core.WriteSpecContents)
-				} else {
-					err2 = core.GetSpecRevision(ctx, client, spec, false, core.PrintSpecDetail)
-				}
-			} else if artifact, err := names.ParseArtifact(args[0]); err == nil {
-				if getPrintedContents {
-					err2 = core.GetArtifact(ctx, client, artifact, true, core.PrintArtifactContents)
-				} else if getRawContents {
-					err2 = core.GetArtifact(ctx, client, artifact, true, core.WriteArtifactContents)
-				} else {
-					err2 = core.GetArtifact(ctx, client, artifact, false, core.PrintArtifactDetail)
-				}
-			} else {
-				log.Errorf(ctx, "Unsupported entity %+v", args)
-			}
-			if err2 != nil {
-				log.FromContext(ctx).WithError(err2).Errorf("Failed to get resource")
+				return err
 			}
+			h := &getHandler{
+				ctx:         ctx,
+				client:      client,
+				adminClient: adminClient,
+				writer:      cmd.OutOrStdout(),
+				name:        args[0],
+				filter:      filter,
+				output:      output,
+			}
+			err = h.traverse()
+			if err != nil {
+				return err
+			}
+			return h.write()
 		},
 	}
 
-	cmd.Flags().BoolVar(&getContents, "contents", false, "Get resource contents if available (deprecated)")
-	cmd.Flags().BoolVar(&getRawContents, "raw", false, "Get raw resource contents if available")
-	cmd.Flags().BoolVar(&getPrintedContents, "print", false, "Print resource contents if available")
+	cmd.Flags().StringVar(&filter, "filter", "", "Filter selected resources")
+	cmd.Flags().StringVarP(&output, "output", "o", "name", "Output type (name, yaml, contents)")
 	return cmd
 }
+
+type getHandler struct {
+	ctx         context.Context
+	client      connection.RegistryClient
+	adminClient connection.AdminClient
+	writer      io.Writer
+	name        string
+	filter      string
+	output      string
+	results     []interface{} // result values to be returned in a single message
+}
+
+func (h *getHandler) traverse() error {
+	// Define aliases to simplify the subsequent code.
+	name := h.name
+	ctx := h.ctx
+	client := h.client
+	adminClient := h.adminClient
+	filter := h.filter
+
+	// First try to match collection names.
+	if project, err := names.ParseProjectCollection(name); err == nil {
+		return core.ListProjects(ctx, adminClient, project, filter, h.projectHandler())
+	} else if api, err := names.ParseApiCollection(name); err == nil {
+		return core.ListAPIs(ctx, client, api, filter, h.apiHandler())
+	} else if deployment, err := names.ParseDeploymentCollection(name); err == nil {
+		return core.ListDeployments(ctx, client, deployment, filter, h.apiDeploymentHandler())
+	} else if rev, err := names.ParseDeploymentRevisionCollection(name); err == nil {
+		return core.ListDeploymentRevisions(ctx, client, rev, filter, h.apiDeploymentHandler())
+	} else if version, err := names.ParseVersionCollection(name); err == nil {
+		return core.ListVersions(ctx, client, version, filter, h.apiVersionHandler())
+	} else if spec, err := names.ParseSpecCollection(name); err == nil {
+		return core.ListSpecs(ctx, client, spec, filter, false, h.apiSpecHandler())
+	} else if rev, err := names.ParseSpecRevisionCollection(name); err == nil {
+		return core.ListSpecRevisions(ctx, client, rev, filter, false, h.apiSpecHandler())
+	} else if artifact, err := names.ParseArtifactCollection(name); err == nil {
+		return core.ListArtifacts(ctx, client, artifact, filter, false, h.artifactHandler())
+	}
+
+	// Then try to match resource names containing wildcards, these also are treated as collections.
+	if strings.Contains(name, "/-") || strings.Contains(name, "@-") {
+		if project, err := names.ParseProject(name); err == nil {
+			return core.ListProjects(ctx, adminClient, project, filter, h.projectHandler())
+		} else if api, err := names.ParseApi(name); err == nil {
+			return core.ListAPIs(ctx, client, api, filter, h.apiHandler())
+		} else if deployment, err := names.ParseDeployment(name); err == nil {
+			return core.ListDeployments(ctx, client, deployment, filter, h.apiDeploymentHandler())
+		} else if rev, err := names.ParseDeploymentRevision(name); err == nil {
+			return core.ListDeploymentRevisions(ctx, client, rev, filter, h.apiDeploymentHandler())
+		} else if version, err := names.ParseVersion(name); err == nil {
+			return core.ListVersions(ctx, client, version, filter, h.apiVersionHandler())
+		} else if spec, err := names.ParseSpec(name); err == nil {
+			return core.ListSpecs(ctx, client, spec, filter, false, h.apiSpecHandler())
+		} else if rev, err := names.ParseSpecRevision(name); err == nil {
+			return core.ListSpecRevisions(ctx, client, rev, filter, false, h.apiSpecHandler())
+		} else if artifact, err := names.ParseArtifact(name); err == nil {
+			return core.ListArtifacts(ctx, client, artifact, filter, false, h.artifactHandler())
+		}
+		return fmt.Errorf("unsupported entity %+v", name)
+	}
+
+	// If we get here, name designates an individual resource to be displayed.
+	// So if a filter was specified, that's an error.
+	if filter != "" {
+		return errors.New("--filter must not be specified for a non-collection resource")
+	}
+
+	if project, err := names.ParseProject(name); err == nil {
+		return core.GetProject(ctx, adminClient, project, h.projectHandler())
+	} else if api, err := names.ParseApi(name); err == nil {
+		return core.GetAPI(ctx, client, api, h.apiHandler())
+	} else if deployment, err := names.ParseDeployment(name); err == nil {
+		return core.GetDeployment(ctx, client, deployment, h.apiDeploymentHandler())
+	} else if deployment, err := names.ParseDeploymentRevision(name); err == nil {
+		return core.GetDeploymentRevision(ctx, client, deployment, h.apiDeploymentHandler())
+	} else if version, err := names.ParseVersion(name); err == nil {
+		return core.GetVersion(ctx, client, version, h.apiVersionHandler())
+	} else if spec, err := names.ParseSpec(name); err == nil {
+		return core.GetSpec(ctx, client, spec, false, h.apiSpecHandler())
+	} else if spec, err := names.ParseSpecRevision(name); err == nil {
+		return core.GetSpecRevision(ctx, client, spec, false, h.apiSpecHandler())
+	} else if artifact, err := names.ParseArtifact(name); err == nil {
+		return core.GetArtifact(ctx, client, artifact, false, h.artifactHandler())
+	} else {
+		return fmt.Errorf("unsupported entity %+v", name)
+	}
+}
+
+func (h *getHandler) projectHandler() func(message *rpc.Project) error {
+	return func(message *rpc.Project) error {
+		switch h.output {
+		case "name":
+			h.results = append(h.results, message.Name)
+			_, err := h.writer.Write([]byte(message.Name + "\n"))
+			return err
+		case "yaml":
+			project, err := patch.NewProject(h.ctx, h.client, message)
+			if err != nil {
+				return err
+			}
+			h.results = append(h.results, project)
+			return nil
+		default:
+			return newOutputTypeError("projects", h.output)
+		}
+	}
+}
+
+func (h *getHandler) apiHandler() func(message *rpc.Api) error {
+	return func(message *rpc.Api) error {
+		switch h.output {
+		case "name":
+			h.results = append(h.results, message.Name)
+			_, err := h.writer.Write([]byte(message.Name + "\n"))
+			return err
+		case "yaml":
+			api, err := patch.NewApi(h.ctx, h.client, message, false)
+			if err != nil {
+				return err
+			}
+			h.results = append(h.results, api)
+			return nil
+		default:
+			return newOutputTypeError("apis", h.output)
+		}
+	}
+}
+
+func (h *getHandler) apiVersionHandler() func(message *rpc.ApiVersion) error {
+	return func(message *rpc.ApiVersion) error {
+		switch h.output {
+		case "name":
+			h.results = append(h.results, message.Name)
+			_, err := h.writer.Write([]byte(message.Name + "\n"))
+			return err
+		case "yaml":
+			version, err := patch.NewApiVersion(h.ctx, h.client, message, false)
+			if err != nil {
+				return err
+			}
+			h.results = append(h.results, version)
+			return nil
+		default:
+			return newOutputTypeError("versions", h.output)
+		}
+	}
+}
+
+func (h *getHandler) apiDeploymentHandler() func(message *rpc.ApiDeployment) error {
+	return func(message *rpc.ApiDeployment) error {
+		switch h.output {
+		case "name":
+			h.results = append(h.results, message.Name)
+			_, err := h.writer.Write([]byte(message.Name + "\n"))
+			return err
+		case "yaml":
+			deployment, err := patch.NewApiDeployment(h.ctx, h.client, message, false)
+			if err != nil {
+				return err
+			}
+			h.results = append(h.results, deployment)
+			return nil
+		default:
+			return newOutputTypeError("deployments", h.output)
+		}
+	}
+}
+
+func (h *getHandler) apiSpecHandler() func(message *rpc.ApiSpec) error {
+	return func(message *rpc.ApiSpec) error {
+		switch h.output {
+		case "name":
+			h.results = append(h.results, message.Name)
+			_, err := h.writer.Write([]byte(message.Name + "\n"))
+			return err
+		case "contents":
+			if len(h.results) > 0 {
+				return fmt.Errorf("contents can be gotten for at most one spec")
+			}
+			if err := core.FetchSpecContents(h.ctx, h.client, message); err != nil {
+				return err
+			}
+			contents := message.GetContents()
+			if strings.Contains(message.GetMimeType(), "+gzip") {
+				contents, _ = core.GUnzippedBytes(contents)
+			}
+			h.results = append(h.results, contents)
+			return nil
+		case "yaml":
+			spec, err := patch.NewApiSpec(h.ctx, h.client, message, false)
+			if err != nil {
+				return err
+			}
+			h.results = append(h.results, spec)
+			return nil
+		default:
+			return newOutputTypeError("specs", h.output)
+		}
+	}
+}
+
+func (h *getHandler) artifactHandler() func(message *rpc.Artifact) error {
+	return func(message *rpc.Artifact) error {
+		switch h.output {
+		case "name":
+			h.results = append(h.results, message.Name)
+			_, err := h.writer.Write([]byte(message.Name + "\n"))
+			return err
+		case "contents":
+			if len(h.results) > 0 {
+				return fmt.Errorf("contents can be gotten for at most one artifact")
+			}
+			if err := core.FetchArtifactContents(h.ctx, h.client, message); err != nil {
+				return err
+			}
+			h.results = append(h.results, message.GetContents())
+			return nil
+		case "yaml":
+			if err := core.FetchArtifactContents(h.ctx, h.client, message); err != nil {
+				return err
+			}
+			artifact, err := patch.NewArtifact(h.ctx, h.client, message)
+			if err != nil {
+				return err
+			}
+			h.results = append(h.results, artifact)
+			return nil
+		default:
+			return newOutputTypeError("artifacts", h.output)
+		}
+	}
+}
+
+func newOutputTypeError(resourceType, outputType string) error {
+	return fmt.Errorf("%s do not support the %q output type", resourceType, outputType)
+}
+
+func (h *getHandler) write() error {
+	if len(h.results) == 0 {
+		return fmt.Errorf("no matching results found")
+	}
+	if h.output == "yaml" {
+		var result interface{}
+		if len(h.results) == 1 {
+			result = h.results[0]
+		} else {
+			result = &models.List{
+				Header: models.Header{ApiVersion: patch.RegistryV1},
+				Items:  h.results,
+			}
+		}
+		bytes, err := patch.Encode(result)
+		if err != nil {
+			return err
+		}
+		_, err = h.writer.Write(bytes)
+		return err
+	}
+	if h.output == "contents" {
+		if len(h.results) == 1 {
+			_, err := h.writer.Write(h.results[0].([]byte))
+			return err
+		}
+	}
+	return nil
+}
diff --git a/cmd/registry/core/get.go b/cmd/registry/core/get.go
index c30240d..d4e556e 100644
--- a/cmd/registry/core/get.go
+++ b/cmd/registry/core/get.go
@@ -106,15 +106,9 @@ func GetSpec(ctx context.Context,
 		return err
 	}
 	if getContents {
-		ctx := metadata.AppendToOutgoingContext(ctx, "accept-encoding", "gzip")
-		contents, err := client.GetApiSpecContents(ctx, &rpc.GetApiSpecContentsRequest{
-			Name: spec.GetName(),
-		})
-		if err != nil {
+		if err := FetchSpecContents(ctx, client, spec); err != nil {
 			return err
 		}
-		spec.Contents = contents.GetData()
-		spec.MimeType = contents.GetContentType()
 	}
 
 	return handler(spec)
@@ -133,17 +127,11 @@ func GetSpecRevision(ctx context.Context,
 		return err
 	}
 	if getContents {
-		request := &rpc.GetApiSpecContentsRequest{
-			Name: spec.GetName(),
-		}
-		ctx := metadata.AppendToOutgoingContext(ctx, "accept-encoding", "gzip")
-		contents, err := client.GetApiSpecContents(ctx, request)
-		if err != nil {
+		if err := FetchSpecContents(ctx, client, spec); err != nil {
 			return err
 		}
-		spec.Contents = contents.GetData()
-		spec.MimeType = contents.GetContentType()
 	}
+
 	return handler(spec)
 }
 
@@ -159,15 +147,42 @@ func GetArtifact(ctx context.Context,
 		return err
 	}
 	if getContents {
-		contents, err := client.GetArtifactContents(ctx, &rpc.GetArtifactContentsRequest{
-			Name: artifact.GetName(),
-		})
-		if err != nil {
+		if err = FetchArtifactContents(ctx, client, artifact); err != nil {
 			return err
 		}
-		artifact.Contents = contents.GetData()
-		artifact.MimeType = contents.GetContentType()
 	}
 
 	return handler(artifact)
 }
+
+func FetchSpecContents(ctx context.Context, client *gapic.RegistryClient, spec *rpc.ApiSpec) error {
+	if spec.Contents != nil {
+		return nil
+	}
+	request := &rpc.GetApiSpecContentsRequest{
+		Name: spec.GetName(),
+	}
+	ctx = metadata.AppendToOutgoingContext(ctx, "accept-encoding", "gzip")
+	contents, err := client.GetApiSpecContents(ctx, request)
+	if err != nil {
+		return err
+	}
+	spec.Contents = contents.GetData()
+	spec.MimeType = contents.GetContentType()
+	return nil
+}
+
+func FetchArtifactContents(ctx context.Context, client *gapic.RegistryClient, artifact *rpc.Artifact) error {
+	if artifact.Contents != nil {
+		return nil
+	}
+	contents, err := client.GetArtifactContents(ctx, &rpc.GetArtifactContentsRequest{
+		Name: artifact.GetName(),
+	})
+	if err != nil {
+		return err
+	}
+	artifact.Contents = contents.GetData()
+	artifact.MimeType = contents.GetContentType()
+	return nil
+}
diff --git a/cmd/registry/core/print.go b/cmd/registry/core/print.go
index cfb5f84..78147ac 100644
--- a/cmd/registry/core/print.go
+++ b/cmd/registry/core/print.go
@@ -16,7 +16,6 @@ package core
 
 import (
 	"fmt"
-	"os"
 	"strings"
 
 	"github.com/apigee/registry/cmd/registry/types"
@@ -30,76 +29,32 @@ func PrintProject(project *rpc.Project) error {
 	return nil
 }
 
-func PrintProjectDetail(message *rpc.Project) error {
-	PrintMessage(message)
-	return nil
-}
-
 func PrintAPI(api *rpc.Api) error {
 	fmt.Println(api.Name)
 	return nil
 }
 
-func PrintAPIDetail(message *rpc.Api) error {
-	PrintMessage(message)
-	return nil
-}
-
 func PrintDeployment(deployment *rpc.ApiDeployment) error {
 	fmt.Println(deployment.Name)
 	return nil
 }
 
-func PrintDeploymentDetail(message *rpc.ApiDeployment) error {
-	PrintMessage(message)
-	return nil
-}
-
 func PrintVersion(version *rpc.ApiVersion) error {
 	fmt.Println(version.Name)
 	return nil
 }
 
-func PrintVersionDetail(message *rpc.ApiVersion) error {
-	PrintMessage(message)
-	return nil
-}
-
 func PrintSpec(spec *rpc.ApiSpec) error {
 	fmt.Println(spec.Name)
 	return nil
 }
 
-func PrintSpecDetail(message *rpc.ApiSpec) error {
-	PrintMessage(message)
-	return nil
-}
-
-func WriteSpecContents(message *rpc.ApiSpec) error {
-	contents := message.GetContents()
-	if strings.Contains(message.GetMimeType(), "+gzip") {
-		contents, _ = GUnzippedBytes(contents)
-	}
-	os.Stdout.Write(contents)
-	return nil
-}
-
 func PrintArtifact(artifact *rpc.Artifact) error {
 	fmt.Println(artifact.Name)
 	return nil
 }
 
-func PrintArtifactDetail(artifact *rpc.Artifact) error {
-	PrintMessage(artifact)
-	return nil
-}
-
-func WriteArtifactContents(artifact *rpc.Artifact) error {
-	os.Stdout.Write(artifact.GetContents())
-	return nil
-}
-
-func isPrintableType(mimeType string) bool {
+func IsPrintableType(mimeType string) bool {
 	if mimeType == "text/plain" {
 		return true
 	} else if strings.HasPrefix(mimeType, "application/yaml") {
@@ -111,20 +66,6 @@ func isPrintableType(mimeType string) bool {
 	}
 }
 
-func PrintArtifactContents(artifact *rpc.Artifact) error {
-	if isPrintableType(artifact.GetMimeType()) {
-		fmt.Printf("%s\n", string(artifact.GetContents()))
-		return nil
-	}
-
-	message, err := GetArtifactMessageContents(artifact)
-	if err != nil {
-		return err
-	}
-	PrintMessage(message)
-	return nil
-}
-
 func PrintMessage(message proto.Message) {
 	fmt.Println(protojson.Format(message))
 }
diff --git a/cmd/registry/patch/api.go b/cmd/registry/patch/api.go
index efbd3da..8243478 100644
--- a/cmd/registry/patch/api.go
+++ b/cmd/registry/patch/api.go
@@ -15,7 +15,6 @@
 package patch
 
 import (
-	"bytes"
 	"context"
 
 	"github.com/apigee/registry/cmd/registry/core"
@@ -28,7 +27,8 @@ import (
 	"gopkg.in/yaml.v3"
 )
 
-func newApi(ctx context.Context, client *gapic.RegistryClient, message *rpc.Api, nested bool) (*models.Api, error) {
+// NewApi allows an API to be individually exported as a YAML file.
+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
@@ -48,7 +48,7 @@ func newApi(ctx context.Context, client *gapic.RegistryClient, message *rpc.Api,
 		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)
+			version, err := NewApiVersion(ctx, client, message, true)
 			if err != nil {
 				return err
 			}
@@ -64,7 +64,7 @@ func newApi(ctx context.Context, client *gapic.RegistryClient, message *rpc.Api,
 		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)
+			deployment, err = NewApiDeployment(ctx, client, message, true)
 			if err != nil {
 				return err
 			}
@@ -109,7 +109,7 @@ func newApi(ctx context.Context, client *gapic.RegistryClient, message *rpc.Api,
 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)
+		artifact, err := NewArtifact(ctx, client, message)
 		if err != nil {
 			log.FromContext(ctx).Warnf("Skipping %s: %s", message.Name, err)
 			return nil
@@ -129,20 +129,6 @@ func collectChildArtifacts(ctx context.Context, client *gapic.RegistryClient, ar
 	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, nested bool) ([]byte, *models.Header, error) {
-	api, err := newApi(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
-}
-
 // TODO: These functions assume that their arguments are valid names and fail the export if they aren't.
 // They are used to replace the absolute resource names stored by the API with more reusable relative
 // resource names that allow the exported files to be applied to arbitrary projects. This more
diff --git a/cmd/registry/patch/artifact.go b/cmd/registry/patch/artifact.go
index 064d991..7a7a068 100644
--- a/cmd/registry/patch/artifact.go
+++ b/cmd/registry/patch/artifact.go
@@ -15,7 +15,6 @@
 package patch
 
 import (
-	"bytes"
 	"context"
 	"encoding/json"
 	"errors"
@@ -33,30 +32,6 @@ import (
 	"google.golang.org/protobuf/proto"
 )
 
-// ExportArtifact allows an artifact to be individually exported as a YAML file.
-func ExportArtifact(ctx context.Context, client *gapic.RegistryClient, message *rpc.Artifact) ([]byte, *models.Header, error) {
-	if message.Contents == nil {
-		req := &rpc.GetArtifactContentsRequest{
-			Name: message.Name,
-		}
-		body, err := client.GetArtifactContents(ctx, req)
-		if err != nil {
-			return nil, nil, err
-		}
-		message.Contents = body.Data
-	}
-	artifact, err := newArtifact(message)
-	if err != nil {
-		return nil, nil, err
-	}
-	var b bytes.Buffer
-	err = yamlEncoder(&b).Encode(artifact)
-	if err != nil {
-		return nil, nil, err
-	}
-	return b.Bytes(), &artifact.Header, nil
-}
-
 // styleForYAML sets the style field on a tree of yaml.Nodes for YAML export.
 func styleForYAML(node *yaml.Node) {
 	node.Style = 0
@@ -100,7 +75,18 @@ func removeIdAndKind(node *yaml.Node) *yaml.Node {
 	return node
 }
 
-func newArtifact(message *rpc.Artifact) (*models.Artifact, error) {
+// NewArtifact allows an artifact to be individually exported as a YAML file.
+func NewArtifact(ctx context.Context, client *gapic.RegistryClient, message *rpc.Artifact) (*models.Artifact, error) {
+	if message.Contents == nil {
+		req := &rpc.GetArtifactContentsRequest{
+			Name: message.Name,
+		}
+		body, err := client.GetArtifactContents(ctx, req)
+		if err != nil {
+			return nil, err
+		}
+		message.Contents = body.Data
+	}
 	artifactName, err := names.ParseArtifact(message.Name)
 	if err != nil {
 		return nil, err
diff --git a/cmd/registry/patch/deployment.go b/cmd/registry/patch/deployment.go
index 44343cd..14a73fb 100644
--- a/cmd/registry/patch/deployment.go
+++ b/cmd/registry/patch/deployment.go
@@ -15,7 +15,6 @@
 package patch
 
 import (
-	"bytes"
 	"context"
 	"strings"
 
@@ -27,20 +26,6 @@ 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 == "" {
@@ -60,8 +45,9 @@ func optionalSpecRevisionName(deploymentName names.Deployment, subpath string) s
 	return deploymentName.Api().String() + "/versions/" + subpath
 }
 
-func newApiDeployment(ctx context.Context, client *gapic.RegistryClient, message *rpc.ApiDeployment, nested bool) (*models.ApiDeployment, error) {
-	deploymentName, err := names.ParseDeployment(message.Name)
+// NewApiDeployment allows an API deployment to be individually exported as a YAML file.
+func NewApiDeployment(ctx context.Context, client *gapic.RegistryClient, message *rpc.ApiDeployment, nested bool) (*models.ApiDeployment, error) {
+	deploymentName, err := names.ParseDeploymentRevision(message.Name)
 	if err != nil {
 		return nil, err
 	}
diff --git a/cmd/registry/patch/project.go b/cmd/registry/patch/project.go
index 620fe06..ac728c2 100644
--- a/cmd/registry/patch/project.go
+++ b/cmd/registry/patch/project.go
@@ -26,11 +26,55 @@ import (
 	"github.com/apigee/registry/gapic"
 	"github.com/apigee/registry/log"
 	"github.com/apigee/registry/pkg/connection"
+	"github.com/apigee/registry/pkg/models"
 	"github.com/apigee/registry/rpc"
 	"github.com/apigee/registry/server/registry/names"
 	"google.golang.org/grpc/metadata"
+	"gopkg.in/yaml.v3"
 )
 
+// NewProject gets a serialized representation of a project.
+func NewProject(ctx context.Context, client *gapic.RegistryClient, message *rpc.Project) (*models.Project, error) {
+	projectName, err := names.ParseProject(message.Name)
+	if err != nil {
+		return nil, err
+	}
+	return &models.Project{
+		Header: models.Header{
+			ApiVersion: RegistryV1,
+			Kind:       "Project",
+			Metadata: models.Metadata{
+				Name: projectName.ProjectID,
+			},
+		},
+		Data: models.ProjectData{
+			DisplayName: message.DisplayName,
+			Description: message.Description,
+		},
+	}, err
+}
+
+func applyProjectPatchBytes(ctx context.Context, client connection.AdminClient, bytes []byte) error {
+	var project models.Project
+	err := yaml.Unmarshal(bytes, &project)
+	if err != nil {
+		return err
+	}
+	req := &rpc.UpdateProjectRequest{
+		Project: &rpc.Project{
+			Name:        "projects/" + project.Metadata.Name,
+			DisplayName: project.Data.DisplayName,
+			Description: project.Data.Description,
+		},
+		AllowMissing: true,
+	}
+	_, err = client.UpdateProject(ctx, req)
+	if err != nil {
+		return err
+	}
+	return nil
+}
+
 // ExportProject writes a project into a directory of YAML files.
 func ExportProject(ctx context.Context, client *gapic.RegistryClient, projectName names.Project, root string, taskQueue chan<- core.Task, nested bool) error {
 	if root != "" {
@@ -160,16 +204,20 @@ func (task *exportAPITask) String() string {
 }
 
 func (task *exportAPITask) Run(ctx context.Context) error {
-	bytes, header, err := ExportAPI(ctx, task.client, task.message, task.nested)
+	api, err := NewApi(ctx, task.client, task.message, task.nested)
+	if err != nil {
+		return err
+	}
+	bytes, err := Encode(api)
 	if err != nil {
 		return err
 	}
 	log.FromContext(ctx).Infof("Exported %s", task.message.Name)
 	var filename string
 	if task.nested {
-		filename = fmt.Sprintf("%s/apis/%s.yaml", task.dir, header.Metadata.Name)
+		filename = fmt.Sprintf("%s/apis/%s.yaml", task.dir, api.Header.Metadata.Name)
 	} else {
-		filename = fmt.Sprintf("%s/apis/%s/info.yaml", task.dir, header.Metadata.Name)
+		filename = fmt.Sprintf("%s/apis/%s/info.yaml", task.dir, api.Header.Metadata.Name)
 	}
 	parentDir := filepath.Dir(filename)
 	if err := os.MkdirAll(parentDir, 0777); err != nil {
@@ -189,21 +237,25 @@ func (task *exportArtifactTask) String() string {
 }
 
 func (task *exportArtifactTask) Run(ctx context.Context) error {
-	bytes, header, err := ExportArtifact(ctx, task.client, task.message)
+	artifact, err := NewArtifact(ctx, task.client, task.message)
 	if err != nil {
 		log.FromContext(ctx).Warnf("Skipped %s: %s", task.message.Name, err)
 		return nil
 	}
-	if header.Kind == "Artifact" { // "Artifact" is the generic artifact type
+	if artifact.Header.Kind == "Artifact" { // "Artifact" is the generic artifact type
 		log.FromContext(ctx).Warnf("Skipped %s", task.message.Name)
 		return nil
 	}
+	bytes, err := Encode(artifact)
+	if err != nil {
+		return err
+	}
 	log.FromContext(ctx).Infof("Exported %s", task.message.Name)
 	var filename string
-	if header.Metadata.Parent == "" {
-		filename = fmt.Sprintf("%s/artifacts/%s.yaml", task.dir, header.Metadata.Name)
+	if artifact.Header.Metadata.Parent == "" {
+		filename = fmt.Sprintf("%s/artifacts/%s.yaml", task.dir, artifact.Header.Metadata.Name)
 	} else {
-		filename = fmt.Sprintf("%s/%s/artifacts/%s.yaml", task.dir, header.Metadata.Parent, header.Metadata.Name)
+		filename = fmt.Sprintf("%s/%s/artifacts/%s.yaml", task.dir, artifact.Header.Metadata.Parent, artifact.Header.Metadata.Name)
 	}
 	parentDir := filepath.Dir(filename)
 	if err := os.MkdirAll(parentDir, 0777); err != nil {
@@ -224,7 +276,11 @@ func (task *exportVersionTask) String() string {
 }
 
 func (task *exportVersionTask) Run(ctx context.Context) error {
-	bytes, header, err := ExportAPIVersion(ctx, task.client, task.message, task.nested)
+	version, err := NewApiVersion(ctx, task.client, task.message, task.nested)
+	if err != nil {
+		return err
+	}
+	bytes, err := Encode(version)
 	if err != nil {
 		return err
 	}
@@ -233,7 +289,7 @@ func (task *exportVersionTask) Run(ctx context.Context) error {
 	if task.nested {
 		filename = ""
 	} else {
-		filename = fmt.Sprintf("%s/%s/versions/%s/info.yaml", task.dir, header.Metadata.Parent, header.Metadata.Name)
+		filename = fmt.Sprintf("%s/%s/versions/%s/info.yaml", task.dir, version.Header.Metadata.Parent, version.Header.Metadata.Name)
 	}
 	parentDir := filepath.Dir(filename)
 	if err := os.MkdirAll(parentDir, 0777); err != nil {
@@ -254,7 +310,11 @@ func (task *exportSpecTask) String() string {
 }
 
 func (task *exportSpecTask) Run(ctx context.Context) error {
-	bytes, header, err := ExportAPISpec(ctx, task.client, task.message, task.nested)
+	spec, err := NewApiSpec(ctx, task.client, task.message, task.nested)
+	if err != nil {
+		return err
+	}
+	bytes, err := Encode(spec)
 	if err != nil {
 		return err
 	}
@@ -263,7 +323,7 @@ func (task *exportSpecTask) Run(ctx context.Context) error {
 	if task.nested {
 		filename = ""
 	} else {
-		filename = fmt.Sprintf("%s/%s/specs/%s/info.yaml", task.dir, header.Metadata.Parent, header.Metadata.Name)
+		filename = fmt.Sprintf("%s/%s/specs/%s/info.yaml", task.dir, spec.Header.Metadata.Parent, spec.Header.Metadata.Name)
 	}
 	parentDir := filepath.Dir(filename)
 	if err := os.MkdirAll(parentDir, 0777); err != nil {
@@ -301,7 +361,11 @@ func (task *exportDeploymentTask) String() string {
 }
 
 func (task *exportDeploymentTask) Run(ctx context.Context) error {
-	bytes, header, err := ExportAPIDeployment(ctx, task.client, task.message, task.nested)
+	deployment, err := NewApiDeployment(ctx, task.client, task.message, task.nested)
+	if err != nil {
+		return err
+	}
+	bytes, err := Encode(deployment)
 	if err != nil {
 		return err
 	}
@@ -310,7 +374,7 @@ func (task *exportDeploymentTask) Run(ctx context.Context) error {
 	if task.nested {
 		filename = ""
 	} else {
-		filename = fmt.Sprintf("%s/%s/deployments/%s/info.yaml", task.dir, header.Metadata.Parent, header.Metadata.Name)
+		filename = fmt.Sprintf("%s/%s/deployments/%s/info.yaml", task.dir, deployment.Header.Metadata.Parent, deployment.Header.Metadata.Name)
 	}
 	parentDir := filepath.Dir(filename)
 	if err := os.MkdirAll(parentDir, 0777); err != nil {
diff --git a/cmd/registry/patch/spec.go b/cmd/registry/patch/spec.go
index 8e2b9fd..9a54f38 100644
--- a/cmd/registry/patch/spec.go
+++ b/cmd/registry/patch/spec.go
@@ -15,7 +15,6 @@
 package patch
 
 import (
-	"bytes"
 	"context"
 	"io"
 	"net/http"
@@ -34,22 +33,9 @@ import (
 	"gopkg.in/yaml.v3"
 )
 
-// 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)
+// NewApiSpec allows an API spec to be individually exported as a YAML file.
+func NewApiSpec(ctx context.Context, client *gapic.RegistryClient, message *rpc.ApiSpec, nested bool) (*models.ApiSpec, error) {
+	specName, err := names.ParseSpecRevision(message.Name)
 	if err != nil {
 		return nil, err
 	}
diff --git a/cmd/registry/patch/version.go b/cmd/registry/patch/version.go
index b8d7bfa..c68efdc 100644
--- a/cmd/registry/patch/version.go
+++ b/cmd/registry/patch/version.go
@@ -15,7 +15,6 @@
 package patch
 
 import (
-	"bytes"
 	"context"
 
 	"github.com/apigee/registry/cmd/registry/core"
@@ -27,21 +26,8 @@ import (
 	"gopkg.in/yaml.v3"
 )
 
-// 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) {
+// NewApiVersion allows an API version to be individually exported as a YAML file.
+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
@@ -52,7 +38,7 @@ func newApiVersion(ctx context.Context, client *gapic.RegistryClient, message *r
 	if nested {
 		specs = make([]*models.ApiSpec, 0)
 		if err = core.ListSpecs(ctx, client, versionName.Spec("-"), "", false, func(message *rpc.ApiSpec) error {
-			spec, err := newApiSpec(ctx, client, message, true)
+			spec, err := NewApiSpec(ctx, client, message, true)
 			if err != nil {
 				return err
 			}
diff --git a/cmd/registry/patch/yaml.go b/cmd/registry/patch/yaml.go
index 4ef715b..b2a34d6 100644
--- a/cmd/registry/patch/yaml.go
+++ b/cmd/registry/patch/yaml.go
@@ -15,6 +15,7 @@
 package patch
 
 import (
+	"bytes"
 	"io"
 
 	"gopkg.in/yaml.v3"
@@ -26,3 +27,13 @@ func yamlEncoder(dst io.Writer) *yaml.Encoder {
 	enc.SetIndent(2)
 	return enc
 }
+
+// Encode a patch model.
+func Encode(model interface{}) ([]byte, error) {
+	var b bytes.Buffer
+	err := yamlEncoder(&b).Encode(model)
+	if err != nil {
+		return nil, err
+	}
+	return b.Bytes(), nil
+}
diff --git a/cmd/registry/types/types.go b/cmd/registry/types/types.go
index 684522f..c555a05 100644
--- a/cmd/registry/types/types.go
+++ b/cmd/registry/types/types.go
@@ -71,6 +71,13 @@ func IsZipArchive(mimeType string) bool {
 	return strings.Contains(mimeType, "+zip")
 }
 
+// IsPrintableType returns true if the corresponding contents can be treated as a string.
+func IsPrintableType(mimeType string) bool {
+	return mimeType == "text/plain" ||
+		strings.HasPrefix(mimeType, "application/yaml") ||
+		strings.HasPrefix(mimeType, "application/json")
+}
+
 // MimeTypeForMessageType returns a MIME type that represents a Protocol Buffer message type.
 func MimeTypeForMessageType(protoType string) string {
 	return fmt.Sprintf("application/octet-stream;type=%s", protoType)
diff --git a/pkg/models/header.go b/pkg/models/header.go
index a683b8c..806d826 100644
--- a/pkg/models/header.go
+++ b/pkg/models/header.go
@@ -17,11 +17,11 @@ package models
 type Header struct {
 	ApiVersion string   `yaml:"apiVersion,omitempty"`
 	Kind       string   `yaml:"kind,omitempty"`
-	Metadata   Metadata `yaml:"metadata"`
+	Metadata   Metadata `yaml:"metadata,omitempty"`
 }
 
 type Metadata struct {
-	Name        string            `yaml:"name"`
+	Name        string            `yaml:"name,omitempty"`
 	Parent      string            `yaml:"parent,omitempty"`
 	Labels      map[string]string `yaml:"labels,omitempty"`
 	Annotations map[string]string `yaml:"annotations,omitempty"`
diff --git a/pkg/models/list.go b/pkg/models/list.go
new file mode 100644
index 0000000..3d15503
--- /dev/null
+++ b/pkg/models/list.go
@@ -0,0 +1,20 @@
+// Copyright 2023 Google LLC. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package models
+
+type List struct {
+	Header `yaml:",inline"`
+	Items  []interface{} `yaml:"items"`
+}
diff --git a/pkg/models/project.go b/pkg/models/project.go
new file mode 100644
index 0000000..39a6007
--- /dev/null
+++ b/pkg/models/project.go
@@ -0,0 +1,25 @@
+// Copyright 2023 Google LLC. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package models
+
+type Project struct {
+	Header `yaml:",inline"`
+	Data   ProjectData `yaml:"data"`
+}
+
+type ProjectData struct {
+	DisplayName string `yaml:"displayName,omitempty"`
+	Description string `yaml:"description,omitempty"`
+}


Test patch:
diff --git a/cmd/registry/cmd/get/get_test.go b/cmd/registry/cmd/get/get_test.go
new file mode 100644
index 0000000..29c4d59
--- /dev/null
+++ b/cmd/registry/cmd/get/get_test.go
@@ -0,0 +1,478 @@
+// Copyright 2022 Google LLC. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package get
+
+import (
+	"bytes"
+	"context"
+	"testing"
+
+	"github.com/apigee/registry/cmd/registry/core"
+	"github.com/apigee/registry/cmd/registry/types"
+	"github.com/apigee/registry/pkg/connection"
+	"github.com/apigee/registry/pkg/connection/grpctest"
+	"github.com/apigee/registry/rpc"
+	"github.com/apigee/registry/server/registry"
+	"github.com/apigee/registry/server/registry/test/seeder"
+	"google.golang.org/protobuf/proto"
+)
+
+// TestMain will set up a local RegistryServer and grpc.Server for all
+// tests in this package if APG_REGISTRY_ADDRESS env var is not set
+// for the client.
+func TestMain(m *testing.M) {
+	grpctest.TestMain(m, registry.Config{})
+}
+
+func TestGetValidResources(t *testing.T) {
+	// Seed a registry with a list of leaf-level artifacts.
+	displaySettingsBytes, err := proto.Marshal(&rpc.DisplaySettings{Organization: "Sample"})
+	if err != nil {
+		t.Fatalf("Failed to prepare test data: %+v", err)
+	}
+	seed := []seeder.RegistryResource{
+		&rpc.ApiSpec{Name: "projects/my-project/locations/global/apis/a/versions/v/specs/s", MimeType: "text/plain", Contents: []byte("hello")},
+		&rpc.Artifact{Name: "projects/my-project/locations/global/artifacts/x", MimeType: types.MimeTypeForKind("DisplaySettings"), Contents: displaySettingsBytes},
+		&rpc.Artifact{Name: "projects/my-project/locations/global/apis/a/artifacts/x", MimeType: "application/yaml", Contents: []byte("hello: 123")},
+		&rpc.Artifact{Name: "projects/my-project/locations/global/apis/a/versions/v/artifacts/x", MimeType: "application/yaml", Contents: []byte("hello: 123")},
+		&rpc.Artifact{Name: "projects/my-project/locations/global/apis/a/versions/v/specs/s/artifacts/x", MimeType: "application/yaml", Contents: []byte("hello: 123")},
+		&rpc.Artifact{Name: "projects/my-project/locations/global/apis/a/deployments/d/artifacts/x", MimeType: "application/yaml", Contents: []byte("hello: 123")},
+		&rpc.Artifact{Name: "projects/my-project/locations/global/apis/b/versions/v/specs/s/artifacts/x", MimeType: "application/yaml", Contents: []byte("hello: 123")},
+	}
+	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,
+	}
+	t.Cleanup(func() {
+		_ = adminClient.DeleteProject(ctx, &rpc.DeleteProjectRequest{Name: "projects/my-project", Force: true})
+	})
+	_ = adminClient.DeleteProject(ctx, &rpc.DeleteProjectRequest{Name: "projects/my-project", Force: true})
+	if err := seeder.SeedRegistry(ctx, client, seed...); err != nil {
+		t.Fatalf("Setup/Seeding: Failed to seed registry: %s", err)
+	}
+
+	spec, err := registryClient.GetApiSpec(ctx, &rpc.GetApiSpecRequest{Name: "projects/my-project/locations/global/apis/a/versions/v/specs/s"})
+	if err != nil {
+		t.Fatalf("Failed to prepare test data: %+v", err)
+	}
+	deployment, err := registryClient.GetApiDeployment(ctx, &rpc.GetApiDeploymentRequest{Name: "projects/my-project/locations/global/apis/a/deployments/d"})
+	if err != nil {
+		t.Fatalf("Failed to prepare test data: %+v", err)
+	}
+	// Verify that get runs for each resource.
+	resources := []string{
+		"projects",
+		"projects/-",
+		"projects/my-project",
+		"projects/my-project/locations/global/artifacts",
+		"projects/my-project/locations/global/artifacts/-",
+		"projects/my-project/locations/global/artifacts/x",
+		"projects/my-project/locations/global/apis",
+		"projects/my-project/locations/global/apis/-",
+		"projects/my-project/locations/global/apis/a",
+		"projects/my-project/locations/global/apis/a/artifacts",
+		"projects/my-project/locations/global/apis/a/artifacts/-",
+		"projects/my-project/locations/global/apis/a/artifacts/x",
+		"projects/my-project/locations/global/apis/a/versions",
+		"projects/my-project/locations/global/apis/a/versions/-",
+		"projects/my-project/locations/global/apis/a/versions/v",
+		"projects/my-project/locations/global/apis/a/versions/v/artifacts",
+		"projects/my-project/locations/global/apis/a/versions/v/artifacts/-",
+		"projects/my-project/locations/global/apis/a/versions/v/artifacts/x",
+		"projects/my-project/locations/global/apis/a/versions/v/specs",
+		"projects/my-project/locations/global/apis/a/versions/v/specs/-",
+		"projects/my-project/locations/global/apis/a/versions/v/specs/-@-",
+		"projects/my-project/locations/global/apis/a/versions/v/specs/s",
+		"projects/my-project/locations/global/apis/a/versions/v/specs/s@",
+		"projects/my-project/locations/global/apis/a/versions/v/specs/s@-",
+		"projects/my-project/locations/global/apis/a/versions/v/specs/s@" + spec.RevisionId,
+		"projects/my-project/locations/global/apis/a/versions/v/specs/s/artifacts",
+		"projects/my-project/locations/global/apis/a/versions/v/specs/s/artifacts/-",
+		"projects/my-project/locations/global/apis/a/versions/v/specs/s/artifacts/x",
+		"projects/my-project/locations/global/apis/a/deployments",
+		"projects/my-project/locations/global/apis/a/deployments/-",
+		"projects/my-project/locations/global/apis/a/deployments/-@-",
+		"projects/my-project/locations/global/apis/a/deployments/d",
+		"projects/my-project/locations/global/apis/a/deployments/d@",
+		"projects/my-project/locations/global/apis/a/deployments/d@-",
+		"projects/my-project/locations/global/apis/a/deployments/d@" + deployment.RevisionId,
+		"projects/my-project/locations/global/apis/a/deployments/d/artifacts",
+		"projects/my-project/locations/global/apis/a/deployments/d/artifacts/-",
+		"projects/my-project/locations/global/apis/a/deployments/d/artifacts/x",
+	}
+	// get names for each resource
+	for _, r := range resources {
+		t.Run(r, func(t *testing.T) {
+			cmd := Command()
+			args := []string{r, "-o", "name"}
+			cmd.SetArgs(args)
+			out := bytes.NewBuffer(make([]byte, 0))
+			cmd.SetOut(out)
+			if err := cmd.Execute(); err != nil {
+				t.Errorf("Execute() with args %v returned error: %s", args, err)
+			}
+			if len(out.Bytes()) == 0 {
+				t.Errorf("Execute() with args %v failed to return expected value(s)", args)
+			}
+		})
+	}
+	// get yaml for each resource
+	for _, r := range resources {
+		t.Run(r, func(t *testing.T) {
+			cmd := Command()
+			args := []string{r, "-o", "yaml"}
+			cmd.SetArgs(args)
+			out := bytes.NewBuffer(make([]byte, 0))
+			cmd.SetOut(out)
+			if err := cmd.Execute(); err != nil {
+				t.Errorf("Execute() with args %v returned error: %s", args, err)
+			}
+			if len(out.Bytes()) == 0 {
+				t.Errorf("Execute() with args %v failed to return expected value(s)", args)
+			}
+		})
+	}
+	resourcesWithContents := []string{
+		"projects/my-project/locations/global/apis/a/versions/v/specs/s",
+		"projects/my-project/locations/global/artifacts/x",
+		"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",
+	}
+	// Get the contents of these resources.
+	for _, r := range resourcesWithContents {
+		t.Run(r, func(t *testing.T) {
+			cmd := Command()
+			args := []string{r, "-o", "contents"}
+			cmd.SetArgs(args)
+			out := bytes.NewBuffer(make([]byte, 0))
+			cmd.SetOut(out)
+			if err := cmd.Execute(); err != nil {
+				t.Fatalf("Execute() with args %v returned error: %s", args, err)
+			}
+			if len(out.Bytes()) == 0 {
+				t.Errorf("Execute() with args %v failed to return expected value(s)", args)
+			}
+		})
+	}
+}
+
+func TestGetInvalidResources(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,
+	}
+	t.Cleanup(func() {
+		_ = adminClient.DeleteProject(ctx, &rpc.DeleteProjectRequest{Name: "projects/my-project", Force: true})
+	})
+	_ = adminClient.DeleteProject(ctx, &rpc.DeleteProjectRequest{Name: "projects/my-project", Force: true})
+	if err := seeder.SeedArtifacts(ctx, client, artifacts...); err != nil {
+		t.Fatalf("Setup/Seeding: Failed to seed registry: %s", err)
+	}
+	// Verify that invalid gets fail.
+	invalid := []string{
+		"projects/my-project/locations/global/invalid",
+		"projects/my-project/locations/global/apis/-/invalid",
+		"projects/my-project/locations/global/apis/-/artifacts/xx",
+		"projects/my-project/locations/global/apis/-/versions/-/artifacts/xx",
+		"projects/my-project/locations/global/apis/-/versions/-/specs/-/artifacts/xx",
+		"projects/my-project/locations/global/apis/-/deployments/-/artifacts/xx",
+		"projects/my-project/locations/global/apis/a/invalid",
+		"projects/my-project/locations/global/apis/a/versions/v/invalid",
+		"projects/my-project/locations/global/apis/a/versions/v/specs/s/invalid",
+		"projects/my-project/locations/global/apis/a/deployments/d/invalid",
+		"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 i, r := range invalid {
+		t.Run(r, func(t *testing.T) {
+			// cycle through output types
+			format := []string{"name", "yaml", "contents"}[i%3]
+			cmd := Command()
+			cmd.SilenceUsage = true
+			cmd.SilenceErrors = true
+			args := []string{r, "-o", format}
+			cmd.SetArgs(args)
+			if err := cmd.Execute(); err == nil {
+				t.Errorf("Execute() with args %v succeeded but should have failed", args)
+			}
+		})
+	}
+	// attempts to get contents of resources that don't support it should fail
+	resourcesWithoutContents := []string{
+		"projects/my-project",
+		"projects/my-project/locations/global/apis/a",
+		"projects/my-project/locations/global/apis/a/versions/v",
+		"projects/my-project/locations/global/apis/a/deployments/d",
+	}
+	for _, r := range resourcesWithoutContents {
+		t.Run(r+"--output-contents", func(t *testing.T) {
+			cmd := Command()
+			cmd.SilenceUsage = true
+			cmd.SilenceErrors = true
+			args := []string{r, "-o", "contents"}
+			cmd.SetArgs(args)
+			if err := cmd.Execute(); err == nil {
+				t.Errorf("Execute() with args %v succeeded but should have failed", args)
+			}
+		})
+	}
+	// attempts to get an unsupported output type should fail
+	resources := []string{
+		"projects/my-project",
+		"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",
+		"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 {
+		t.Run(r+"--output-invalid", func(t *testing.T) {
+			cmd := Command()
+			cmd.SilenceUsage = true
+			cmd.SilenceErrors = true
+			args := []string{r, "-o", "invalid"}
+			cmd.SetArgs(args)
+			if err := cmd.Execute(); err == nil {
+				t.Errorf("Execute() with args %v succeeded but should have failed", args)
+			}
+		})
+	}
+}
+
+func TestGetValidResourcesWithFilter(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,
+	}
+	t.Cleanup(func() {
+		_ = adminClient.DeleteProject(ctx, &rpc.DeleteProjectRequest{Name: "projects/my-project", Force: true})
+	})
+	_ = adminClient.DeleteProject(ctx, &rpc.DeleteProjectRequest{Name: "projects/my-project", Force: true})
+	if err := seeder.SeedArtifacts(ctx, client, artifacts...); err != nil {
+		t.Fatalf("Setup/Seeding: Failed to seed registry: %s", err)
+	}
+
+	// Verify that a filter specified on a get of a collection is ok.
+	valid_collections := []string{
+		"projects/my-project/locations/global/apis",
+		"projects/my-project/locations/global/apis/a/versions",
+		"projects/my-project/locations/global/apis/a/versions/v/specs",
+		"projects/my-project/locations/global/apis/a/deployments",
+		"projects/my-project/locations/global/apis/a/artifacts",
+		"projects/my-project/locations/global/apis/a/versions/v/artifacts",
+		"projects/my-project/locations/global/apis/a/versions/v/specs/s/artifacts",
+		"projects/my-project/locations/global/apis/a/deployments/d/artifacts",
+	}
+	for _, c := range valid_collections {
+		t.Run(c, func(t *testing.T) {
+			cmd := Command()
+			out := bytes.NewBuffer(make([]byte, 0))
+			cmd.SetOut(out)
+			args := []string{c, "--filter", "name.contains('a')"}
+			cmd.SetArgs(args)
+			if err := cmd.Execute(); err != nil {
+				t.Errorf("Execute() with args %v failed but should have succeeded", args)
+			}
+			if len(out.Bytes()) == 0 {
+				t.Errorf("Execute() with args %v failed to return expected value(s)", args)
+			}
+		})
+	}
+
+	// Verify that a filter specified on a get of an individual resource is an error.
+	valid_resources := []string{
+		"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 valid_resources {
+		t.Run(r, func(t *testing.T) {
+			cmd := Command()
+			cmd.SilenceUsage = true
+			cmd.SilenceErrors = true
+			args := []string{r, "--filter", "name.contains('a')"}
+			cmd.SetArgs(args)
+			if err := cmd.Execute(); err == nil {
+				t.Errorf("Execute() with args %v succeeded but should have failed", args)
+			}
+		})
+	}
+}
+
+func TestGetGZippedSpec(t *testing.T) {
+	payload := "hello"
+	contents, err := core.GZippedBytes([]byte(payload))
+	if err != nil {
+		t.Fatalf("Failed to create client: %+v", err)
+	}
+	seed := []*rpc.ApiSpec{
+		{Name: "projects/my-project/locations/global/apis/a/versions/v/specs/s", MimeType: "text/plain+gzip", Contents: contents},
+	}
+	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,
+	}
+	t.Cleanup(func() {
+		_ = adminClient.DeleteProject(ctx, &rpc.DeleteProjectRequest{Name: "projects/my-project", Force: true})
+	})
+	_ = adminClient.DeleteProject(ctx, &rpc.DeleteProjectRequest{Name: "projects/my-project", Force: true})
+	if err := seeder.SeedSpecs(ctx, client, seed...); err != nil {
+		t.Fatalf("Setup/Seeding: Failed to seed registry: %s", err)
+	}
+
+	cmd := Command()
+	out := bytes.NewBuffer(make([]byte, 0))
+	cmd.SetOut(out)
+	args := []string{"projects/my-project/locations/global/apis/a/versions/v/specs/s", "-o", "contents"}
+	cmd.SetArgs(args)
+	if err := cmd.Execute(); err != nil {
+		t.Errorf("Execute() with args %v failed but should have succeeded", args)
+	}
+	if len(out.Bytes()) == 0 {
+		t.Errorf("Execute() with args %v failed to return expected value(s)", args)
+	}
+	if out.String() != payload {
+		t.Errorf("Execute() with args %v returned spec %q, expected %q", out.String(), args, payload)
+	}
+}
+
+func TestGetMultipleContentRequestsShouldFail(t *testing.T) {
+	seed := []*rpc.Artifact{
+		{Name: "projects/my-project/locations/global/apis/a/versions/v/specs/s/artifacts/x", MimeType: "application/yaml", Contents: []byte("hello: 123")},
+		{Name: "projects/my-project/locations/global/apis/b/versions/v/specs/s/artifacts/x", MimeType: "application/yaml", Contents: []byte("hello: 123")},
+	}
+	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,
+	}
+	t.Cleanup(func() {
+		_ = adminClient.DeleteProject(ctx, &rpc.DeleteProjectRequest{Name: "projects/my-project", Force: true})
+	})
+	_ = adminClient.DeleteProject(ctx, &rpc.DeleteProjectRequest{Name: "projects/my-project", Force: true})
+	if err := seeder.SeedArtifacts(ctx, client, seed...); err != nil {
+		t.Fatalf("Setup/Seeding: Failed to seed registry: %s", err)
+	}
+	// Verify that a filter specified on a get of an individual resource is an error.
+	multiple_resources := []string{
+		"projects/my-project/locations/global/apis/-/versions/v/specs/s",
+		"projects/my-project/locations/global/apis/-/versions/v/specs/s/artifacts/x",
+	}
+	for _, r := range multiple_resources {
+		t.Run(r, func(t *testing.T) {
+			cmd := Command()
+			cmd.SilenceUsage = true
+			cmd.SilenceErrors = true
+			args := []string{r, "--output", "contents"}
+			cmd.SetArgs(args)
+			if err := cmd.Execute(); err == nil {
+				t.Errorf("Execute() with args %v succeeded but should have failed", args)
+			}
+		})
+	}
+}
diff --git a/cmd/registry/patch/patch_project_test.go b/cmd/registry/patch/patch_project_test.go
index 03cfb59..8c94103 100644
--- a/cmd/registry/patch/patch_project_test.go
+++ b/cmd/registry/patch/patch_project_test.go
@@ -17,7 +17,7 @@ import (
 	"google.golang.org/grpc/status"
 )
 
-func TestProjectPatches(t *testing.T) {
+func TestProjectImports(t *testing.T) {
 	// Each of these three imports should import an identical project that can be exported
 	// into the structures in "sampleDir".
 	const sampleDir = "testdata/sample-nested"
@@ -92,11 +92,14 @@ func TestProjectPatches(t *testing.T) {
 				t.Fatalf("Failed to get API: %s", err)
 			}
 
-			actual, _, err := ExportAPI(ctx, registryClient, got, true)
+			model, err := NewApi(ctx, registryClient, got, true)
 			if err != nil {
-				t.Fatalf("ExportApi(%+v) returned an error: %s", got, err)
+				t.Fatalf("NewApi(%+v) returned an error: %s", got, err)
+			}
+			actual, err := Encode(model)
+			if err != nil {
+				t.Fatalf("Encode(%+v) returned an error: %s", model, err)
 			}
-
 			if diff := cmp.Diff(expected, actual); diff != "" {
 				t.Errorf("GetApi(%q) returned unexpected diff: (-want +got):\n%s", got, diff)
 			}
@@ -118,11 +121,14 @@ func TestProjectPatches(t *testing.T) {
 					t.Fatalf("Failed to verify artifact existence: %s", err)
 				}
 
-				actual, _, err := ExportArtifact(ctx, registryClient, message)
+				model, err := NewArtifact(ctx, registryClient, message)
 				if err != nil {
-					t.Fatalf("ExportArtifact(%+v) returned an error: %s", message, err)
+					t.Fatalf("NewArtifact(%+v) returned an error: %s", message, err)
+				}
+				actual, err := Encode(model)
+				if err != nil {
+					t.Fatalf("Encode(%+v) returned an error: %s", model, err)
 				}
-
 				if diff := cmp.Diff(expected, actual); diff != "" {
 					t.Errorf("GetArtifact(%q) returned unexpected diff: (-want +got):\n%s", message, diff)
 				}
diff --git a/cmd/registry/patch/patch_test.go b/cmd/registry/patch/patch_test.go
index 6872dc5..b458acc 100644
--- a/cmd/registry/patch/patch_test.go
+++ b/cmd/registry/patch/patch_test.go
@@ -25,6 +25,94 @@ func TestMain(m *testing.M) {
 	grpctest.TestMain(m, registry.Config{})
 }
 
+func TestProjectPatches(t *testing.T) {
+	tests := []struct {
+		desc       string
+		resourceID string
+		yamlFile   string
+		parent     string
+		message    proto.Message
+	}{
+		{
+			desc:       "sample",
+			resourceID: "sample",
+			yamlFile:   "testdata/resources/projects-sample.yaml",
+			parent:     "",
+			message: &rpc.Project{
+				Name:        "projects/sample",
+				DisplayName: "Sample",
+				Description: "This sample project is described by a YAML file.",
+			},
+		},
+	}
+	ctx := context.Background()
+	adminClient, err := connection.NewAdminClient(ctx)
+	if err != nil {
+		t.Fatalf("Setup: failed to create client: %+v", err)
+	}
+	defer adminClient.Close()
+	registryClient, err := connection.NewRegistryClient(ctx)
+	if err != nil {
+		t.Fatalf("Setup: Failed to create registry client: %s", err)
+	}
+	defer registryClient.Close()
+	for _, test := range tests {
+		t.Run(test.desc, func(t *testing.T) {
+			b, err := os.ReadFile(test.yamlFile)
+			if err != nil {
+				t.Fatalf("%s", err)
+			}
+			err = applyProjectPatchBytes(ctx, adminClient, b)
+			if err != nil {
+				t.Fatalf("%s", err)
+			}
+			collection := "projects/"
+			projectName, err := names.ParseProject(collection + test.resourceID)
+			if err != nil {
+				t.Fatalf("%s", err)
+			}
+			opts := cmp.Options{
+				protocmp.Transform(),
+				protocmp.IgnoreFields(new(rpc.Project), "create_time", "update_time"),
+			}
+			err = core.GetProject(ctx, adminClient, projectName,
+				func(project *rpc.Project) error {
+					if !cmp.Equal(test.message, project, opts) {
+						t.Errorf("GetDiff returned unexpected diff (-want +got):\n%s", cmp.Diff(test.message, project, opts))
+					}
+					model, err := NewProject(ctx, registryClient, project)
+					if err != nil {
+						t.Fatalf("%s", err)
+					}
+					if model.Header.Metadata.Parent != test.parent {
+						t.Errorf("Incorrect export parent. Wanted %s, got %s", test.parent, model.Header.Metadata.Parent)
+					}
+					if model.Header.Metadata.Name != test.resourceID {
+						t.Errorf("Incorrect export name. Wanted %s, got %s", test.resourceID, model.Header.Metadata.Name)
+					}
+					out, err := Encode(model)
+					if err != nil {
+						t.Errorf("Encode(%+v) returned an error: %s", model, err)
+					}
+					if !cmp.Equal(b, out, opts) {
+						t.Errorf("GetDiff returned unexpected diff (-want +got):\n%s", cmp.Diff(b, out, opts))
+					}
+					return nil
+				})
+			if err != nil {
+				t.Fatalf("%s", err)
+			}
+			err = adminClient.DeleteProject(ctx, &rpc.DeleteProjectRequest{
+				Name:  "projects/" + test.resourceID,
+				Force: true,
+			})
+			if err != nil {
+				t.Fatalf("%s", err)
+			}
+		})
+	}
+}
+
 func TestApiPatches(t *testing.T) {
 	root := "projects/patch-api-test/locations/global"
 	tests := []struct {
@@ -115,15 +203,19 @@ func TestApiPatches(t *testing.T) {
 					if !cmp.Equal(test.message, api, opts) {
 						t.Errorf("GetDiff returned unexpected diff (-want +got):\n%s", cmp.Diff(test.message, api, opts))
 					}
-					out, header, err := ExportAPI(ctx, registryClient, api, test.nested)
+					model, err := NewApi(ctx, registryClient, api, test.nested)
 					if err != nil {
 						t.Fatalf("%s", err)
 					}
-					if header.Metadata.Parent != test.parent {
-						t.Errorf("Incorrect export parent. Wanted %s, got %s", test.parent, header.Metadata.Parent)
+					if model.Header.Metadata.Parent != test.parent {
+						t.Errorf("Incorrect export parent. Wanted %s, got %s", test.parent, model.Header.Metadata.Parent)
+					}
+					if model.Header.Metadata.Name != test.resourceID {
+						t.Errorf("Incorrect export name. Wanted %s, got %s", test.resourceID, model.Header.Metadata.Name)
 					}
-					if header.Metadata.Name != test.resourceID {
-						t.Errorf("Incorrect export name. Wanted %s, got %s", test.resourceID, header.Metadata.Name)
+					out, err := Encode(model)
+					if err != nil {
+						t.Errorf("Encode(%+v) returned an error: %s", model, err)
 					}
 					if !cmp.Equal(b, out, opts) {
 						t.Errorf("GetDiff returned unexpected diff (-want +got):\n%s", cmp.Diff(b, out, opts))
@@ -213,15 +305,19 @@ func TestVersionPatches(t *testing.T) {
 					if !cmp.Equal(test.message, version, opts) {
 						t.Errorf("GetDiff returned unexpected diff (-want +got):\n%s", cmp.Diff(test.message, version, opts))
 					}
-					out, header, err := ExportAPIVersion(ctx, registryClient, version, test.nested)
+					model, err := NewApiVersion(ctx, registryClient, version, test.nested)
 					if err != nil {
 						t.Fatalf("%s", err)
 					}
-					if header.Metadata.Parent != test.parent {
-						t.Errorf("Incorrect export parent. Wanted %s, got %s", test.parent, header.Metadata.Parent)
+					if model.Header.Metadata.Parent != test.parent {
+						t.Errorf("Incorrect export parent. Wanted %s, got %s", test.parent, model.Header.Metadata.Parent)
+					}
+					if model.Header.Metadata.Name != test.resourceID {
+						t.Errorf("Incorrect export name. Wanted %s, got %s", test.resourceID, model.Header.Metadata.Name)
 					}
-					if header.Metadata.Name != test.resourceID {
-						t.Errorf("Incorrect export name. Wanted %s, got %s", test.resourceID, header.Metadata.Name)
+					out, err := Encode(model)
+					if err != nil {
+						t.Errorf("Encode(%+v) returned an error: %s", model, err)
 					}
 					if !cmp.Equal(b, out, opts) {
 						t.Errorf("GetDiff returned unexpected diff (-want +got):\n%s", cmp.Diff(b, out, opts))
@@ -312,15 +408,19 @@ func TestSpecPatches(t *testing.T) {
 					if !cmp.Equal(test.message, spec, opts) {
 						t.Errorf("GetDiff returned unexpected diff (-want +got):\n%s", cmp.Diff(test.message, spec, opts))
 					}
-					out, header, err := ExportAPISpec(ctx, registryClient, spec, test.nested)
+					model, err := NewApiSpec(ctx, registryClient, spec, test.nested)
 					if err != nil {
 						t.Fatalf("%s", err)
 					}
-					if header.Metadata.Parent != test.parent {
-						t.Errorf("Incorrect export parent. Wanted %s, got %s", test.parent, header.Metadata.Parent)
+					if model.Header.Metadata.Parent != test.parent {
+						t.Errorf("Incorrect export parent. Wanted %s, got %s", test.parent, model.Header.Metadata.Parent)
+					}
+					if model.Header.Metadata.Name != test.resourceID {
+						t.Errorf("Incorrect export name. Wanted %s, got %s", test.resourceID, model.Header.Metadata.Name)
 					}
-					if header.Metadata.Name != test.resourceID {
-						t.Errorf("Incorrect export name. Wanted %s, got %s", test.resourceID, header.Metadata.Name)
+					out, err := Encode(model)
+					if err != nil {
+						t.Errorf("Encode(%+v) returned an error: %s", model, err)
 					}
 					if !cmp.Equal(b, out, opts) {
 						t.Errorf("GetDiff returned unexpected diff (-want +got):\n%s", cmp.Diff(b, out, opts))
@@ -414,15 +514,19 @@ func TestDeploymentPatches(t *testing.T) {
 					if !cmp.Equal(test.message, deployment, opts) {
 						t.Errorf("GetDiff returned unexpected diff (-want +got):\n%s", cmp.Diff(test.message, deployment, opts))
 					}
-					out, header, err := ExportAPIDeployment(ctx, registryClient, deployment, test.nested)
+					model, err := NewApiDeployment(ctx, registryClient, deployment, test.nested)
 					if err != nil {
 						t.Fatalf("%s", err)
 					}
-					if header.Metadata.Parent != test.parent {
-						t.Errorf("Incorrect export parent. Wanted %s, got %s", test.parent, header.Metadata.Parent)
+					if model.Header.Metadata.Parent != test.parent {
+						t.Errorf("Incorrect export parent. Wanted %s, got %s", test.parent, model.Header.Metadata.Parent)
 					}
-					if header.Metadata.Name != test.resourceID {
-						t.Errorf("Incorrect export name. Wanted %s, got %s", test.resourceID, header.Metadata.Name)
+					if model.Header.Metadata.Name != test.resourceID {
+						t.Errorf("Incorrect export name. Wanted %s, got %s", test.resourceID, model.Header.Metadata.Name)
+					}
+					out, err := Encode(model)
+					if err != nil {
+						t.Errorf("Encode(%+v) returned an error: %s", model, err)
 					}
 					if !cmp.Equal(b, out, opts) {
 						t.Errorf("GetDiff returned unexpected diff (-want +got):\n%s", cmp.Diff(b, out, opts))
@@ -763,15 +867,19 @@ func TestMessageArtifactPatches(t *testing.T) {
 					if !cmp.Equal(test.message, contents, opts) {
 						t.Errorf("GetDiff returned unexpected diff (-want +got):\n%s", cmp.Diff(test.message, contents, opts))
 					}
-					out, header, err := ExportArtifact(ctx, registryClient, artifact)
+					model, err := NewArtifact(ctx, registryClient, artifact)
 					if err != nil {
 						t.Fatalf("%s", err)
 					}
-					if header.Metadata.Parent != test.parent {
-						t.Errorf("Incorrect export parent. Wanted %s, got %s", test.parent, header.Metadata.Parent)
+					if model.Header.Metadata.Parent != test.parent {
+						t.Errorf("Incorrect export parent. Wanted %s, got %s", test.parent, model.Header.Metadata.Parent)
 					}
-					if header.Metadata.Name != test.artifactID {
-						t.Errorf("Incorrect export name. Wanted %s, got %s", test.artifactID, header.Metadata.Name)
+					if model.Header.Metadata.Name != test.artifactID {
+						t.Errorf("Incorrect export name. Wanted %s, got %s", test.artifactID, model.Header.Metadata.Name)
+					}
+					out, err := Encode(model)
+					if err != nil {
+						t.Errorf("Encode(%+v) returned an error: %s", model, err)
 					}
 					if !cmp.Equal(b, out, opts) {
 						t.Errorf("GetDiff returned unexpected diff (-want +got):\n%s", cmp.Diff(b, out, opts))
@@ -849,18 +957,22 @@ func TestYamlArtifactPatches(t *testing.T) {
 			}
 			err = core.GetArtifact(ctx, registryClient, artifactName, true,
 				func(artifact *rpc.Artifact) error {
-					out, header, err := ExportArtifact(ctx, registryClient, artifact)
+					model, err := NewArtifact(ctx, registryClient, artifact)
 					if err != nil {
 						t.Fatalf("%s", err)
 					}
-					if header.Kind != test.kind {
-						t.Errorf("Incorrect export parent. Wanted %s, got %s", test.kind, header.Kind)
+					if model.Header.Kind != test.kind {
+						t.Errorf("Incorrect export parent. Wanted %s, got %s", test.kind, model.Header.Kind)
 					}
-					if header.Metadata.Parent != test.parent {
-						t.Errorf("Incorrect export parent. Wanted %s, got %s", test.parent, header.Metadata.Parent)
+					if model.Header.Metadata.Parent != test.parent {
+						t.Errorf("Incorrect export parent. Wanted %s, got %s", test.parent, model.Header.Metadata.Parent)
 					}
-					if header.Metadata.Name != test.artifactID {
-						t.Errorf("Incorrect export name. Wanted %s, got %s", test.artifactID, header.Metadata.Name)
+					if model.Header.Metadata.Name != test.artifactID {
+						t.Errorf("Incorrect export name. Wanted %s, got %s", test.artifactID, model.Header.Metadata.Name)
+					}
+					out, err := Encode(model)
+					if err != nil {
+						t.Errorf("Encode(%+v) returned an error: %s", model, err)
 					}
 					if !cmp.Equal(b, out) {
 						t.Errorf("GetDiff returned unexpected diff (-want +got):\n%s", cmp.Diff(b, out))
diff --git a/cmd/registry/patch/testdata/resources/projects-sample.yaml b/cmd/registry/patch/testdata/resources/projects-sample.yaml
new file mode 100644
index 0000000..52a3fa7
--- /dev/null
+++ b/cmd/registry/patch/testdata/resources/projects-sample.yaml
@@ -0,0 +1,7 @@
+apiVersion: apigeeregistry/v1
+kind: Project
+metadata:
+  name: sample
+data:
+  displayName: Sample
+  description: This sample project is described by a YAML file.
diff --git a/cmd/registry/types/types_test.go b/cmd/registry/types/types_test.go
index 23d4afb..7cd9f1e 100644
--- a/cmd/registry/types/types_test.go
+++ b/cmd/registry/types/types_test.go
@@ -352,3 +352,35 @@ func TestInvalidTypes(t *testing.T) {
 		})
 	}
 }
+
+func TestPrintableTypes(t *testing.T) {
+	tests := []struct {
+		mimeType    string
+		isPrintable bool
+	}{
+		{
+			mimeType:    "application/octet-stream;type=google.cloud.apigeeregistry.v1.style.StyleGuide",
+			isPrintable: false,
+		},
+		{
+			mimeType:    "text/plain",
+			isPrintable: true,
+		},
+		{
+			mimeType:    "application/json;type=google.cloud.apigeeregistry.v1.style.StyleGuide",
+			isPrintable: true,
+		},
+		{
+			mimeType:    "application/yaml;type=Struct",
+			isPrintable: true,
+		},
+	}
+	for _, test := range tests {
+		t.Run(test.mimeType, func(t *testing.T) {
+			value := IsPrintableType(test.mimeType)
+			if value != test.isPrintable {
+				t.Errorf("Did not obtain expected value for isPrintable: expected %t got %t", test.isPrintable, value)
+			}
+		})
+	}
+}