Repository: apigee/registry (go)

Issue:
Registry packages: Add support code to `pkg/encoding`
As proposed in #1036, add a helper for converting proto messages to YAML nodes:
```
func NodeForMessage(message proto.Message) (yaml.Node, error)
```

Also move [this code](https://github.com/apigee/registry/blob/bdfd66836a91ec41db7b1ce5d5dd9cf76d4c42e4/cmd/registry/patch/yaml.go#L32) from `cmd/registry/cmd/patch` to `pkg/encoding`... but `Encode` probably isn't the right name for this, maybe something with "YAML" in it (`EncodeYAML`)?



Pull request description:
Move YAML encoding support code to pkg/encoding.
This will close #1041.

See `TestYAML()` in `pkg/encoding/yaml_tests.go` for a simple usage example.

Patch:
diff --git a/cmd/registry/cmd/get/get.go b/cmd/registry/cmd/get/get.go
index 10410ec..600b278 100644
--- a/cmd/registry/cmd/get/get.go
+++ b/cmd/registry/cmd/get/get.go
@@ -281,11 +281,11 @@ func (v *getVisitor) write() error {
 			result = v.results[0]
 		} else {
 			result = &encoding.List{
-				Header: encoding.Header{ApiVersion: patch.RegistryV1},
+				Header: encoding.Header{ApiVersion: encoding.RegistryV1},
 				Items:  v.results,
 			}
 		}
-		bytes, err := patch.Encode(result)
+		bytes, err := encoding.EncodeYAML(result)
 		if err != nil {
 			return err
 		}
diff --git a/cmd/registry/patch/api.go b/cmd/registry/patch/api.go
index 97bcd1a..49e1895 100644
--- a/cmd/registry/patch/api.go
+++ b/cmd/registry/patch/api.go
@@ -86,7 +86,7 @@ func NewApi(ctx context.Context, client *gapic.RegistryClient, message *rpc.Api,
 
 	return &encoding.Api{
 		Header: encoding.Header{
-			ApiVersion: RegistryV1,
+			ApiVersion: encoding.RegistryV1,
 			Kind:       "API",
 			Metadata: encoding.Metadata{
 				Name:        apiName.ApiID,
diff --git a/cmd/registry/patch/apply.go b/cmd/registry/patch/apply.go
index 6ae1495..52f6ad2 100644
--- a/cmd/registry/patch/apply.go
+++ b/cmd/registry/patch/apply.go
@@ -26,6 +26,7 @@ import (
 
 	"github.com/apigee/registry/cmd/registry/tasks"
 	"github.com/apigee/registry/pkg/connection"
+	"github.com/apigee/registry/pkg/encoding"
 	"github.com/apigee/registry/pkg/log"
 	"gopkg.in/yaml.v3"
 )
@@ -93,7 +94,7 @@ func (p *patchGroup) parse(client connection.RegistryClient, bytes []byte, fileN
 	header, items, err := readHeaderWithItems(bytes)
 	if err != nil {
 		return err
-	} else if header.ApiVersion != RegistryV1 {
+	} else if header.ApiVersion != encoding.RegistryV1 {
 		return nil
 	}
 
@@ -119,7 +120,7 @@ func (p *patchGroup) parse(client connection.RegistryClient, bytes []byte, fileN
 		if err != nil {
 			return err
 		}
-		if itemHeader.ApiVersion != RegistryV1 {
+		if itemHeader.ApiVersion != encoding.RegistryV1 {
 			continue
 		}
 		p.add(&applyBytesTask{
@@ -193,7 +194,7 @@ func (task *applyBytesTask) Run(ctx context.Context) error {
 	header, err := readHeader(task.bytes)
 	if err != nil {
 		return err
-	} else if header.ApiVersion != RegistryV1 {
+	} else if header.ApiVersion != encoding.RegistryV1 {
 		return nil
 	}
 
diff --git a/cmd/registry/patch/artifact.go b/cmd/registry/patch/artifact.go
index a3fab16..37c0d47 100644
--- a/cmd/registry/patch/artifact.go
+++ b/cmd/registry/patch/artifact.go
@@ -33,49 +33,6 @@ import (
 	"google.golang.org/protobuf/proto"
 )
 
-// styleForYAML sets the style field on a tree of yaml.Nodes for YAML export.
-func styleForYAML(node *yaml.Node) {
-	node.Style = 0
-	for _, n := range node.Content {
-		styleForYAML(n)
-	}
-}
-
-// styleForYAML sets the style field on a tree of yaml.Nodes for JSON export.
-func styleForJSON(node *yaml.Node) {
-	switch node.Kind {
-	case yaml.DocumentNode, yaml.SequenceNode, yaml.MappingNode:
-		node.Style = yaml.FlowStyle
-	case yaml.ScalarNode:
-		switch node.Tag {
-		case "!!str":
-			node.Style = yaml.DoubleQuotedStyle
-		default:
-			node.Style = 0
-		}
-	case yaml.AliasNode:
-	default:
-	}
-	for _, n := range node.Content {
-		styleForJSON(n)
-	}
-}
-
-func removeIdAndKind(node *yaml.Node) *yaml.Node {
-	if node.Kind == yaml.MappingNode {
-		content := make([]*yaml.Node, 0)
-		for i := 0; i < len(node.Content); i += 2 {
-			k := node.Content[i]
-			if k.Value != "id" && k.Value != "kind" {
-				content = append(content, node.Content[i])
-				content = append(content, node.Content[i+1])
-			}
-		}
-		node.Content = content
-	}
-	return node
-}
-
 // NewArtifact allows an artifact to be individually exported as a YAML file.
 func NewArtifact(ctx context.Context, client *gapic.RegistryClient, message *rpc.Artifact) (*encoding.Artifact, error) {
 	if message.Contents == nil {
@@ -110,34 +67,14 @@ func NewArtifact(ctx context.Context, client *gapic.RegistryClient, message *rpc
 		if err = proto.Unmarshal(message.Contents, m); err != nil {
 			return nil, err
 		}
-		// Marshal the artifact content as JSON using the protobuf marshaller.
-		var s []byte
-		s, err = protojson.MarshalOptions{
-			UseEnumNumbers:  false,
-			EmitUnpopulated: true,
-			Indent:          "  ",
-			UseProtoNames:   false,
-		}.Marshal(m)
-		if err != nil {
-			return nil, err
-		}
-		// Unmarshal the JSON with yaml.v3 so that we can re-marshal it as YAML.
-		var doc yaml.Node
-		err = yaml.Unmarshal([]byte(s), &doc)
-		if err != nil {
+		if node, err = encoding.NodeForMessage(m); err != nil {
 			return nil, err
 		}
-		// The top-level node is a "document" node. We need to marshal the node below it.
-		node = doc.Content[0]
-		// Restyle the YAML representation so that it will be serialized with YAML defaults.
-		styleForYAML(node)
-		// We exclude the id and kind fields from YAML serializations.
-		node = removeIdAndKind(node)
 	}
 	// Wrap the artifact for YAML export.
 	return &encoding.Artifact{
 		Header: encoding.Header{
-			ApiVersion: RegistryV1,
+			ApiVersion: encoding.RegistryV1,
 			Kind:       mime.KindForMimeType(message.MimeType),
 			Metadata: encoding.Metadata{
 				Name:        artifactName.ArtifactID(),
@@ -168,7 +105,7 @@ func artifactName(parent string, metadata encoding.Metadata) (names.Artifact, er
 
 func applyArtifactPatch(ctx context.Context, client connection.RegistryClient, content *encoding.Artifact, parent string, filename string) error {
 	// Restyle the YAML representation so that yaml.Marshal will marshal it as JSON.
-	styleForJSON(&content.Data)
+	encoding.StyleForJSON(&content.Data)
 	// Marshal the YAML representation into the JSON serialization.
 	j, err := yaml.Marshal(content.Data)
 	if err != nil {
@@ -201,7 +138,7 @@ func applyArtifactPatch(ctx context.Context, client connection.RegistryClient, c
 		}
 	} else {
 		// If there was no struct defined for the type, marshal it struct as YAML
-		styleForYAML(&content.Data)
+		encoding.StyleForYAML(&content.Data)
 		bytes, err = yaml.Marshal(content.Data)
 		if err != nil {
 			return err
diff --git a/cmd/registry/patch/deployment.go b/cmd/registry/patch/deployment.go
index 9518936..cd7be71 100644
--- a/cmd/registry/patch/deployment.go
+++ b/cmd/registry/patch/deployment.go
@@ -62,7 +62,7 @@ func NewApiDeployment(ctx context.Context, client *gapic.RegistryClient, message
 	}
 	return &encoding.ApiDeployment{
 		Header: encoding.Header{
-			ApiVersion: RegistryV1,
+			ApiVersion: encoding.RegistryV1,
 			Kind:       "Deployment",
 			Metadata: encoding.Metadata{
 				Name:        deploymentName.DeploymentID,
diff --git a/cmd/registry/patch/project.go b/cmd/registry/patch/project.go
index 9a390ee..e9b13a9 100644
--- a/cmd/registry/patch/project.go
+++ b/cmd/registry/patch/project.go
@@ -43,7 +43,7 @@ func NewProject(ctx context.Context, client *gapic.RegistryClient, message *rpc.
 	}
 	return &encoding.Project{
 		Header: encoding.Header{
-			ApiVersion: RegistryV1,
+			ApiVersion: encoding.RegistryV1,
 			Kind:       "Project",
 			Metadata: encoding.Metadata{
 				Name: projectName.ProjectID,
@@ -379,7 +379,7 @@ func (task *exportAPITask) Run(ctx context.Context) error {
 	if err != nil {
 		return err
 	}
-	bytes, err := Encode(api)
+	bytes, err := encoding.EncodeYAML(api)
 	if err != nil {
 		return err
 	}
@@ -412,7 +412,7 @@ func (task *exportArtifactTask) Run(ctx context.Context) error {
 		log.FromContext(ctx).Warnf("Skipped %s", task.message.Name)
 		return nil
 	}
-	bytes, err := Encode(artifact)
+	bytes, err := encoding.EncodeYAML(artifact)
 	if err != nil {
 		return err
 	}
@@ -445,7 +445,7 @@ func (task *exportVersionTask) Run(ctx context.Context) error {
 	if err != nil {
 		return err
 	}
-	bytes, err := Encode(version)
+	bytes, err := encoding.EncodeYAML(version)
 	if err != nil {
 		return err
 	}
@@ -473,7 +473,7 @@ func (task *exportSpecTask) Run(ctx context.Context) error {
 	if err != nil {
 		return err
 	}
-	bytes, err := Encode(spec)
+	bytes, err := encoding.EncodeYAML(spec)
 	if err != nil {
 		return err
 	}
@@ -518,7 +518,7 @@ func (task *exportDeploymentTask) Run(ctx context.Context) error {
 	if err != nil {
 		return err
 	}
-	bytes, err := Encode(deployment)
+	bytes, err := encoding.EncodeYAML(deployment)
 	if err != nil {
 		return err
 	}
diff --git a/cmd/registry/patch/spec.go b/cmd/registry/patch/spec.go
index 3a070cc..3a9d119 100644
--- a/cmd/registry/patch/spec.go
+++ b/cmd/registry/patch/spec.go
@@ -49,7 +49,7 @@ func NewApiSpec(ctx context.Context, client *gapic.RegistryClient, message *rpc.
 	}
 	return &encoding.ApiSpec{
 		Header: encoding.Header{
-			ApiVersion: RegistryV1,
+			ApiVersion: encoding.RegistryV1,
 			Kind:       "Spec",
 			Metadata: encoding.Metadata{
 				Name:        specName.SpecID,
diff --git a/cmd/registry/patch/version.go b/cmd/registry/patch/version.go
index e5ac81f..02beccc 100644
--- a/cmd/registry/patch/version.go
+++ b/cmd/registry/patch/version.go
@@ -59,7 +59,7 @@ func NewApiVersion(ctx context.Context, client *gapic.RegistryClient, message *r
 	}
 	return &encoding.ApiVersion{
 		Header: encoding.Header{
-			ApiVersion: RegistryV1,
+			ApiVersion: encoding.RegistryV1,
 			Kind:       "Version",
 			Metadata: encoding.Metadata{
 				Name:        versionName.VersionID,
diff --git a/cmd/registry/patch/yaml.go b/cmd/registry/patch/yaml.go
deleted file mode 100644
index b2a34d6..0000000
--- a/cmd/registry/patch/yaml.go
+++ /dev/null
@@ -1,39 +0,0 @@
-// 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 patch
-
-import (
-	"bytes"
-	"io"
-
-	"gopkg.in/yaml.v3"
-)
-
-// Prefer this encoder because it uses tighter 2-space indentation.
-func yamlEncoder(dst io.Writer) *yaml.Encoder {
-	enc := yaml.NewEncoder(dst)
-	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/pkg/encoding/header.go b/pkg/encoding/header.go
index 58e85f3..9cbaa58 100644
--- a/pkg/encoding/header.go
+++ b/pkg/encoding/header.go
@@ -26,3 +26,5 @@ type Metadata struct {
 	Labels      map[string]string `yaml:"labels,omitempty"`
 	Annotations map[string]string `yaml:"annotations,omitempty"`
 }
+
+const RegistryV1 = "apigeeregistry/v1"
diff --git a/pkg/encoding/yaml.go b/pkg/encoding/yaml.go
new file mode 100644
index 0000000..5850219
--- /dev/null
+++ b/pkg/encoding/yaml.go
@@ -0,0 +1,113 @@
+// 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 encoding
+
+import (
+	"bytes"
+	"io"
+
+	"google.golang.org/protobuf/encoding/protojson"
+	"google.golang.org/protobuf/proto"
+	"gopkg.in/yaml.v3"
+)
+
+// Prefer this encoder because it uses tighter 2-space indentation.
+func yamlEncoder(dst io.Writer) *yaml.Encoder {
+	enc := yaml.NewEncoder(dst)
+	enc.SetIndent(2)
+	return enc
+}
+
+// Encode a model as YAML.
+func EncodeYAML(model interface{}) ([]byte, error) {
+	var b bytes.Buffer
+	err := yamlEncoder(&b).Encode(model)
+	if err != nil {
+		return nil, err
+	}
+	return b.Bytes(), nil
+}
+
+// NodeForMessage converts a proto message for export as a YAML node.
+func NodeForMessage(m proto.Message) (*yaml.Node, error) {
+	// Marshal the artifact content as JSON using the protobuf marshaller.
+	var s []byte
+	s, err := protojson.MarshalOptions{
+		UseEnumNumbers:  false,
+		EmitUnpopulated: true,
+		Indent:          "  ",
+		UseProtoNames:   false,
+	}.Marshal(m)
+	if err != nil {
+		return nil, err
+	}
+	// Unmarshal the JSON with yaml.v3 so that we can re-marshal it as YAML.
+	var doc yaml.Node
+	err = yaml.Unmarshal([]byte(s), &doc)
+	if err != nil {
+		return nil, err
+	}
+	// The top-level node is a "document" node. We need to marshal the node below it.
+	node := doc.Content[0]
+	// Restyle the YAML representation so that it will be serialized with YAML defaults.
+	StyleForYAML(node)
+	// We exclude the id and kind fields from YAML serializations.
+	node = removeIdAndKind(node)
+
+	return node, nil
+}
+
+func removeIdAndKind(node *yaml.Node) *yaml.Node {
+	if node.Kind == yaml.MappingNode {
+		content := make([]*yaml.Node, 0)
+		for i := 0; i < len(node.Content); i += 2 {
+			k := node.Content[i]
+			if k.Value != "id" && k.Value != "kind" {
+				content = append(content, node.Content[i])
+				content = append(content, node.Content[i+1])
+			}
+		}
+		node.Content = content
+	}
+	return node
+}
+
+// StyleForYAML sets the style field on a tree of yaml.Nodes for YAML export.
+func StyleForYAML(node *yaml.Node) {
+	node.Style = 0
+	for _, n := range node.Content {
+		StyleForYAML(n)
+	}
+}
+
+// StyleForJSON sets the style field on a tree of yaml.Nodes for JSON export.
+func StyleForJSON(node *yaml.Node) {
+	switch node.Kind {
+	case yaml.DocumentNode, yaml.SequenceNode, yaml.MappingNode:
+		node.Style = yaml.FlowStyle
+	case yaml.ScalarNode:
+		switch node.Tag {
+		case "!!str":
+			node.Style = yaml.DoubleQuotedStyle
+		default:
+			node.Style = 0
+		}
+	case yaml.AliasNode:
+	default:
+	}
+	for _, n := range node.Content {
+		StyleForJSON(n)
+	}
+}


Test patch:
diff --git a/cmd/registry/patch/patch_project_test.go b/cmd/registry/patch/patch_project_test.go
index a52bc51..79399c8 100644
--- a/cmd/registry/patch/patch_project_test.go
+++ b/cmd/registry/patch/patch_project_test.go
@@ -20,6 +20,7 @@ import (
 	"testing"
 
 	"github.com/apigee/registry/pkg/connection"
+	"github.com/apigee/registry/pkg/encoding"
 	"github.com/apigee/registry/pkg/names"
 	"github.com/apigee/registry/rpc"
 	"github.com/google/go-cmp/cmp"
@@ -106,9 +107,9 @@ func TestProjectImports(t *testing.T) {
 			if err != nil {
 				t.Fatalf("NewApi(%+v) returned an error: %s", got, err)
 			}
-			actual, err := Encode(model)
+			actual, err := encoding.EncodeYAML(model)
 			if err != nil {
-				t.Fatalf("Encode(%+v) returned an error: %s", model, err)
+				t.Fatalf("encoding.EncodeYAML(%+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)
@@ -135,9 +136,9 @@ func TestProjectImports(t *testing.T) {
 				if err != nil {
 					t.Fatalf("NewArtifact(%+v) returned an error: %s", message, err)
 				}
-				actual, err := Encode(model)
+				actual, err := encoding.EncodeYAML(model)
 				if err != nil {
-					t.Fatalf("Encode(%+v) returned an error: %s", model, err)
+					t.Fatalf("encoding.EncodeYAML(%+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 c1931dd..5ee07e4 100644
--- a/cmd/registry/patch/patch_test.go
+++ b/cmd/registry/patch/patch_test.go
@@ -25,6 +25,7 @@ import (
 	"github.com/apigee/registry/pkg/application/style"
 	"github.com/apigee/registry/pkg/connection"
 	"github.com/apigee/registry/pkg/connection/grpctest"
+	"github.com/apigee/registry/pkg/encoding"
 	"github.com/apigee/registry/pkg/mime"
 	"github.com/apigee/registry/pkg/names"
 	"github.com/apigee/registry/pkg/visitor"
@@ -109,9 +110,9 @@ func TestProjectPatches(t *testing.T) {
 					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)
+					out, err := encoding.EncodeYAML(model)
 					if err != nil {
-						t.Errorf("Encode(%+v) returned an error: %s", model, err)
+						t.Errorf("encoding.EncodeYAML(%+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))
@@ -232,9 +233,9 @@ func TestApiPatches(t *testing.T) {
 					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)
+					out, err := encoding.EncodeYAML(model)
 					if err != nil {
-						t.Errorf("Encode(%+v) returned an error: %s", model, err)
+						t.Errorf("encoding.EncodeYAML(%+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))
@@ -335,9 +336,9 @@ func TestVersionPatches(t *testing.T) {
 					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)
+					out, err := encoding.EncodeYAML(model)
 					if err != nil {
-						t.Errorf("Encode(%+v) returned an error: %s", model, err)
+						t.Errorf("encoding.EncodeYAML(%+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))
@@ -438,9 +439,9 @@ func TestSpecPatches(t *testing.T) {
 					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)
+					out, err := encoding.EncodeYAML(model)
 					if err != nil {
-						t.Errorf("Encode(%+v) returned an error: %s", model, err)
+						t.Errorf("encoding.EncodeYAML(%+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))
@@ -544,9 +545,9 @@ func TestDeploymentPatches(t *testing.T) {
 					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)
+					out, err := encoding.EncodeYAML(model)
 					if err != nil {
-						t.Errorf("Encode(%+v) returned an error: %s", model, err)
+						t.Errorf("encoding.EncodeYAML(%+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))
@@ -1094,9 +1095,9 @@ func TestMessageArtifactPatches(t *testing.T) {
 					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)
+					out, err := encoding.EncodeYAML(model)
 					if err != nil {
-						t.Errorf("Encode(%+v) returned an error: %s", model, err)
+						t.Errorf("encoding.EncodeYAML(%+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))
@@ -1187,9 +1188,9 @@ func TestYamlArtifactPatches(t *testing.T) {
 					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)
+					out, err := encoding.EncodeYAML(model)
 					if err != nil {
-						t.Errorf("Encode(%+v) returned an error: %s", model, err)
+						t.Errorf("encoding.EncodeYAML(%+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/pkg/encoding/yaml_test.go b/pkg/encoding/yaml_test.go
new file mode 100644
index 0000000..d28c4a9
--- /dev/null
+++ b/pkg/encoding/yaml_test.go
@@ -0,0 +1,76 @@
+// 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 encoding
+
+import (
+	"testing"
+
+	"github.com/apigee/registry/pkg/application/apihub"
+	"gopkg.in/yaml.v3"
+)
+
+func TestYAML(t *testing.T) {
+	m := &apihub.DisplaySettings{
+		Description:     "Display settings",
+		Organization:    "ACME APIs",
+		ApiGuideEnabled: true,
+		ApiScoreEnabled: false,
+	}
+	node, err := NodeForMessage(m)
+	if err != nil {
+		t.Fatalf("Failed to encode message: %s", err)
+	}
+	a := &Artifact{
+		Header: Header{
+			ApiVersion: RegistryV1,
+			Kind:       "Settings",
+			Metadata: Metadata{
+				Name: "settings",
+			},
+		},
+		Data: *node,
+	}
+	bytes, err := EncodeYAML(a)
+	if err != nil {
+		t.Fatalf("Failed to encode artifact: %s", err)
+	}
+	expectedYAML := `apiVersion: apigeeregistry/v1
+kind: Settings
+metadata:
+  name: settings
+data:
+  description: Display settings
+  organization: ACME APIs
+  apiGuideEnabled: true
+  apiScoreEnabled: false
+`
+	if string(bytes) != expectedYAML {
+		t.Errorf("Unexpected encoding. Got %s, expected %s", string(bytes), expectedYAML)
+	}
+	err = yaml.Unmarshal(bytes, node)
+	if err != nil {
+		t.Fatalf("Failed to unmarshal YAML: %s", err)
+	}
+	StyleForJSON(node)
+	bytes, err = EncodeYAML(node)
+	if err != nil {
+		t.Fatalf("Failed to re-encode artifact: %s", err)
+	}
+	expectedJSON := `{"apiVersion": "apigeeregistry/v1", "kind": "Settings", "metadata": {"name": "settings"}, "data": {"description": "Display settings", "organization": "ACME APIs", "apiGuideEnabled": true, "apiScoreEnabled": false}}
+`
+	if string(bytes) != expectedJSON {
+		t.Errorf("Unexpected encoding. Got %s, expected %s", string(bytes), expectedJSON)
+	}
+}