[object Object]

← back to Cli Printing Press

feat(catalog): add catalog schema validator with tests

cd4824a98297751eacbe3d7cfc42095bc9f0c61a · 2026-03-23 16:13:24 -0700 · Matt Van Horn

Files touched

Diff

commit cd4824a98297751eacbe3d7cfc42095bc9f0c61a
Author: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Date:   Mon Mar 23 16:13:24 2026 -0700

    feat(catalog): add catalog schema validator with tests
---
 internal/catalog/catalog.go      | 136 ++++++++++++++++++++++++++++++++++++++
 internal/catalog/catalog_test.go | 139 +++++++++++++++++++++++++++++++++++++++
 testdata/catalog/petstore.yaml   |  11 ++++
 testdata/catalog/test-api.yaml   |  11 ++++
 4 files changed, 297 insertions(+)

diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go
new file mode 100644
index 00000000..8fbb7d80
--- /dev/null
+++ b/internal/catalog/catalog.go
@@ -0,0 +1,136 @@
+package catalog
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+	"regexp"
+	"sort"
+	"strings"
+
+	"gopkg.in/yaml.v3"
+)
+
+var namePattern = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
+
+var validCategories = map[string]struct{}{
+	"auth":               {},
+	"payments":           {},
+	"email":              {},
+	"developer-tools":    {},
+	"project-management": {},
+	"communication":      {},
+	"crm":                {},
+	"example":            {},
+}
+
+var validSpecFormats = map[string]struct{}{
+	"yaml": {},
+	"json": {},
+}
+
+var validTiers = map[string]struct{}{
+	"official":  {},
+	"community": {},
+}
+
+type Entry struct {
+	Name           string `yaml:"name"`
+	DisplayName    string `yaml:"display_name"`
+	Description    string `yaml:"description"`
+	Category       string `yaml:"category"`
+	SpecURL        string `yaml:"spec_url"`
+	SpecFormat     string `yaml:"spec_format"`
+	OpenAPIVersion string `yaml:"openapi_version"`
+	Tier           string `yaml:"tier"`
+	VerifiedDate   string `yaml:"verified_date"`
+	Homepage       string `yaml:"homepage"`
+	Notes          string `yaml:"notes"`
+}
+
+func ParseEntry(data []byte) (*Entry, error) {
+	var e Entry
+	if err := yaml.Unmarshal(data, &e); err != nil {
+		return nil, fmt.Errorf("parsing yaml: %w", err)
+	}
+	if err := e.Validate(); err != nil {
+		return nil, fmt.Errorf("validation: %w", err)
+	}
+	return &e, nil
+}
+
+func ParseDir(dir string) ([]Entry, error) {
+	dirEntries, err := os.ReadDir(dir)
+	if err != nil {
+		return nil, fmt.Errorf("reading directory: %w", err)
+	}
+
+	sort.Slice(dirEntries, func(i, j int) bool {
+		return dirEntries[i].Name() < dirEntries[j].Name()
+	})
+
+	entries := make([]Entry, 0, len(dirEntries))
+	for _, de := range dirEntries {
+		if de.IsDir() {
+			continue
+		}
+		if filepath.Ext(de.Name()) != ".yaml" {
+			continue
+		}
+
+		path := filepath.Join(dir, de.Name())
+		data, err := os.ReadFile(path)
+		if err != nil {
+			return nil, fmt.Errorf("reading %s: %w", de.Name(), err)
+		}
+
+		entry, err := ParseEntry(data)
+		if err != nil {
+			return nil, fmt.Errorf("parsing %s: %w", de.Name(), err)
+		}
+		entries = append(entries, *entry)
+	}
+
+	return entries, nil
+}
+
+func (e *Entry) Validate() error {
+	if e.Name == "" {
+		return fmt.Errorf("name is required")
+	}
+	if !namePattern.MatchString(e.Name) {
+		return fmt.Errorf("name must be lowercase kebab-case (letters, digits, hyphens only)")
+	}
+	if e.DisplayName == "" {
+		return fmt.Errorf("display_name is required")
+	}
+	if e.Description == "" {
+		return fmt.Errorf("description is required")
+	}
+	if e.Category == "" {
+		return fmt.Errorf("category is required")
+	}
+	if _, ok := validCategories[e.Category]; !ok {
+		return fmt.Errorf("category must be one of: auth, payments, email, developer-tools, project-management, communication, crm, example")
+	}
+	if e.SpecURL == "" {
+		return fmt.Errorf("spec_url is required")
+	}
+	if !strings.HasPrefix(e.SpecURL, "https://") {
+		return fmt.Errorf(`spec_url must start with "https://"`)
+	}
+	if e.SpecFormat == "" {
+		return fmt.Errorf("spec_format is required")
+	}
+	if _, ok := validSpecFormats[e.SpecFormat]; !ok {
+		return fmt.Errorf("spec_format must be one of: yaml, json")
+	}
+	if e.Tier == "" {
+		return fmt.Errorf("tier is required")
+	}
+	if _, ok := validTiers[e.Tier]; !ok {
+		return fmt.Errorf("tier must be one of: official, community")
+	}
+
+	return nil
+}
diff --git a/internal/catalog/catalog_test.go b/internal/catalog/catalog_test.go
new file mode 100644
index 00000000..ca6e03b4
--- /dev/null
+++ b/internal/catalog/catalog_test.go
@@ -0,0 +1,139 @@
+package catalog
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestParseEntry(t *testing.T) {
+	data := []byte(`
+name: test-api
+display_name: Test API
+description: Test API for catalog parser validation
+category: developer-tools
+spec_url: https://example.com/openapi.yaml
+spec_format: yaml
+openapi_version: "3.0"
+tier: community
+verified_date: "2026-03-23"
+homepage: https://example.com
+notes: Example fixture.
+`)
+
+	entry, err := ParseEntry(data)
+	require.NoError(t, err)
+
+	assert.Equal(t, "test-api", entry.Name)
+	assert.Equal(t, "Test API", entry.DisplayName)
+	assert.Equal(t, "Test API for catalog parser validation", entry.Description)
+	assert.Equal(t, "developer-tools", entry.Category)
+	assert.Equal(t, "https://example.com/openapi.yaml", entry.SpecURL)
+	assert.Equal(t, "yaml", entry.SpecFormat)
+	assert.Equal(t, "3.0", entry.OpenAPIVersion)
+	assert.Equal(t, "community", entry.Tier)
+	assert.Equal(t, "2026-03-23", entry.VerifiedDate)
+	assert.Equal(t, "https://example.com", entry.Homepage)
+	assert.Equal(t, "Example fixture.", entry.Notes)
+}
+
+func TestValidateEntry(t *testing.T) {
+	base := Entry{
+		Name:        "test-api",
+		DisplayName: "Test API",
+		Description: "A valid catalog entry",
+		Category:    "developer-tools",
+		SpecURL:     "https://example.com/openapi.yaml",
+		SpecFormat:  "yaml",
+		Tier:        "official",
+	}
+
+	tests := []struct {
+		name    string
+		mutate  func(*Entry)
+		wantErr string
+	}{
+		{
+			name: "empty name",
+			mutate: func(e *Entry) {
+				e.Name = ""
+			},
+			wantErr: "name is required",
+		},
+		{
+			name: "invalid name format",
+			mutate: func(e *Entry) {
+				e.Name = "Not_Kebab"
+			},
+			wantErr: "name must be lowercase kebab-case",
+		},
+		{
+			name: "invalid category",
+			mutate: func(e *Entry) {
+				e.Category = "finance"
+			},
+			wantErr: "category must be one of",
+		},
+		{
+			name: "non https spec url",
+			mutate: func(e *Entry) {
+				e.SpecURL = "http://example.com/openapi.yaml"
+			},
+			wantErr: `spec_url must start with "https://"`,
+		},
+		{
+			name: "invalid spec format",
+			mutate: func(e *Entry) {
+				e.SpecFormat = "xml"
+			},
+			wantErr: "spec_format must be one of",
+		},
+		{
+			name: "invalid tier",
+			mutate: func(e *Entry) {
+				e.Tier = "partner"
+			},
+			wantErr: "tier must be one of",
+		},
+		{
+			name: "missing display_name",
+			mutate: func(e *Entry) {
+				e.DisplayName = ""
+			},
+			wantErr: "display_name is required",
+		},
+		{
+			name: "missing description",
+			mutate: func(e *Entry) {
+				e.Description = ""
+			},
+			wantErr: "description is required",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			entry := base
+			tt.mutate(&entry)
+
+			err := entry.Validate()
+			require.Error(t, err)
+			assert.Contains(t, err.Error(), tt.wantErr)
+		})
+	}
+}
+
+func TestParseDir(t *testing.T) {
+	entries, err := ParseDir("../../testdata/catalog")
+	require.NoError(t, err)
+	require.Len(t, entries, 2)
+
+	names := make([]string, 0, len(entries))
+	for _, entry := range entries {
+		names = append(names, entry.Name)
+	}
+
+	assert.Contains(t, names, "test-api")
+	assert.Contains(t, names, "petstore")
+}
diff --git a/testdata/catalog/petstore.yaml b/testdata/catalog/petstore.yaml
new file mode 100644
index 00000000..a79e922f
--- /dev/null
+++ b/testdata/catalog/petstore.yaml
@@ -0,0 +1,11 @@
+name: petstore
+display_name: Petstore
+description: Canonical OpenAPI example - pet store management
+category: example
+spec_url: https://petstore3.swagger.io/api/v3/openapi.yaml
+spec_format: yaml
+openapi_version: "3.0"
+tier: official
+verified_date: "2026-03-23"
+homepage: https://petstore3.swagger.io
+notes: None.
diff --git a/testdata/catalog/test-api.yaml b/testdata/catalog/test-api.yaml
new file mode 100644
index 00000000..bdbbc413
--- /dev/null
+++ b/testdata/catalog/test-api.yaml
@@ -0,0 +1,11 @@
+name: test-api
+display_name: Test API
+description: Test API fixture for catalog directory parsing
+category: developer-tools
+spec_url: https://example.com/openapi.yaml
+spec_format: yaml
+openapi_version: "3.0"
+tier: community
+verified_date: "2026-03-23"
+homepage: https://example.com
+notes: Example fixture.

← 96649272 feat(catalog): add 12 official catalog entries for popular A  ·  back to Cli Printing Press  ·  feat(skill): add /printing-press-catalog for browsing and in 3ff4500f →