Repository: apigee/registry (go)

Issue:
Registry tool: add registry rules for fields
Add field checks to `registry check` command.

rule group | rule number | resource | field | type | severity | check
-- | -- | -- | -- | -- | -- | --
Registry | 109 | * | display_name | string | ERROR | max length of 64, all characters must use UTF-8 encoding
Registry | 110 | * | description | string | ERROR | max length of 5k, all characters must use UTF-8 encoding
Registry | 111 | * | mime_type | string | WARN | if contents not empty, matches detected type of contents
Registry | 112 | * | labels | map | ERROR | Label keys and values can be no longer than 64 characters (Unicode codepoints), can only contain lowercase letters, numeric characters, underscores, and dashes. International characters are allowed. No more than 64 user labels can be associated with one resource (System labels are excluded).
Registry | 113 | * | annotations | map | ERROR | Annotation keys can be no longer than 64 characters (Unicode codepoints), can only contain lowercase letters, numeric characters, underscores, and dashes. International characters are allowed. Max total annotation map size limit 256k.


Pull request description:
add registry rules for fields
Fixes #957

Patch:
diff --git a/cmd/registry/cmd/check/rules/rule109/rule109.go b/cmd/registry/cmd/check/rules/rule109/rule109.go
new file mode 100644
index 0000000..470f34a
--- /dev/null
+++ b/cmd/registry/cmd/check/rules/rule109/rule109.go
@@ -0,0 +1,46 @@
+// 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 rule109
+
+import (
+	"context"
+
+	"github.com/apigee/registry/cmd/registry/cmd/check/lint"
+	"github.com/apigee/registry/cmd/registry/cmd/check/rules/util"
+)
+
+var ruleNum = 109
+var fieldName = "display_name"
+var ruleName = lint.NewRuleName(ruleNum, "display-name-format")
+
+// 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,
+		displayName,
+	)
+}
+
+// max length of 64, all characters must use UTF-8 encoding
+var displayName = &lint.FieldRule{
+	Name: ruleName,
+	OnlyIf: func(resource lint.Resource, field string) bool {
+		return field == fieldName
+	},
+	ApplyToField: func(ctx context.Context, resource lint.Resource, field string, value interface{}) []lint.Problem {
+		return util.CheckUTF(fieldName, value, 65)
+	},
+}
diff --git a/cmd/registry/cmd/check/rules/rule110/rule110.go b/cmd/registry/cmd/check/rules/rule110/rule110.go
index 5384795..5e1e0e1 100644
--- a/cmd/registry/cmd/check/rules/rule110/rule110.go
+++ b/cmd/registry/cmd/check/rules/rule110/rule110.go
@@ -12,89 +12,35 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
+// max length of 5k, all characters must use UTF-8 encoding
 package rule110
 
 import (
 	"context"
-	"fmt"
-	"mime"
-	"net/http"
-	"strings"
 
 	"github.com/apigee/registry/cmd/registry/cmd/check/lint"
-	"github.com/apigee/registry/rpc"
+	"github.com/apigee/registry/cmd/registry/cmd/check/rules/util"
 )
 
 var ruleNum = 110
-var ruleName = lint.NewRuleName(ruleNum, "mime-type-detected-contents")
+var fieldName = "description"
+var ruleName = lint.NewRuleName(ruleNum, "description-format")
 
 // 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,
-		mimeTypeContents,
+		description,
 	)
 }
 
-var allowPrefixes = []string{
-	"application/octet-stream",
-	"application/x.",
-	"application/x-",
-	"application/vnd",
-}
-
-var mimeTypeContents = &lint.FieldRule{
+var description = &lint.FieldRule{
 	Name: ruleName,
 	OnlyIf: func(resource lint.Resource, field string) bool {
-		return field == "MimeType"
+		return field == fieldName
 	},
 	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
-		}
-		for _, p := range allowPrefixes {
-			if strings.HasPrefix(declared, p) {
-				return nil
-			}
-		}
-
-		detected := http.DetectContentType(contents)
-
-		if strings.TrimSpace(declared) == "" {
-			return []lint.Problem{{
-				Severity:   lint.ERROR,
-				Message:    "Empty mime_type.",
-				Suggestion: fmt.Sprintf("Detected mime_type: %q.", detected),
-			}}
-		}
-
-		declaredType, _, err := mime.ParseMediaType(declared)
-		if err != nil {
-			return []lint.Problem{{
-				Severity: lint.ERROR,
-				Message:  fmt.Sprintf("Unable to parse mime_type %q: %s.", declared, err),
-			}}
-		}
-
-		detectedType, _, _ := mime.ParseMediaType(detected)
-		if declaredType != detectedType {
-			return []lint.Problem{{
-				Severity:   lint.WARNING,
-				Message:    fmt.Sprintf("Unexpected mime_type %q for contents.", declared),
-				Suggestion: fmt.Sprintf("Detected mime_type: %q.", detected),
-			}}
-		}
-		return nil
+		return util.CheckUTF(fieldName, value, 5000)
 	},
 }
diff --git a/cmd/registry/cmd/check/rules/rule111/rule111.go b/cmd/registry/cmd/check/rules/rule111/rule111.go
new file mode 100644
index 0000000..7f3276c
--- /dev/null
+++ b/cmd/registry/cmd/check/rules/rule111/rule111.go
@@ -0,0 +1,100 @@
+// 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 rule111
+
+import (
+	"context"
+	"fmt"
+	"mime"
+	"net/http"
+	"strings"
+
+	"github.com/apigee/registry/cmd/registry/cmd/check/lint"
+	"github.com/apigee/registry/rpc"
+)
+
+var ruleNum = 111
+var ruleName = lint.NewRuleName(ruleNum, "mime-type-detected-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,
+		mimeTypeContents,
+	)
+}
+
+var allowPrefixes = []string{
+	"application/octet-stream",
+	"application/x.",
+	"application/x-",
+	"application/vnd",
+}
+
+var mimeTypeContents = &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
+		}
+		for _, p := range allowPrefixes {
+			if strings.HasPrefix(declared, p) {
+				return nil
+			}
+		}
+
+		detected := http.DetectContentType(contents)
+
+		if strings.TrimSpace(declared) == "" {
+			return []lint.Problem{{
+				Severity:   lint.ERROR,
+				Message:    "Empty mime_type.",
+				Suggestion: fmt.Sprintf("Detected mime_type: %q.", detected),
+			}}
+		}
+
+		declaredType, _, err := mime.ParseMediaType(declared)
+		if err != nil {
+			return []lint.Problem{{
+				Severity: lint.ERROR,
+				Message:  fmt.Sprintf("Unable to parse mime_type %q: %s.", declared, err),
+			}}
+		}
+
+		detectedType, _, _ := mime.ParseMediaType(detected)
+		if declaredType != detectedType {
+			return []lint.Problem{{
+				Severity:   lint.WARNING,
+				Message:    fmt.Sprintf("Unexpected mime_type %q for contents.", declared),
+				Suggestion: fmt.Sprintf("Detected mime_type: %q.", detected),
+			}}
+		}
+		return nil
+	},
+}
diff --git a/cmd/registry/cmd/check/rules/rule112/rule112.go b/cmd/registry/cmd/check/rules/rule112/rule112.go
new file mode 100644
index 0000000..557fb3a
--- /dev/null
+++ b/cmd/registry/cmd/check/rules/rule112/rule112.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.
+
+// Each resource can have multiple labels, up to a maximum of 64.
+// Each label must be a key-value pair.
+// Keys have a minimum length of 1 character and a maximum length of 63 characters, and cannot be empty.
+// Values can be empty, and have a maximum length of 63 characters.
+// Keys and values can contain only lowercase letters, numeric characters, underscores, and dashes.
+// All characters must use UTF-8 encoding, and international characters are allowed.
+// The key portion of a label must be unique within a single resource. However,
+// you can use the same key with multiple resources.
+// Keys must start with a lowercase letter or international character.
+package rule112
+
+import (
+	"context"
+	"fmt"
+	"unicode"
+	"unicode/utf8"
+
+	"github.com/apigee/registry/cmd/registry/cmd/check/lint"
+)
+
+const ruleNum = 112
+const fieldName = "labels"
+
+var ruleName = lint.NewRuleName(ruleNum, "labels-format")
+
+// 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,
+		labels,
+	)
+}
+
+var labels = &lint.FieldRule{
+	Name: ruleName,
+	OnlyIf: func(resource lint.Resource, field string) bool {
+		return field == fieldName
+	},
+	ApplyToField: func(ctx context.Context, resource lint.Resource, field string, value interface{}) []lint.Problem {
+		labels := value.(map[string]string)
+		if len(labels) == 0 {
+			return nil
+		}
+		var probs []lint.Problem
+		for k, v := range labels {
+			probs = append(probs, checkLabel(k, v)...)
+		}
+		if len(labels) > 64 {
+			probs = append(probs, lint.Problem{
+				Severity:   lint.ERROR,
+				Message:    `Maximum number of labels is 64.`,
+				Suggestion: `Delete some entries.`,
+			})
+		}
+
+		return probs
+	},
+}
+
+func checkLabel(k string, v string) []lint.Problem {
+	var probs []lint.Problem
+	if r, _ := utf8.DecodeRuneInString(k); r == utf8.RuneError || !unicode.In(r, unicode.Ll, unicode.Lo) {
+		probs = append(probs, lint.Problem{
+			Severity:   lint.ERROR,
+			Message:    fmt.Sprintf(`Key %q has illegal first character %q.`, k, r),
+			Suggestion: `Fix key.`,
+		})
+	} else if ok, r := validLabelRunes(k); !ok {
+		probs = append(probs, lint.Problem{
+			Severity:   lint.ERROR,
+			Message:    fmt.Sprintf(`Key %q contains illegal character %q.`, k, r),
+			Suggestion: `Fix key.`,
+		})
+	}
+	if count := utf8.RuneCountInString(k); count > 64 {
+		probs = append(probs, lint.Problem{
+			Severity:   lint.ERROR,
+			Message:    fmt.Sprintf(`Key %q exceeds max length of 64 characters.`, k),
+			Suggestion: `Fix key.`,
+		})
+	}
+	if ok, r := validLabelRunes(v); !ok {
+		probs = append(probs, lint.Problem{
+			Severity:   lint.ERROR,
+			Message:    fmt.Sprintf(`Value for key %q contains illegal character %q.`, k, r),
+			Suggestion: `Fix value.`,
+		})
+	}
+	if count := utf8.RuneCountInString(v); count > 64 {
+		probs = append(probs, lint.Problem{
+			Severity:   lint.ERROR,
+			Message:    fmt.Sprintf(`Value for key %q exceeds max length of 64 characters.`, k),
+			Suggestion: `Fix value.`,
+		})
+	}
+	return probs
+}
+
+func validLabelRunes(s string) (bool, rune) {
+	for _, r := range s {
+		if !unicode.In(r, unicode.Ll, unicode.Lo, unicode.N) && r != '_' && r != '-' {
+			return false, r
+		}
+	}
+	return true, 0
+}
diff --git a/cmd/registry/cmd/check/rules/rule113/rule113.go b/cmd/registry/cmd/check/rules/rule113/rule113.go
new file mode 100644
index 0000000..45b6d3c
--- /dev/null
+++ b/cmd/registry/cmd/check/rules/rule113/rule113.go
@@ -0,0 +1,112 @@
+// 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.
+
+// Each resource can have multiple annotations, up to a maximum total size of 256k.
+// Each annotation must be a key-value pair.
+// Keys have a minimum length of 1 character and a maximum length of 63 characters, and cannot be empty.
+// Values can be empty, and have no maximum length (other than total size limit).
+// Keys can contain only lowercase letters, numeric characters, underscores, and dashes.
+// All characters must use UTF-8 encoding, and international characters are allowed.
+// Values can contain any characters.
+// The key portion of a label must be unique within a single resource. However,
+// you can use the same key with multiple resources.
+// Keys must start with a lowercase letter or international character.
+package rule113
+
+import (
+	"context"
+	"fmt"
+	"unicode"
+	"unicode/utf8"
+
+	"github.com/apigee/registry/cmd/registry/cmd/check/lint"
+)
+
+const ruleNum = 113
+const fieldName = "annotations"
+
+const totalSizeLimit int = 256 * (1 << 10) // 256 kB
+
+var ruleName = lint.NewRuleName(ruleNum, "annotations-format")
+
+// 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,
+		annotations,
+	)
+}
+
+var annotations = &lint.FieldRule{
+	Name: ruleName,
+	OnlyIf: func(resource lint.Resource, field string) bool {
+		return field == fieldName
+	},
+	ApplyToField: func(ctx context.Context, resource lint.Resource, field string, value interface{}) []lint.Problem {
+		labels := value.(map[string]string)
+		if len(labels) == 0 {
+			return nil
+		}
+		totalSize := 0
+		var probs []lint.Problem
+		for k, v := range labels {
+			probs = append(probs, checkAnnotation(k, v)...)
+			totalSize += len(k) + len(v)
+		}
+		if totalSize > totalSizeLimit {
+			probs = append(probs, lint.Problem{
+				Severity:   lint.ERROR,
+				Message:    `Maximum size of all annotations is 256k.`,
+				Suggestion: fmt.Sprintf(`Reduce size by %d bytes.`, totalSize-totalSizeLimit),
+			})
+		}
+
+		return probs
+	},
+}
+
+func checkAnnotation(k string, v string) []lint.Problem {
+	var probs []lint.Problem
+	if r, _ := utf8.DecodeRuneInString(k); r == utf8.RuneError || !unicode.In(r, unicode.Ll, unicode.Lo) {
+		probs = append(probs, lint.Problem{
+			Severity:   lint.ERROR,
+			Message:    fmt.Sprintf(`Key %q has illegal first character %q.`, k, r),
+			Suggestion: `Fix key.`,
+		})
+	} else if ok, r := validKeyRunes(k); !ok {
+		probs = append(probs, lint.Problem{
+			Severity:   lint.ERROR,
+			Message:    fmt.Sprintf(`Key %q contains illegal character %q.`, k, r),
+			Suggestion: `Fix key.`,
+		})
+	}
+	if count := utf8.RuneCountInString(k); count > 64 {
+		probs = append(probs, lint.Problem{
+			Severity:   lint.ERROR,
+			Message:    fmt.Sprintf(`Key %q exceeds max length of 64 characters.`, k),
+			Suggestion: `Fix key.`,
+		})
+	}
+	return probs
+}
+
+func validKeyRunes(s string) (bool, rune) {
+	for _, r := range s {
+		if !unicode.In(r, unicode.Ll, unicode.Lo, unicode.N) && r != '_' && r != '-' {
+			return false, r
+		}
+	}
+	return true, 0
+}
diff --git a/cmd/registry/cmd/check/rules/rules.go b/cmd/registry/cmd/check/rules/rules.go
index 2ac9fa7..5cd43a7 100644
--- a/cmd/registry/cmd/check/rules/rules.go
+++ b/cmd/registry/cmd/check/rules/rules.go
@@ -42,7 +42,11 @@ import (
 	"github.com/apigee/registry/cmd/registry/cmd/check/rules/rule106"
 	"github.com/apigee/registry/cmd/registry/cmd/check/rules/rule107"
 	"github.com/apigee/registry/cmd/registry/cmd/check/rules/rule108"
+	"github.com/apigee/registry/cmd/registry/cmd/check/rules/rule109"
 	"github.com/apigee/registry/cmd/registry/cmd/check/rules/rule110"
+	"github.com/apigee/registry/cmd/registry/cmd/check/rules/rule111"
+	"github.com/apigee/registry/cmd/registry/cmd/check/rules/rule112"
+	"github.com/apigee/registry/cmd/registry/cmd/check/rules/rule113"
 )
 
 type addRulesFuncType func(lint.RuleRegistry) error
@@ -57,7 +61,11 @@ var addRulesFuncs = []addRulesFuncType{
 	rule106.AddRules,
 	rule107.AddRules,
 	rule108.AddRules,
+	rule109.AddRules,
 	rule110.AddRules,
+	rule111.AddRules,
+	rule112.AddRules,
+	rule113.AddRules,
 }
 
 // Add all rules to the given registry.
diff --git a/cmd/registry/cmd/check/rules/util/strings.go b/cmd/registry/cmd/check/rules/util/strings.go
new file mode 100644
index 0000000..b11ac9a
--- /dev/null
+++ b/cmd/registry/cmd/check/rules/util/strings.go
@@ -0,0 +1,41 @@
+// 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 util
+
+import (
+	"fmt"
+	"unicode/utf8"
+
+	"github.com/apigee/registry/cmd/registry/cmd/check/lint"
+)
+
+func CheckUTF(fieldName string, value interface{}, maxLen int) []lint.Problem {
+	v := value.(string)
+	if !utf8.ValidString(v) {
+		return []lint.Problem{{
+			Severity:   lint.ERROR,
+			Message:    fmt.Sprintf("%s must contain only UTF-8 characters.", fieldName),
+			Suggestion: fmt.Sprintf("Fix %s.", fieldName),
+		}}
+	}
+	if utf8.RuneCountInString(v) > maxLen {
+		return []lint.Problem{{
+			Severity:   lint.ERROR,
+			Message:    fmt.Sprintf("%s exceeds limit of %d characters.", fieldName, maxLen),
+			Suggestion: fmt.Sprintf("Fix %s.", fieldName),
+		}}
+	}
+	return nil
+}


Test patch:
diff --git a/cmd/registry/cmd/check/rules/rule109/rule109_test.go b/cmd/registry/cmd/check/rules/rule109/rule109_test.go
new file mode 100644
index 0000000..d5f4848
--- /dev/null
+++ b/cmd/registry/cmd/check/rules/rule109/rule109_test.go
@@ -0,0 +1,70 @@
+// 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 rule109
+
+import (
+	"context"
+	"fmt"
+	"strings"
+	"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"
+)
+
+func TestAddRules(t *testing.T) {
+	if err := AddRules(lint.NewRuleRegistry()); err != nil {
+		t.Errorf("AddRules got an error: %v", err)
+	}
+}
+
+func TestDisplayName(t *testing.T) {
+	bad := []lint.Problem{{
+		Severity:   lint.ERROR,
+		Message:    fmt.Sprintf("%s must contain only UTF-8 characters.", fieldName),
+		Suggestion: fmt.Sprintf("Fix %s.", fieldName)}}
+
+	tooLong := []lint.Problem{{
+		Severity:   lint.ERROR,
+		Message:    fmt.Sprintf("%s exceeds limit of 65 characters.", fieldName),
+		Suggestion: fmt.Sprintf("Fix %s.", fieldName)}}
+
+	tests := []struct {
+		name     string
+		in       string
+		expected []lint.Problem
+	}{
+		{"empty", "", nil},
+		{"invalid", string([]byte{0xff}), bad},
+		{"long", strings.Repeat("x", 65), nil},
+		{"too long", strings.Repeat("y", 66), tooLong},
+	}
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			ctx := context.Background()
+			a := &rpc.ApiSpec{
+				Description: test.in,
+			}
+			if displayName.OnlyIf(a, fieldName) {
+				got := displayName.ApplyToField(ctx, a, fieldName, test.in)
+				if diff := cmp.Diff(test.expected, got, cmpopts.IgnoreUnexported(lint.Problem{})); diff != "" {
+					t.Errorf("Unexpected diff (-want +got):\n%s", diff)
+				}
+			}
+		})
+	}
+}
diff --git a/cmd/registry/cmd/check/rules/rule110/rule110_test.go b/cmd/registry/cmd/check/rules/rule110/rule110_test.go
index 3ccd21f..74564f1 100644
--- a/cmd/registry/cmd/check/rules/rule110/rule110_test.go
+++ b/cmd/registry/cmd/check/rules/rule110/rule110_test.go
@@ -16,13 +16,14 @@ package rule110
 
 import (
 	"context"
+	"fmt"
+	"strings"
 	"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"
 )
 
 func TestAddRules(t *testing.T) {
@@ -31,105 +32,39 @@ func TestAddRules(t *testing.T) {
 	}
 }
 
-func Test_mimeTypeContents(t *testing.T) {
+func TestDescription(t *testing.T) {
+	bad := []lint.Problem{{
+		Severity:   lint.ERROR,
+		Message:    fmt.Sprintf("%s must contain only UTF-8 characters.", fieldName),
+		Suggestion: fmt.Sprintf("Fix %s.", fieldName)}}
+
+	tooLong := []lint.Problem{{
+		Severity:   lint.ERROR,
+		Message:    fmt.Sprintf("%s exceeds limit of 5000 characters.", fieldName),
+		Suggestion: fmt.Sprintf("Fix %s.", fieldName)}}
+
 	tests := []struct {
 		name     string
-		mimeType string
-		contents []byte
-		problems []lint.Problem
+		in       string
+		expected []lint.Problem
 	}{
-		{
-			"empty content",
-			"text/plain",
-			nil,
-			nil,
-		},
-		{
-			"type match",
-			"text/plain",
-			[]byte("string"),
-			nil,
-		},
-		{
-			"type and parameter match",
-			"text/plain;charset=utf-8",
-			[]byte("string"),
-			nil,
-		},
-		{
-			"wrong type",
-			"text/html",
-			[]byte("string"),
-			[]lint.Problem{{
-				Message:    `Unexpected mime_type "text/html" for contents.`,
-				Suggestion: `Detected mime_type: "text/plain; charset=utf-8".`,
-				Severity:   lint.WARNING,
-			}},
-		},
-		{
-			"empty type",
-			"",
-			[]byte("string"),
-			[]lint.Problem{{
-				Message:    `Empty mime_type.`,
-				Suggestion: `Detected mime_type: "text/plain; charset=utf-8".`,
-				Severity:   lint.ERROR,
-			}},
-		},
-		{
-			"bad type",
-			"bad/",
-			[]byte("string"),
-			[]lint.Problem{{
-				Message:  `Unable to parse mime_type "bad/": mime: expected token after slash.`,
-				Severity: lint.ERROR,
-			}},
-		},
-		{
-			"score",
-			"application/octet-stream;type=google.cloud.apigeeregistry.v1.scoring.Score",
-			createScore(),
-			nil,
-		},
+		{"empty", "", nil},
+		{"invalid", string([]byte{0xff}), bad},
+		{"long", strings.Repeat("x", 5000), nil},
+		{"too long", strings.Repeat("y", 5001), tooLong},
 	}
 	for _, test := range tests {
 		t.Run(test.name, func(t *testing.T) {
-			resources := []lint.Resource{
-				&rpc.ApiSpec{
-					Name:     "ApiSpec",
-					MimeType: test.mimeType,
-					Contents: test.contents,
-				},
-				&rpc.Artifact{
-					Name:     "Artifact",
-					MimeType: test.mimeType,
-					Contents: test.contents,
-				},
+			ctx := context.Background()
+			a := &rpc.ApiSpec{
+				Description: test.in,
 			}
-			for _, resource := range resources {
-				t.Run(resource.GetName(), func(t *testing.T) {
-					if !mimeTypeContents.OnlyIf(resource, "MimeType") {
-						t.Error("invalid OnlyIf")
-					}
-					got := mimeTypeContents.Apply(context.Background(), resource)
-					for i := range test.problems {
-						test.problems[i].Location = resource.GetName() + "::MimeType"
-					}
-					if diff := cmp.Diff(test.problems, got, cmpopts.IgnoreUnexported(lint.Problem{})); diff != "" {
-						t.Errorf("Unexpected diff (-want +got):\n%s", diff)
-					}
-				})
+			if description.OnlyIf(a, fieldName) {
+				got := description.ApplyToField(ctx, a, fieldName, test.in)
+				if diff := cmp.Diff(test.expected, got, cmpopts.IgnoreUnexported(lint.Problem{})); diff != "" {
+					t.Errorf("Unexpected diff (-want +got):\n%s", diff)
+				}
 			}
 		})
 	}
 }
-
-func createScore() []byte {
-	s := &rpc.Score{
-		Id:    "score",
-		Kind:  "Score",
-		Value: &rpc.Score_IntegerValue{},
-	}
-	b, _ := proto.Marshal(s)
-	return b
-}
diff --git a/cmd/registry/cmd/check/rules/rule111/rule111_test.go b/cmd/registry/cmd/check/rules/rule111/rule111_test.go
new file mode 100644
index 0000000..8afae35
--- /dev/null
+++ b/cmd/registry/cmd/check/rules/rule111/rule111_test.go
@@ -0,0 +1,135 @@
+// 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 rule111
+
+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"
+)
+
+func TestAddRules(t *testing.T) {
+	if err := AddRules(lint.NewRuleRegistry()); err != nil {
+		t.Errorf("AddRules got an error: %v", err)
+	}
+}
+
+func TestMimeTypeContents(t *testing.T) {
+	tests := []struct {
+		name     string
+		mimeType string
+		contents []byte
+		problems []lint.Problem
+	}{
+		{
+			"empty content",
+			"text/plain",
+			nil,
+			nil,
+		},
+		{
+			"type match",
+			"text/plain",
+			[]byte("string"),
+			nil,
+		},
+		{
+			"type and parameter match",
+			"text/plain;charset=utf-8",
+			[]byte("string"),
+			nil,
+		},
+		{
+			"wrong type",
+			"text/html",
+			[]byte("string"),
+			[]lint.Problem{{
+				Message:    `Unexpected mime_type "text/html" for contents.`,
+				Suggestion: `Detected mime_type: "text/plain; charset=utf-8".`,
+				Severity:   lint.WARNING,
+			}},
+		},
+		{
+			"empty type",
+			"",
+			[]byte("string"),
+			[]lint.Problem{{
+				Message:    `Empty mime_type.`,
+				Suggestion: `Detected mime_type: "text/plain; charset=utf-8".`,
+				Severity:   lint.ERROR,
+			}},
+		},
+		{
+			"bad type",
+			"bad/",
+			[]byte("string"),
+			[]lint.Problem{{
+				Message:  `Unable to parse mime_type "bad/": mime: expected token after slash.`,
+				Severity: lint.ERROR,
+			}},
+		},
+		{
+			"score",
+			"application/octet-stream;type=google.cloud.apigeeregistry.v1.scoring.Score",
+			createScore(),
+			nil,
+		},
+	}
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			resources := []lint.Resource{
+				&rpc.ApiSpec{
+					Name:     "ApiSpec",
+					MimeType: test.mimeType,
+					Contents: test.contents,
+				},
+				&rpc.Artifact{
+					Name:     "Artifact",
+					MimeType: test.mimeType,
+					Contents: test.contents,
+				},
+			}
+			for _, resource := range resources {
+				t.Run(resource.GetName(), func(t *testing.T) {
+					if !mimeTypeContents.OnlyIf(resource, "MimeType") {
+						t.Error("invalid OnlyIf")
+					}
+					got := mimeTypeContents.Apply(context.Background(), resource)
+					for i := range test.problems {
+						test.problems[i].Location = resource.GetName() + "::MimeType"
+					}
+					if diff := cmp.Diff(test.problems, got, cmpopts.IgnoreUnexported(lint.Problem{})); diff != "" {
+						t.Errorf("Unexpected diff (-want +got):\n%s", diff)
+					}
+				})
+			}
+		})
+	}
+}
+
+func createScore() []byte {
+	s := &rpc.Score{
+		Id:    "score",
+		Kind:  "Score",
+		Value: &rpc.Score_IntegerValue{},
+	}
+	b, _ := proto.Marshal(s)
+	return b
+}
diff --git a/cmd/registry/cmd/check/rules/rule112/rule112_test.go b/cmd/registry/cmd/check/rules/rule112/rule112_test.go
new file mode 100644
index 0000000..70dd2bd
--- /dev/null
+++ b/cmd/registry/cmd/check/rules/rule112/rule112_test.go
@@ -0,0 +1,167 @@
+// 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 rule112
+
+import (
+	"context"
+	"fmt"
+	"strconv"
+	"strings"
+	"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"
+)
+
+func TestAddRules(t *testing.T) {
+	if err := AddRules(lint.NewRuleRegistry()); err != nil {
+		t.Errorf("AddRules got an error: %v", err)
+	}
+}
+
+func TestLabels(t *testing.T) {
+	many := make(map[string]string, 64)
+	for i := 0; i < 64; i++ {
+		many["key"+strconv.Itoa(i)] = "value" + strconv.Itoa(i)
+	}
+	tooMany := make(map[string]string, 65)
+	for k, v := range many {
+		tooMany[k] = v
+	}
+	tooMany["final"] = "straw"
+	tests := []struct {
+		name     string
+		in       map[string]string
+		expected []lint.Problem
+	}{
+		{"nil", nil, nil},
+		{"empty", map[string]string{}, nil},
+		{
+			"good",
+			map[string]string{
+				"key1": "value1",
+				"key2": "value2",
+			},
+			nil,
+		},
+		{
+			"bad",
+			map[string]string{
+				"key": "value",
+				"*":   "*",
+			},
+			[]lint.Problem{
+				{
+					Message:    `Key "*" has illegal first character '*'.`,
+					Suggestion: "Fix key.",
+					Severity:   lint.ERROR,
+				},
+				{
+					Message:    `Value for key "*" contains illegal character '*'.`,
+					Suggestion: "Fix value.",
+					Severity:   lint.ERROR,
+				},
+			},
+		},
+		{
+			"many",
+			many,
+			nil,
+		},
+		{
+			"too many",
+			tooMany,
+			[]lint.Problem{
+				{
+					Message:    `Maximum number of labels is 64.`,
+					Suggestion: "Delete some entries.",
+					Severity:   lint.ERROR,
+				},
+			},
+		},
+	}
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			ctx := context.Background()
+			a := &rpc.ApiSpec{
+				Labels: test.in,
+			}
+			if labels.OnlyIf(a, fieldName) {
+				got := labels.ApplyToField(ctx, a, fieldName, test.in)
+				if diff := cmp.Diff(test.expected, got, cmpopts.IgnoreUnexported(lint.Problem{})); diff != "" {
+					t.Errorf("Unexpected diff (-want +got):\n%s", diff)
+				}
+			}
+		})
+	}
+}
+
+func TestCheckLabel(t *testing.T) {
+	tests := []struct {
+		name     string
+		key      string
+		value    string
+		expected []lint.Problem
+	}{
+		{"good", "alphanum", "value1_2-", nil},
+		{"period", "key.", ".", []lint.Problem{
+			{
+				Message:    `Key "key." contains illegal character '.'.`,
+				Suggestion: "Fix key.",
+				Severity:   lint.ERROR,
+			},
+			{
+				Message:    `Value for key "key." contains illegal character '.'.`,
+				Suggestion: "Fix value.",
+				Severity:   lint.ERROR,
+			},
+		}},
+		{"uppercase", "keY", "valuE", []lint.Problem{
+			{
+				Message:    `Key "keY" contains illegal character 'Y'.`,
+				Suggestion: "Fix key.",
+				Severity:   lint.ERROR,
+			},
+			{
+				Message:    `Value for key "keY" contains illegal character 'E'.`,
+				Suggestion: "Fix value.",
+				Severity:   lint.ERROR,
+			},
+		}},
+		{"long", strings.Repeat("y", 64), strings.Repeat("y", 64), nil},
+		{"too long", strings.Repeat("n", 65), strings.Repeat("n", 65), []lint.Problem{
+			{
+				Message:    fmt.Sprintf(`Key %q exceeds max length of 64 characters.`, strings.Repeat("n", 65)),
+				Suggestion: "Fix key.",
+				Severity:   lint.ERROR,
+			},
+			{
+				Message:    fmt.Sprintf(`Value for key %q exceeds max length of 64 characters.`, strings.Repeat("n", 65)),
+				Suggestion: "Fix value.",
+				Severity:   lint.ERROR,
+			},
+		}},
+	}
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			got := checkLabel(test.key, test.value)
+			if diff := cmp.Diff(test.expected, got, cmpopts.IgnoreUnexported(lint.Problem{})); diff != "" {
+				t.Errorf("Unexpected diff (-want +got):\n%s", diff)
+			}
+		})
+	}
+}
diff --git a/cmd/registry/cmd/check/rules/rule113/rule113_test.go b/cmd/registry/cmd/check/rules/rule113/rule113_test.go
new file mode 100644
index 0000000..8dc1bc9
--- /dev/null
+++ b/cmd/registry/cmd/check/rules/rule113/rule113_test.go
@@ -0,0 +1,156 @@
+// 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 rule113
+
+import (
+	"context"
+	"fmt"
+	"strings"
+	"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"
+)
+
+func TestAddRules(t *testing.T) {
+	if err := AddRules(lint.NewRuleRegistry()); err != nil {
+		t.Errorf("AddRules got an error: %v", err)
+	}
+}
+
+func TestAnnotations(t *testing.T) {
+	tests := []struct {
+		name     string
+		in       map[string]string
+		expected []lint.Problem
+	}{
+		{"nil", nil, nil},
+		{"empty", map[string]string{}, nil},
+		{
+			"good",
+			map[string]string{
+				"key1": "value1",
+				"key2": "value2",
+			},
+			nil,
+		},
+		{
+			"bad",
+			map[string]string{
+				"key": "value",
+				"*":   "*",
+			},
+			[]lint.Problem{
+				{
+					Message:    `Key "*" has illegal first character '*'.`,
+					Suggestion: "Fix key.",
+					Severity:   lint.ERROR,
+				},
+			},
+		},
+		{
+			"big one",
+			map[string]string{
+				"key": strings.Repeat("x", totalSizeLimit-3),
+			},
+			nil,
+		},
+		{
+			"too big one",
+			map[string]string{
+				"key2": strings.Repeat("x", totalSizeLimit-3),
+			},
+			[]lint.Problem{
+				{
+					Message:    `Maximum size of all annotations is 256k.`,
+					Suggestion: `Reduce size by 1 bytes.`,
+					Severity:   lint.ERROR,
+				},
+			},
+		},
+		{
+			"too big multiple",
+			map[string]string{
+				"key1": strings.Repeat("x", totalSizeLimit/3),
+				"key2": strings.Repeat("x", totalSizeLimit/3),
+				"key3": strings.Repeat("x", totalSizeLimit/3),
+			},
+			[]lint.Problem{
+				{
+					Message:    `Maximum size of all annotations is 256k.`,
+					Suggestion: `Reduce size by 11 bytes.`,
+					Severity:   lint.ERROR,
+				},
+			},
+		},
+	}
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			ctx := context.Background()
+			a := &rpc.ApiSpec{
+				Labels: test.in,
+			}
+			if annotations.OnlyIf(a, fieldName) {
+				got := annotations.ApplyToField(ctx, a, fieldName, test.in)
+				if diff := cmp.Diff(test.expected, got, cmpopts.IgnoreUnexported(lint.Problem{})); diff != "" {
+					t.Errorf("Unexpected diff (-want +got):\n%s", diff)
+				}
+			}
+		})
+	}
+}
+
+func TestCheckAnnotation(t *testing.T) {
+	tests := []struct {
+		name     string
+		key      string
+		value    string
+		expected []lint.Problem
+	}{
+		{"good", "alphanum", "value1_2-", nil},
+		{"period", "key.", ".", []lint.Problem{
+			{
+				Message:    `Key "key." contains illegal character '.'.`,
+				Suggestion: "Fix key.",
+				Severity:   lint.ERROR,
+			},
+		}},
+		{"uppercase", "keY", "valuE", []lint.Problem{
+			{
+				Message:    `Key "keY" contains illegal character 'Y'.`,
+				Suggestion: "Fix key.",
+				Severity:   lint.ERROR,
+			},
+		}},
+		{"long", strings.Repeat("y", 64), strings.Repeat("y", 64), nil},
+		{"too long", strings.Repeat("n", 65), strings.Repeat("n", 65), []lint.Problem{
+			{
+				Message:    fmt.Sprintf(`Key %q exceeds max length of 64 characters.`, strings.Repeat("n", 65)),
+				Suggestion: "Fix key.",
+				Severity:   lint.ERROR,
+			},
+		}},
+	}
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			got := checkAnnotation(test.key, test.value)
+			if diff := cmp.Diff(test.expected, got, cmpopts.IgnoreUnexported(lint.Problem{})); diff != "" {
+				t.Errorf("Unexpected diff (-want +got):\n%s", diff)
+			}
+		})
+	}
+}
diff --git a/cmd/registry/cmd/check/rules/util/strings_test.go b/cmd/registry/cmd/check/rules/util/strings_test.go
new file mode 100644
index 0000000..0090a69
--- /dev/null
+++ b/cmd/registry/cmd/check/rules/util/strings_test.go
@@ -0,0 +1,55 @@
+// 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 util
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/apigee/registry/cmd/registry/cmd/check/lint"
+	"github.com/google/go-cmp/cmp"
+	"github.com/google/go-cmp/cmp/cmpopts"
+)
+
+func TestCheckUTF(t *testing.T) {
+	bad := []lint.Problem{{
+		Severity:   lint.ERROR,
+		Message:    `xxx must contain only UTF-8 characters.`,
+		Suggestion: `Fix xxx.`}}
+
+	tooLong := []lint.Problem{{
+		Severity:   lint.ERROR,
+		Message:    `xxx exceeds limit of 64 characters.`,
+		Suggestion: `Fix xxx.`}}
+
+	tests := []struct {
+		name     string
+		in       string
+		expected []lint.Problem
+	}{
+		{"empty", "", nil},
+		{"invalid", string([]byte{0xff}), bad},
+		{"long", strings.Repeat("y", 64), nil},
+		{"too long", strings.Repeat("n", 65), tooLong},
+	}
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			got := CheckUTF("xxx", test.in, 64)
+			if diff := cmp.Diff(test.expected, got, cmpopts.IgnoreUnexported(lint.Problem{})); diff != "" {
+				t.Errorf("Unexpected diff (-want +got):\n%s", diff)
+			}
+		})
+	}
+}