← 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
A internal/catalog/catalog.goA internal/catalog/catalog_test.goA testdata/catalog/petstore.yamlA testdata/catalog/test-api.yaml
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 →