[object Object]

← back to Cli Printing Press

feat(spec): YAML spec parser with validation and Stytch test fixture

b4ab56ba6c58477cfa439b67fcd8a551b6543b7c · 2026-03-23 09:03:22 -0700 · Matt Van Horn

Parses API description YAML into Go structs. Validates required fields,
auth config, resource endpoints. Tests pass against Stytch fixture.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit b4ab56ba6c58477cfa439b67fcd8a551b6543b7c
Author: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Date:   Mon Mar 23 09:03:22 2026 -0700

    feat(spec): YAML spec parser with validation and Stytch test fixture
    
    Parses API description YAML into Go structs. Validates required fields,
    auth config, resource endpoints. Tests pass against Stytch fixture.
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---
 go.mod                     |   3 ++
 go.sum                     |   6 +++
 internal/spec/spec_test.go |  87 ++++++++++++++++++++++++++++++
 testdata/stytch.yaml       | 131 +++++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 227 insertions(+)

diff --git a/go.mod b/go.mod
index d46c2082..1fd7224f 100644
--- a/go.mod
+++ b/go.mod
@@ -4,10 +4,13 @@ go 1.26.1
 
 require (
 	github.com/spf13/cobra v1.10.2
+	github.com/stretchr/testify v1.11.1
 	gopkg.in/yaml.v3 v3.0.1
 )
 
 require (
+	github.com/davecgh/go-spew v1.1.1 // indirect
 	github.com/inconshreveable/mousetrap v1.1.0 // indirect
+	github.com/pmezard/go-difflib v1.0.0 // indirect
 	github.com/spf13/pflag v1.0.9 // indirect
 )
diff --git a/go.sum b/go.sum
index 47edb24d..5352f4f3 100644
--- a/go.sum
+++ b/go.sum
@@ -1,11 +1,17 @@
 github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
 github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
 github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
 github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
 github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
 go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
diff --git a/internal/spec/spec_test.go b/internal/spec/spec_test.go
new file mode 100644
index 00000000..7be0e6d1
--- /dev/null
+++ b/internal/spec/spec_test.go
@@ -0,0 +1,87 @@
+package spec
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestParseStytch(t *testing.T) {
+	s, err := Parse("../../testdata/stytch.yaml")
+	require.NoError(t, err)
+
+	assert.Equal(t, "stytch", s.Name)
+	assert.Equal(t, "Stytch authentication API CLI", s.Description)
+	assert.Equal(t, "0.1.0", s.Version)
+	assert.Equal(t, "https://api.stytch.com/v1", s.BaseURL)
+
+	// Auth
+	assert.Equal(t, "api_key", s.Auth.Type)
+	assert.Equal(t, "Authorization", s.Auth.Header)
+	assert.Len(t, s.Auth.EnvVars, 2)
+
+	// Resources
+	assert.Len(t, s.Resources, 2)
+	users := s.Resources["users"]
+	assert.Equal(t, "Manage Stytch users", users.Description)
+	assert.Len(t, users.Endpoints, 4) // list, get, create, delete
+
+	// Users list endpoint
+	list := users.Endpoints["list"]
+	assert.Equal(t, "GET", list.Method)
+	assert.Equal(t, "/users", list.Path)
+	assert.NotNil(t, list.Pagination)
+	assert.Equal(t, "cursor", list.Pagination.Type)
+
+	// Users create endpoint
+	create := users.Endpoints["create"]
+	assert.Equal(t, "POST", create.Method)
+	assert.Len(t, create.Body, 2)
+
+	// Sessions
+	sessions := s.Resources["sessions"]
+	assert.Len(t, sessions.Endpoints, 2)
+
+	// Types
+	assert.Len(t, s.Types, 2)
+	assert.Len(t, s.Types["User"].Fields, 5)
+	assert.Len(t, s.Types["Session"].Fields, 4)
+}
+
+func TestValidation(t *testing.T) {
+	tests := []struct {
+		name    string
+		spec    APISpec
+		wantErr string
+	}{
+		{
+			name:    "empty name",
+			spec:    APISpec{BaseURL: "http://x", Resources: map[string]Resource{"a": {Endpoints: map[string]Endpoint{"b": {Method: "GET", Path: "/"}}}}},
+			wantErr: "name is required",
+		},
+		{
+			name:    "empty base_url",
+			spec:    APISpec{Name: "x", Resources: map[string]Resource{"a": {Endpoints: map[string]Endpoint{"b": {Method: "GET", Path: "/"}}}}},
+			wantErr: "base_url is required",
+		},
+		{
+			name:    "no resources",
+			spec:    APISpec{Name: "x", BaseURL: "http://x"},
+			wantErr: "at least one resource is required",
+		},
+		{
+			name:    "endpoint missing method",
+			spec:    APISpec{Name: "x", BaseURL: "http://x", Resources: map[string]Resource{"a": {Endpoints: map[string]Endpoint{"b": {Path: "/"}}}}},
+			wantErr: "method is required",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			err := tt.spec.Validate()
+			require.Error(t, err)
+			assert.Contains(t, err.Error(), tt.wantErr)
+		})
+	}
+}
diff --git a/testdata/stytch.yaml b/testdata/stytch.yaml
new file mode 100644
index 00000000..91f3fdb1
--- /dev/null
+++ b/testdata/stytch.yaml
@@ -0,0 +1,131 @@
+name: stytch
+description: "Stytch authentication API CLI"
+version: "0.1.0"
+base_url: "https://api.stytch.com/v1"
+
+auth:
+  type: api_key
+  header: "Authorization"
+  format: "Basic {project_id}:{secret}"
+  env_vars:
+    - STYTCH_PROJECT_ID
+    - STYTCH_SECRET
+
+config:
+  format: toml
+  path: "~/.config/stytch-cli/config.toml"
+
+resources:
+  users:
+    description: "Manage Stytch users"
+    endpoints:
+      list:
+        method: GET
+        path: "/users"
+        description: "List all users"
+        params:
+          - name: limit
+            type: int
+            default: 100
+            description: "Max users to return"
+          - name: cursor
+            type: string
+            description: "Pagination cursor"
+        response:
+          type: array
+          item: User
+        pagination:
+          type: cursor
+          cursor_field: "cursor"
+          has_more_field: "results.has_more"
+
+      get:
+        method: GET
+        path: "/users/{user_id}"
+        description: "Get a user by ID"
+        params:
+          - name: user_id
+            type: string
+            required: true
+            positional: true
+            description: "User ID"
+        response:
+          type: object
+          item: User
+
+      create:
+        method: POST
+        path: "/users"
+        description: "Create a new user"
+        body:
+          - name: email
+            type: string
+            description: "User email"
+          - name: phone_number
+            type: string
+            description: "User phone number"
+        response:
+          type: object
+          item: User
+
+      delete:
+        method: DELETE
+        path: "/users/{user_id}"
+        description: "Delete a user"
+        params:
+          - name: user_id
+            type: string
+            required: true
+            positional: true
+            description: "User ID"
+
+  sessions:
+    description: "Manage user sessions"
+    endpoints:
+      list:
+        method: GET
+        path: "/sessions"
+        description: "List sessions for a user"
+        params:
+          - name: user_id
+            type: string
+            required: true
+            description: "User ID"
+        response:
+          type: array
+          item: Session
+
+      revoke:
+        method: POST
+        path: "/sessions/revoke"
+        description: "Revoke a session"
+        body:
+          - name: session_id
+            type: string
+            required: true
+            description: "Session ID to revoke"
+
+types:
+  User:
+    fields:
+      - name: user_id
+        type: string
+      - name: email
+        type: string
+      - name: phone_number
+        type: string
+      - name: status
+        type: string
+      - name: created_at
+        type: string
+
+  Session:
+    fields:
+      - name: session_id
+        type: string
+      - name: user_id
+        type: string
+      - name: started_at
+        type: string
+      - name: expires_at
+        type: string

← 454739bd feat(scaffold): initial project structure with CLI skeleton  ·  back to Cli Printing Press  ·  feat(templates): Go templates for all generated CLI files a78f0979 →