Repository: apigee/registry (go)

Issue:
Registry tool: add registry rules for API Hub
Add API Hub checks to `registry check` command.

rule group | rule number | resource | field | type | severity | check
-- | -- | -- | -- | -- | -- | --
API Hub | 1000 | artifacts/apihub-lifecycle |   | Lifecycle | ERROR | required, must load into proto type
API Hub | 1000 | artifacts/apihub-taxonomies |   | TaxonomyList | ERROR | required, must load into proto type, has expected values (apihub-target-users, apihub-style, apihub-team, apihub-business-unit, apihub-gateway)
API Hub | 1001 | * | labels | map | ERROR | if label name is the same as a Taxonomy name, its value must match one of the Taxonomy's Element's ID
API Hub | 1002 | * | mime_type | string | WARN | if internal proto type, can load contents into declared proto



Pull request description:
add registry rules for API Hub
Fixes #981

Patch:
diff --git a/cmd/registry/cmd/check/lint/rule.go b/cmd/registry/cmd/check/lint/rule.go
index 09a750c..0e71e93 100644
--- a/cmd/registry/cmd/check/lint/rule.go
+++ b/cmd/registry/cmd/check/lint/rule.go
@@ -218,7 +218,7 @@ func (r *FieldRule) Apply(ctx context.Context, res Resource) (problems []Problem
 	for i := 0; i < in.NumField(); i++ {
 		name := t.Elem().Field(i).Name
 		if r.OnlyIf == nil || r.OnlyIf(res, name) {
-			value := v.Elem().Field(i)
+			value := v.Elem().Field(i).Interface()
 			probs := r.ApplyToField(ctx, res, name, value)
 			for i := range probs {
 				if probs[i].Location == "" {
diff --git a/cmd/registry/cmd/check/rules/rule1000/rule1000.go b/cmd/registry/cmd/check/rules/rule1000/rule1000.go
new file mode 100644
index 0000000..56cecea
--- /dev/null
+++ b/cmd/registry/cmd/check/rules/rule1000/rule1000.go
@@ -0,0 +1,121 @@
+// 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 rule1000
+
+import (
+	"context"
+	"fmt"
+	"strings"
+
+	"github.com/apigee/registry/cmd/registry/cmd/check/lint"
+	"github.com/apigee/registry/cmd/registry/types"
+	"github.com/apigee/registry/pkg/visitor"
+	"github.com/apigee/registry/rpc"
+	"github.com/apigee/registry/server/registry/names"
+	"google.golang.org/protobuf/proto"
+)
+
+var ruleNum = 1000
+var ruleName = lint.NewRuleName(ruleNum, "required-artifacts")
+var requiredIDs = []string{"apihub-lifecycle", "apihub-taxonomies"}
+var requiredTaxonomies = []string{"apihub-target-users", "apihub-style", "apihub-team", "apihub-business-unit", "apihub-gateway"}
+
+// AddRules accepts a register function and registers each of
+// this rules' checks to the RuleRegistry.
+func AddRules(r lint.RuleRegistry) error {
+	return r.Register(
+		ruleNum,
+		requiredArtifacts,
+	)
+}
+
+var requiredArtifacts = &lint.ProjectRule{
+	Name: ruleName,
+	OnlyIf: func(a *rpc.Project) bool {
+		return true
+	},
+	ApplyToProject: func(ctx context.Context, a *rpc.Project) []lint.Problem {
+		client := lint.RegistryClient(ctx)
+
+		var filter string
+		found := make(map[string]bool, len(requiredIDs))
+		for _, n := range requiredIDs {
+			if len(filter) > 0 {
+				filter += " || "
+			}
+			filter += fmt.Sprintf("artifact_id == '%s'", n)
+		}
+		projectName, _ := names.ParseProject(a.GetName())
+		var probs []lint.Problem
+		if err := visitor.ListArtifacts(ctx, client, projectName.Artifact("-"), filter, true, func(a *rpc.Artifact) error {
+			found[a.GetName()] = true
+
+			name, _ := names.ParseArtifact(a.GetName())
+			if "apihub-taxonomies" == name.ArtifactID() {
+				probs = append(probs, checkTaxonomies(a)...)
+			}
+
+			return nil
+		}); err != nil {
+			panic(err)
+		}
+
+		for _, id := range requiredIDs {
+			fn := projectName.Artifact(id).String()
+			if !found[fn] {
+				probs = append(probs, lint.Problem{
+					Severity:   lint.ERROR,
+					Message:    fmt.Sprintf(`artifact %q not found in registry.`, fn),
+					Suggestion: `Initialize API Hub.`,
+				})
+			}
+		}
+
+		return probs
+	},
+}
+
+func checkTaxonomies(a *rpc.Artifact) []lint.Problem {
+	message, err := types.MessageForMimeType(a.GetMimeType())
+	if err == nil {
+		err = proto.Unmarshal(a.GetContents(), message)
+	}
+	if err != nil {
+		return []lint.Problem{{
+			Severity:   lint.ERROR,
+			Message:    fmt.Sprintf(`Unable to verify %s`, a.GetName()),
+			Suggestion: fmt.Sprintf(`Error: %s`, err),
+		}}
+	}
+	tl := message.(*rpc.TaxonomyList)
+	tm := make(map[string]bool)
+	for _, t := range tl.Taxonomies {
+		tm[t.Id] = true
+	}
+	var missing []string
+	for _, t := range requiredTaxonomies {
+		if !tm[t] {
+			missing = append(missing, t)
+		}
+	}
+	if len(missing) > 0 {
+		return []lint.Problem{{
+			Severity:   lint.ERROR,
+			Message:    fmt.Sprintf(`TaxonomyList %q must include items: %s.`, a.GetName(), strings.Join(missing, `, `)),
+			Suggestion: `Initialize API Hub.`,
+		}}
+	}
+	return nil
+}
diff --git a/cmd/registry/cmd/check/rules/rule1001/rule1001.go b/cmd/registry/cmd/check/rules/rule1001/rule1001.go
new file mode 100644
index 0000000..b160626
--- /dev/null
+++ b/cmd/registry/cmd/check/rules/rule1001/rule1001.go
@@ -0,0 +1,107 @@
+// 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.
+
+// if label name is the same as a Taxonomy name, its value must match
+// one of the Taxonomy's Element's ID
+package rule1001
+
+import (
+	"context"
+	"fmt"
+	"strings"
+
+	"github.com/apigee/registry/cmd/registry/cmd/check/lint"
+	"github.com/apigee/registry/rpc"
+	"github.com/apigee/registry/server/registry/names"
+	"google.golang.org/protobuf/proto"
+)
+
+var ruleNum = 1001
+var ruleName = lint.NewRuleName(ruleNum, "taxonomy-labels")
+
+// AddRules accepts a register function and registers each of
+// this rules' checks to the RuleRegistry.
+func AddRules(r lint.RuleRegistry) error {
+	return r.Register(
+		ruleNum,
+		taxonomyLabels,
+	)
+}
+
+var taxonomyLabels = &lint.FieldRule{
+	Name: ruleName,
+	OnlyIf: func(resource lint.Resource, field string) bool {
+		return field == "Labels"
+	},
+	ApplyToField: func(ctx context.Context, resource lint.Resource, field string, value interface{}) []lint.Problem {
+		labels := map[string]string{}
+		for k, v := range value.(map[string]string) {
+			if strings.HasPrefix(k, "apihub-") {
+				labels[k] = v
+			}
+		}
+		if len(labels) == 0 {
+			return nil
+		}
+
+		name, _ := names.Parse(resource.GetName())
+		project := name.Project()
+		taxonomies := taxonomies(ctx, project)
+
+		var probs []lint.Problem
+		for k, v := range labels {
+			if !taxonomies.exists(k, v) {
+				probs = append(probs, lint.Problem{
+					Severity:   lint.ERROR,
+					Message:    fmt.Sprintf(`Label value %q not present in Taxonomy %q`, v, k),
+					Suggestion: `Adjust label value or Taxonomy elements.`,
+				})
+			}
+		}
+
+		return probs
+	},
+}
+
+// TODO: cache?
+func taxonomies(ctx context.Context, project names.Project) *TaxList {
+	client := lint.RegistryClient(ctx)
+	ac, err := client.GetArtifactContents(ctx, &rpc.GetArtifactContentsRequest{
+		Name: project.Artifact("apihub-taxonomies").String(),
+	})
+	if err == nil {
+		tl := new(rpc.TaxonomyList)
+		if err = proto.Unmarshal(ac.Data, tl); err == nil {
+			return &TaxList{tl}
+		}
+	}
+	panic(err)
+}
+
+type TaxList struct {
+	*rpc.TaxonomyList
+}
+
+func (tl *TaxList) exists(k, v string) bool {
+	for _, t := range tl.GetTaxonomies() {
+		if k == t.GetId() {
+			for _, e := range t.GetElements() {
+				if v == e.Id {
+					return true
+				}
+			}
+		}
+	}
+	return false
+}
diff --git a/cmd/registry/cmd/check/rules/rule1002/rule1002.go b/cmd/registry/cmd/check/rules/rule1002/rule1002.go
new file mode 100644
index 0000000..8add6ad
--- /dev/null
+++ b/cmd/registry/cmd/check/rules/rule1002/rule1002.go
@@ -0,0 +1,100 @@
+// Copyright 2023 Google LLC
+//
+// 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
+//
+//     https://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 rule1002
+
+import (
+	"context"
+	"fmt"
+	"strings"
+
+	"github.com/apigee/registry/cmd/registry/cmd/check/lint"
+	"github.com/apigee/registry/cmd/registry/types"
+	"github.com/apigee/registry/rpc"
+	"google.golang.org/protobuf/proto"
+)
+
+var ruleNum = 1002
+var ruleName = lint.NewRuleName(ruleNum, "internal-mime-type-contents")
+
+// AddRules accepts a register function and registers each of
+// this rules' checks to the RuleRegistry.
+func AddRules(r lint.RuleRegistry) error {
+	return r.Register(
+		ruleNum,
+		internalMimeTypeContents,
+	)
+}
+
+var internalMimeTypeContents = &lint.FieldRule{
+	Name: ruleName,
+	OnlyIf: func(resource lint.Resource, field string) bool {
+		return field == "MimeType"
+	},
+	ApplyToField: func(ctx context.Context, resource lint.Resource, field string, value interface{}) []lint.Problem {
+		var declared string
+		var contents []byte
+		switch t := resource.(type) {
+		case *rpc.ApiSpec:
+			declared = t.GetMimeType()
+			contents = t.GetContents()
+		case *rpc.Artifact:
+			declared = t.GetMimeType()
+			contents = t.GetContents()
+		}
+		if len(contents) == 0 {
+			return nil
+		}
+		_, declaredParameters := parseMime(declared)
+
+		// internal types, see github.com/apigee/registry/cmd/registry/types
+		typeParam := declaredParameters["type"]
+		if strings.HasPrefix(typeParam, "google.cloud.apigeeregistry.") || strings.HasPrefix(typeParam, "gnostic.metrics.") {
+			message, err := types.MessageForMimeType(declared)
+			if err != nil {
+				return []lint.Problem{{
+					Severity:   lint.ERROR,
+					Message:    fmt.Sprintf("Unknown internal mime_type: %q.", declared),
+					Suggestion: "Fix mime_type.",
+				}}
+			}
+
+			// does not validate contents, just proves type compatibility
+			err = proto.Unmarshal(contents, message)
+			if err != nil {
+				return []lint.Problem{{
+					Severity:   lint.ERROR,
+					Message:    fmt.Sprintf("Error loading contents into proto type %q: %v.", declared, err),
+					Suggestion: "Fix mime_type or contents.",
+				}}
+			}
+		}
+
+		return nil
+	},
+}
+
+func parseMime(mime string) (baseType string, parameters map[string]string) {
+	mime = strings.ToLower(mime)
+	splits := strings.Split(mime, ";")
+	baseType = strings.TrimSpace(splits[0])
+	parameters = make(map[string]string)
+	if len(splits) > 1 {
+		for _, s := range splits[1:] {
+			p := strings.Split(s, "=")
+			parameters[p[0]] = p[1]
+		}
+	}
+	return
+}
diff --git a/cmd/registry/cmd/check/rules/rules.go b/cmd/registry/cmd/check/rules/rules.go
index 5cd43a7..098fa13 100644
--- a/cmd/registry/cmd/check/rules/rules.go
+++ b/cmd/registry/cmd/check/rules/rules.go
@@ -34,6 +34,9 @@ package rules
 import (
 	"github.com/apigee/registry/cmd/registry/cmd/check/lint"
 	"github.com/apigee/registry/cmd/registry/cmd/check/rules/rule100"
+	"github.com/apigee/registry/cmd/registry/cmd/check/rules/rule1000"
+	"github.com/apigee/registry/cmd/registry/cmd/check/rules/rule1001"
+	"github.com/apigee/registry/cmd/registry/cmd/check/rules/rule1002"
 	"github.com/apigee/registry/cmd/registry/cmd/check/rules/rule101"
 	"github.com/apigee/registry/cmd/registry/cmd/check/rules/rule102"
 	"github.com/apigee/registry/cmd/registry/cmd/check/rules/rule103"
@@ -66,6 +69,9 @@ var addRulesFuncs = []addRulesFuncType{
 	rule111.AddRules,
 	rule112.AddRules,
 	rule113.AddRules,
+	rule1000.AddRules,
+	rule1001.AddRules,
+	rule1002.AddRules,
 }
 
 // Add all rules to the given registry.
diff --git a/server/registry/names/artifact.go b/server/registry/names/artifact.go
index 480b753..46a02a9 100644
--- a/server/registry/names/artifact.go
+++ b/server/registry/names/artifact.go
@@ -41,6 +41,13 @@ type Artifact struct {
 	}
 }
 
+// Project returns the name of this resource's parent project.
+func (a Artifact) Project() Project {
+	return Project{
+		ProjectID: a.ProjectID(),
+	}
+}
+
 // ProjectID returns the artifact's project ID, or empty string if it doesn't have one.
 func (a Artifact) ProjectID() string {
 	switch name := a.name.(type) {
diff --git a/server/registry/names/common.go b/server/registry/names/common.go
index 49107ef..072bab2 100644
--- a/server/registry/names/common.go
+++ b/server/registry/names/common.go
@@ -78,7 +78,8 @@ const Location = "global"
 
 // Name is an interface that represents resource names.
 type Name interface {
-	String() string // all names have a string representation.
+	String() string   // all names have a string representation.
+	Project() Project // all names are associated with a project
 }
 
 // ExportableName returns a name suitable for export.
diff --git a/server/registry/names/project.go b/server/registry/names/project.go
index 4adac05..350ec62 100644
--- a/server/registry/names/project.go
+++ b/server/registry/names/project.go
@@ -34,6 +34,11 @@ func (p Project) Validate() error {
 	return validateID(p.ProjectID)
 }
 
+// Project returns this resource
+func (p Project) Project() Project {
+	return p
+}
+
 // Api returns an API with the provided ID and this resource as its parent.
 func (p Project) Api(id string) Api {
 	return Api{


Test patch:
diff --git a/cmd/registry/cmd/check/rules/rule1000/rule1000_test.go b/cmd/registry/cmd/check/rules/rule1000/rule1000_test.go
new file mode 100644
index 0000000..5b83022
--- /dev/null
+++ b/cmd/registry/cmd/check/rules/rule1000/rule1000_test.go
@@ -0,0 +1,151 @@
+// 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 rule1000
+
+import (
+	"context"
+	"testing"
+
+	"github.com/apigee/registry/cmd/registry/cmd/check/lint"
+	"github.com/apigee/registry/cmd/registry/types"
+	"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"
+	"github.com/google/go-cmp/cmp"
+	"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 TestAddRules(t *testing.T) {
+	if err := AddRules(lint.NewRuleRegistry()); err != nil {
+		t.Errorf("AddRules got an error: %v", err)
+	}
+}
+
+func TestRequiredArtifacts(t *testing.T) {
+	ctx := context.Background()
+	registryClient, _ := grpctest.SetupRegistry(ctx, t, "check-test", []seeder.RegistryResource{
+		&rpc.Artifact{
+			Name: "projects/check-test/locations/global/artifacts/good1",
+		},
+		&rpc.Artifact{
+			Name: "projects/check-test/locations/global/artifacts/good2",
+		},
+	})
+	ctx = context.WithValue(ctx, lint.ContextKeyRegistryClient, registryClient)
+
+	bad1 := lint.Problem{
+		Message:    `artifact "projects/check-test/locations/global/artifacts/bad1" not found in registry.`,
+		Suggestion: `Initialize API Hub.`,
+		Severity:   lint.ERROR,
+	}
+	bad2 := lint.Problem{
+		Message:    `artifact "projects/check-test/locations/global/artifacts/bad2" not found in registry.`,
+		Suggestion: `Initialize API Hub.`,
+		Severity:   lint.ERROR,
+	}
+
+	for _, tt := range []struct {
+		name     string
+		required []string
+		expected []lint.Problem
+	}{
+		{"good one", []string{"good1"}, nil},
+		{"good two", []string{"good1", "good2"}, nil},
+		{"bad one", []string{"bad1", "good2"}, []lint.Problem{bad1}},
+		{"bad two", []string{"bad1", "bad2"}, []lint.Problem{bad1, bad2}},
+	} {
+		t.Run(tt.name, func(t *testing.T) {
+			oldIds := requiredIDs
+			t.Cleanup(func() { requiredIDs = oldIds })
+			requiredIDs = tt.required
+			p := &rpc.Project{
+				Name: "projects/check-test",
+			}
+			if requiredArtifacts.OnlyIf(p) {
+				got := requiredArtifacts.ApplyToProject(ctx, p)
+				if diff := cmp.Diff(got, tt.expected); diff != "" {
+					t.Errorf("unexpected diff: (-want +got):\n%s", diff)
+				}
+			}
+		})
+	}
+}
+
+func TestRequiredTaxonomies(t *testing.T) {
+	ctx := context.Background()
+	registryClient, _ := grpctest.SetupRegistry(ctx, t, "check-test", nil)
+	ctx = context.WithValue(ctx, lint.ContextKeyRegistryClient, registryClient)
+
+	tl, _ := proto.Marshal(&rpc.TaxonomyList{
+		Taxonomies: []*rpc.TaxonomyList_Taxonomy{
+			{Id: "good1"},
+			{Id: "good2"},
+		},
+	})
+	_, err := registryClient.CreateArtifact(ctx, &rpc.CreateArtifactRequest{
+		ArtifactId: "apihub-taxonomies",
+		Parent:     "projects/check-test/locations/global",
+		Artifact: &rpc.Artifact{
+			MimeType: types.MimeTypeForMessageType("google.cloud.apigeeregistry.v1.apihub.TaxonomyList"),
+			Contents: tl,
+		},
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	for _, tt := range []struct {
+		name     string
+		required []string
+		expected []lint.Problem
+	}{
+		{"good", []string{"good1"}, nil},
+		{"good2", []string{"good1", "good2"}, nil},
+		{"bad", []string{"bad1"}, []lint.Problem{{
+			Message:    `TaxonomyList "projects/check-test/locations/global/artifacts/apihub-taxonomies" must include items: bad1.`,
+			Suggestion: `Initialize API Hub.`,
+			Severity:   lint.ERROR,
+		}}},
+		{"bad2", []string{"bad1", "bad2"}, []lint.Problem{{
+			Message:    `TaxonomyList "projects/check-test/locations/global/artifacts/apihub-taxonomies" must include items: bad1, bad2.`,
+			Suggestion: `Initialize API Hub.`,
+			Severity:   lint.ERROR,
+		}}},
+	} {
+		t.Run(tt.name, func(t *testing.T) {
+			oldIds := requiredIDs
+			t.Cleanup(func() { requiredIDs = oldIds })
+			requiredIDs = []string{"apihub-taxonomies"}
+			requiredTaxonomies = tt.required
+			p := &rpc.Project{
+				Name: "projects/check-test",
+			}
+			if requiredArtifacts.OnlyIf(p) {
+				got := requiredArtifacts.ApplyToProject(ctx, p)
+				if diff := cmp.Diff(got, tt.expected); diff != "" {
+					t.Errorf("unexpected diff: (-want +got):\n%s", diff)
+				}
+			}
+		})
+	}
+}
diff --git a/cmd/registry/cmd/check/rules/rule1001/rule1001_test.go b/cmd/registry/cmd/check/rules/rule1001/rule1001_test.go
new file mode 100644
index 0000000..344a146
--- /dev/null
+++ b/cmd/registry/cmd/check/rules/rule1001/rule1001_test.go
@@ -0,0 +1,108 @@
+// 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 rule1001
+
+import (
+	"context"
+	"testing"
+
+	"github.com/apigee/registry/cmd/registry/cmd/check/lint"
+	"github.com/apigee/registry/cmd/registry/types"
+	"github.com/apigee/registry/pkg/connection/grpctest"
+	"github.com/apigee/registry/rpc"
+	"github.com/apigee/registry/server/registry"
+	"github.com/google/go-cmp/cmp"
+	"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 TestAddRules(t *testing.T) {
+	if err := AddRules(lint.NewRuleRegistry()); err != nil {
+		t.Errorf("AddRules got an error: %v", err)
+	}
+}
+
+func TestTaxonomyList(t *testing.T) {
+	ctx := context.Background()
+	registryClient, _ := grpctest.SetupRegistry(ctx, t, "check-test", nil)
+	ctx = context.WithValue(ctx, lint.ContextKeyRegistryClient, registryClient)
+
+	tl, _ := proto.Marshal(&rpc.TaxonomyList{
+		Taxonomies: []*rpc.TaxonomyList_Taxonomy{
+			{
+				Id: "apihub-list1",
+				Elements: []*rpc.TaxonomyList_Taxonomy_Element{
+					{Id: "good1"},
+					{Id: "good2"},
+				},
+			},
+			{
+				Id: "apihub-list2",
+				Elements: []*rpc.TaxonomyList_Taxonomy_Element{
+					{Id: "good3"},
+					{Id: "good4"},
+				},
+			},
+		},
+	})
+	_, err := registryClient.CreateArtifact(ctx, &rpc.CreateArtifactRequest{
+		ArtifactId: "apihub-taxonomies",
+		Parent:     "projects/check-test/locations/global",
+		Artifact: &rpc.Artifact{
+			MimeType: types.MimeTypeForMessageType("google.cloud.apigeeregistry.v1.apihub.TaxonomyList"),
+			Contents: tl,
+		},
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	for _, tt := range []struct {
+		name     string
+		labels   map[string]string
+		expected []lint.Problem
+	}{
+		{"good", map[string]string{
+			"apihub-list1": "good1",
+			"apihub-list2": "good3",
+		}, nil},
+		{"bad1", map[string]string{
+			"apihub-list1": "good3",
+			"apihub-list2": "good3",
+		}, []lint.Problem{{
+			Message:    `Label value "good3" not present in Taxonomy "apihub-list1"`,
+			Suggestion: `Adjust label value or Taxonomy elements.`,
+			Severity:   lint.ERROR,
+		}}},
+	} {
+		t.Run(tt.name, func(t *testing.T) {
+			p := &rpc.Project{
+				Name: "projects/check-test",
+			}
+			if taxonomyLabels.OnlyIf(p, "Labels") {
+				got := taxonomyLabels.ApplyToField(ctx, p, "Labels", tt.labels)
+				if diff := cmp.Diff(got, tt.expected); diff != "" {
+					t.Errorf("unexpected diff: (-want +got):\n%s", diff)
+				}
+			}
+		})
+	}
+}
diff --git a/cmd/registry/cmd/check/rules/rule1002/rule1002_test.go b/cmd/registry/cmd/check/rules/rule1002/rule1002_test.go
new file mode 100644
index 0000000..1e58a9e
--- /dev/null
+++ b/cmd/registry/cmd/check/rules/rule1002/rule1002_test.go
@@ -0,0 +1,139 @@
+// Copyright 2023 Google LLC
+//
+// 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
+//
+//     https://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 rule1002
+
+import (
+	"context"
+	"testing"
+
+	"github.com/apigee/registry/cmd/registry/cmd/check/lint"
+	"github.com/apigee/registry/rpc"
+	"github.com/google/go-cmp/cmp"
+	"github.com/google/go-cmp/cmp/cmpopts"
+	"google.golang.org/protobuf/proto"
+	"google.golang.org/protobuf/reflect/protoreflect"
+)
+
+func TestAddRules(t *testing.T) {
+	if err := AddRules(lint.NewRuleRegistry()); err != nil {
+		t.Errorf("AddRules got an error: %v", err)
+	}
+}
+
+func TestInternalMimeTypeContents(t *testing.T) {
+	tests := []struct {
+		name     string
+		mimeType string
+		bytes    []byte
+		problems []lint.Problem
+	}{
+		{
+			"empty contents",
+			"application/octet-stream;type=google.cloud.apigeeregistry.v1.apihub.Lifecycle",
+			[]byte{},
+			nil,
+		},
+		{
+			"good contents",
+			"application/octet-stream;type=google.cloud.apigeeregistry.v1.apihub.Lifecycle",
+			marshalMessage(t,
+				&rpc.Lifecycle{
+					Description: "Lifecycle",
+				},
+			),
+			nil,
+		},
+		{
+			"unknown type",
+			"application/octet-stream;type=google.cloud.apigeeregistry.v1.apihub.UnknownType",
+			marshalMessage(t,
+				&rpc.Lifecycle{
+					Description: "Lifecycle",
+				},
+			),
+			[]lint.Problem{{
+				Message:    `Error loading contents into proto type Lifecycle.`,
+				Suggestion: `Fix mime_type.`,
+				Severity:   lint.ERROR,
+			}},
+		},
+		{
+			"not a message",
+			"application/octet-stream;type=google.cloud.apigeeregistry.v1.apihub.Lifecycle",
+			[]byte("not a score"),
+			[]lint.Problem{{
+				Message:    `Unknown internal mime_type: "application/octet-stream;type=google.cloud.apigeeregistry.v1.apihub.Lifecycle".`,
+				Suggestion: `Fix mime_type or contents.`,
+				Severity:   lint.ERROR,
+			}},
+		},
+		{
+			"wrong mime type",
+			"application/octet-stream;type=google.cloud.apigeeregistry.v1.apihub.ReferenceList",
+			marshalMessage(t,
+				&rpc.Lifecycle{
+					Description: "Lifecycle",
+				},
+			),
+			// TODO: Would like a Problem, but Unmarshal is too lenient and this verification is impossible?
+			nil,
+			// []lint.Problem{{
+			// 	Message:    `Error loading contents into proto type Lifecycle.`,
+			// 	Suggestion: `Fix mime_type or contents.`,
+			// 	Severity:   lint.ERROR,
+			// 	Location:   "Artifact::MimeType",
+			// }},
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			artifact := &rpc.Artifact{
+				Name:     "Artifact",
+				MimeType: test.mimeType,
+				Contents: test.bytes,
+			}
+			spec := &rpc.ApiSpec{
+				Name:     "Spec",
+				MimeType: test.mimeType,
+				Contents: test.bytes,
+			}
+
+			opts := []cmp.Option{
+				cmpopts.IgnoreFields(lint.Problem{}, "Message", "Location"),
+				cmpopts.IgnoreUnexported(lint.Problem{}),
+			}
+			if internalMimeTypeContents.OnlyIf(artifact, "MimeType") {
+				result := internalMimeTypeContents.Apply(context.Background(), spec)
+				if diff := cmp.Diff(test.problems, result, opts...); diff != "" {
+					t.Errorf("Unexpected diff (-want +got):\n%s", diff)
+				}
+
+				result = internalMimeTypeContents.Apply(context.Background(), artifact)
+				if diff := cmp.Diff(test.problems, result, opts...); diff != "" {
+					t.Errorf("Unexpected diff (-want +got):\n%s", diff)
+				}
+			}
+		})
+	}
+}
+
+func marshalMessage(t *testing.T, message protoreflect.ProtoMessage) []byte {
+	var bytes, err = proto.Marshal(message)
+	if err != nil {
+		t.Fatal(err)
+	}
+	return bytes
+}
diff --git a/pkg/connection/grpctest/server.go b/pkg/connection/grpctest/server.go
index cef588f..ab3adf3 100644
--- a/pkg/connection/grpctest/server.go
+++ b/pkg/connection/grpctest/server.go
@@ -115,6 +115,18 @@ func SetupRegistry(ctx context.Context, t *testing.T, projectID string, seeds []
 	}
 	t.Cleanup(func() { _ = adminClient.DeleteProject(ctx, delProjReq) })
 
+	if len(seeds) == 0 {
+		_, err = adminClient.CreateProject(ctx, &rpc.CreateProjectRequest{
+			ProjectId: projectID,
+			Project: &rpc.Project{
+				DisplayName: "Test",
+			},
+		})
+		if err != nil {
+			t.Fatalf("Failed to create project: %+v", err)
+		}
+	}
+
 	seedClient := seeder.Client{
 		RegistryClient: registryClient,
 		AdminClient:    adminClient,