[object Object]

← back to Cli Printing Press

feat(cli): add spec_source, auth_required, client_pattern to catalog schema (#61)

f5716d9039613a392e903fe32448bea871445aab · 2026-03-29 18:46:33 -0700 · Trevin Chow

* feat(cli): add spec_source, auth_required, client_pattern to catalog schema

Extends the catalog entry schema with three optional fields to better
support sniff-based and non-standard CLIs:

- spec_source: official | community | sniffed | docs
  Describes how the spec was obtained. Sniffed specs are reverse-
  engineered from browser traffic and carry different trust levels
  than vendor-published specs.

- auth_required: bool (optional)
  Whether the API needs authentication. Replaces burying this info
  in the notes field.

- client_pattern: rest | proxy-envelope | graphql
  The HTTP client pattern needed. Most APIs are standard REST.
  Postman Explore uses a proxy envelope (all requests wrapped in
  a POST with {service, method, path, body?}). This signals to
  the generator/skill that extra client work is needed.

All three fields are optional with backward-compatible defaults
(empty = official/rest/unknown). Existing catalog entries are
unchanged.

Also adds the postman-explore catalog entry as the first sniffed
API in the catalog, with its reverse-engineered spec.

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

* fix(cli): gofmt catalog.go struct alignment

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

---------

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

Files touched

Diff

commit f5716d9039613a392e903fe32448bea871445aab
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Sun Mar 29 18:46:33 2026 -0700

    feat(cli): add spec_source, auth_required, client_pattern to catalog schema (#61)
    
    * feat(cli): add spec_source, auth_required, client_pattern to catalog schema
    
    Extends the catalog entry schema with three optional fields to better
    support sniff-based and non-standard CLIs:
    
    - spec_source: official | community | sniffed | docs
      Describes how the spec was obtained. Sniffed specs are reverse-
      engineered from browser traffic and carry different trust levels
      than vendor-published specs.
    
    - auth_required: bool (optional)
      Whether the API needs authentication. Replaces burying this info
      in the notes field.
    
    - client_pattern: rest | proxy-envelope | graphql
      The HTTP client pattern needed. Most APIs are standard REST.
      Postman Explore uses a proxy envelope (all requests wrapped in
      a POST with {service, method, path, body?}). This signals to
      the generator/skill that extra client work is needed.
    
    All three fields are optional with backward-compatible defaults
    (empty = official/rest/unknown). Existing catalog entries are
    unchanged.
    
    Also adds the postman-explore catalog entry as the first sniffed
    API in the catalog, with its reverse-engineered spec.
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
    
    * fix(cli): gofmt catalog.go struct alignment
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
    
    ---------
    
    Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---
 catalog/postman-explore.yaml            |  17 +
 catalog/specs/postman-explore-spec.yaml | 598 ++++++++++++++++++++++++++++++++
 internal/catalog/catalog.go             |  33 ++
 internal/catalog/catalog_test.go        |  48 +++
 internal/cli/catalog.go                 |   9 +
 5 files changed, 705 insertions(+)

diff --git a/catalog/postman-explore.yaml b/catalog/postman-explore.yaml
new file mode 100644
index 00000000..4138aafc
--- /dev/null
+++ b/catalog/postman-explore.yaml
@@ -0,0 +1,17 @@
+name: postman-explore
+display_name: Postman Explore
+description: Public API network directory for discovering community collections, workspaces, and APIs
+category: developer-tools
+spec_url: https://raw.githubusercontent.com/mvanhorn/cli-printing-press/main/catalog/specs/postman-explore-spec.yaml
+spec_format: yaml
+openapi_version: "3.0"
+tier: community
+spec_source: sniffed
+auth_required: false
+client_pattern: proxy-envelope
+verified_date: "2026-03-29"
+homepage: https://www.postman.com/explore
+notes: >
+  Spec reverse-engineered from postman.com/explore by intercepting fetch/XHR calls to
+  _api/ws/proxy. The proxy envelope wraps all requests as POST with {service, method,
+  path, body?}. Search uses POST /search-all with queryIndices to filter entity types.
diff --git a/catalog/specs/postman-explore-spec.yaml b/catalog/specs/postman-explore-spec.yaml
new file mode 100644
index 00000000..049175c3
--- /dev/null
+++ b/catalog/specs/postman-explore-spec.yaml
@@ -0,0 +1,598 @@
+openapi: "3.0.3"
+info:
+  title: Postman Explore API
+  description: |
+    Undocumented internal API powering the Postman API Network explore site (postman.com/explore).
+    All requests go through a proxy endpoint as POST requests with a JSON body containing
+    {service, method, path, body?}. This spec models the logical endpoints directly.
+
+    No authentication required for public browsing and search.
+  version: "1.0.0"
+  contact:
+    name: Postman Explore (Community)
+
+servers:
+  - url: https://www.postman.com/_api/ws/proxy
+    description: Postman web proxy (all requests are POST with service/method/path body)
+
+tags:
+  - name: browse
+    description: Browse public collections, workspaces, APIs, and flows
+  - name: search
+    description: Full-text search across the public API network
+  - name: categories
+    description: API categories for filtering
+  - name: teams
+    description: Publisher teams on the API network
+  - name: stats
+    description: Network-wide statistics
+
+paths:
+  /v1/api/networkentity:
+    get:
+      operationId: listNetworkEntities
+      summary: Browse public entities on the API network
+      description: |
+        List public collections, workspaces, APIs, or flows with optional sorting,
+        category filtering, and pagination. This is the primary browse endpoint
+        powering postman.com/explore/collections.
+      tags: [browse]
+      parameters:
+        - name: entityType
+          in: query
+          required: true
+          description: Type of entity to browse
+          schema:
+            type: string
+            enum: [collection, workspace, api, flow]
+        - name: limit
+          in: query
+          description: Number of results per page
+          schema:
+            type: integer
+            default: 20
+            minimum: 1
+            maximum: 100
+        - name: offset
+          in: query
+          description: Pagination offset
+          schema:
+            type: integer
+            default: 0
+            minimum: 0
+        - name: sort
+          in: query
+          description: Sort order for results
+          schema:
+            type: string
+            enum: [popular, recent, featured, new, week, alltime]
+            default: popular
+        - name: categoryId
+          in: query
+          description: Filter by category ID (numeric, from categories endpoint)
+          schema:
+            type: integer
+      responses:
+        "200":
+          description: Paginated list of entities
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/NetworkEntityListResponse"
+        "400":
+          description: Invalid parameters
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/ErrorResponse"
+        "429":
+          description: Rate limited
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/RateLimitError"
+
+  /v1/api/networkentity/count:
+    get:
+      operationId: getNetworkEntityCounts
+      summary: Get total counts of entities on the network
+      description: Returns aggregate counts of collections, workspaces, APIs, flows, notebooks, and teams.
+      tags: [stats]
+      parameters:
+        - name: flattenAPIVersions
+          in: query
+          schema:
+            type: boolean
+            default: true
+      responses:
+        "200":
+          description: Entity counts
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/EntityCountResponse"
+
+  /search-all:
+    post:
+      operationId: searchAll
+      summary: Full-text search across the public API network
+      description: |
+        Search for collections, workspaces, requests, flows, and teams by text query.
+        Supports filtering by entity type via queryIndices and pagination via from/size.
+        This powers the search bar at postman.com/search.
+      tags: [search]
+      requestBody:
+        required: true
+        content:
+          application/json:
+            schema:
+              $ref: "#/components/schemas/SearchRequest"
+      responses:
+        "200":
+          description: Search results
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/SearchResponse"
+
+  /v2/api/category:
+    get:
+      operationId: listCategories
+      summary: List all API categories
+      description: Returns all categories used to organize the API network (e.g., AI, Communication, DevOps).
+      tags: [categories]
+      parameters:
+        - name: sort
+          in: query
+          description: Sort order
+          schema:
+            type: string
+            enum: [spotlighted]
+            default: spotlighted
+      responses:
+        "200":
+          description: List of categories
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/CategoryListResponse"
+
+  /v2/api/category/{slug}:
+    get:
+      operationId: getCategory
+      summary: Get details for a specific category
+      description: Returns full details for a category by its URL slug.
+      tags: [categories]
+      parameters:
+        - name: slug
+          in: path
+          required: true
+          description: Category URL slug (e.g., artificial-intelligence, devops)
+          schema:
+            type: string
+      responses:
+        "200":
+          description: Category details
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/CategoryDetailResponse"
+
+  /v1/api/team:
+    get:
+      operationId: listTeams
+      summary: List publisher teams on the API network
+      description: Returns teams that publish collections and APIs on the network.
+      tags: [teams]
+      parameters:
+        - name: limit
+          in: query
+          schema:
+            type: integer
+            default: 10
+        - name: sort
+          in: query
+          schema:
+            type: string
+            enum: [popular]
+            default: popular
+      responses:
+        "200":
+          description: List of teams
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/TeamListResponse"
+
+components:
+  schemas:
+    NetworkEntity:
+      type: object
+      description: A public entity (collection, workspace, API, or flow) on the Postman API Network
+      properties:
+        id:
+          type: integer
+          description: Internal network entity ID
+        entityId:
+          type: string
+          description: Unique entity identifier (format varies by type)
+        entityType:
+          type: string
+          enum: [collection, workspace, api, flow]
+        name:
+          type: string
+          description: Display name of the entity
+        summary:
+          type: string
+          description: Short summary
+        description:
+          type: string
+          description: Full description (may contain HTML/Markdown)
+        type:
+          type: string
+          enum: [public]
+        metrics:
+          type: array
+          items:
+            $ref: "#/components/schemas/Metric"
+        publisherType:
+          type: string
+          enum: [team, user]
+        publisherId:
+          type: integer
+        createdBy:
+          type: integer
+        createdAt:
+          type: string
+          format: date-time
+        updatedAt:
+          type: string
+          format: date-time
+        categories:
+          type: array
+          items:
+            $ref: "#/components/schemas/CategorySummary"
+        tags:
+          type: array
+          items:
+            type: string
+        meta:
+          type: object
+          properties:
+            publisherId:
+              type: string
+            workspaceId:
+              type: string
+            publisherType:
+              type: string
+            workspaceSlug:
+              type: string
+        redirectURL:
+          type: string
+          format: uri
+          description: URL to view this entity on postman.com
+        redirectURLV2:
+          type: string
+          format: uri
+          description: V2 URL with slug-based path
+
+    Metric:
+      type: object
+      properties:
+        metricName:
+          type: string
+          enum:
+            - forkCount
+            - monthForkCount
+            - monthViewCount
+            - monthWatchCount
+            - publicViewCount
+            - viewCount
+            - watchCount
+            - weekForkCount
+            - weekViewCount
+            - weekWatchCount
+        metricValue:
+          type: integer
+
+    CategorySummary:
+      type: object
+      properties:
+        id:
+          type: integer
+        slug:
+          type: string
+        name:
+          type: string
+        isDeleted:
+          type: boolean
+
+    Category:
+      type: object
+      properties:
+        id:
+          type: integer
+        name:
+          type: string
+        summary:
+          type: string
+        description:
+          type: string
+        heroImageURL:
+          type: string
+          format: uri
+        iconURL:
+          type: string
+          format: uri
+        slug:
+          type: string
+        createdAt:
+          type: string
+          format: date-time
+        updatedAt:
+          type: string
+          format: date-time
+        publicURL:
+          type: string
+          format: uri
+
+    Team:
+      type: object
+      properties:
+        id:
+          type: string
+        name:
+          type: string
+        description:
+          type: string
+        categories:
+          type: array
+          items:
+            $ref: "#/components/schemas/CategorySummary"
+        tags:
+          type: array
+          items:
+            type: string
+        profileURL:
+          type: string
+          format: uri
+        publicURL:
+          type: string
+          format: uri
+        createdAt:
+          type: string
+          format: date-time
+        updatedAt:
+          type: string
+          format: date-time
+        metrics:
+          type: array
+          items:
+            type: object
+            properties:
+              team:
+                type: string
+              viewCount:
+                type: integer
+              teamPublisherScore:
+                type: integer
+              apinetworkentityCount:
+                type: integer
+              collectionCount:
+                type: integer
+              apiCount:
+                type: integer
+              workspaceCount:
+                type: integer
+
+    SearchRequest:
+      type: object
+      required: [queryText, size, queryIndices]
+      properties:
+        queryText:
+          type: string
+          description: Search query text
+        size:
+          type: integer
+          description: Number of results to return
+          default: 10
+        from:
+          type: integer
+          description: Pagination offset
+          default: 0
+        domain:
+          type: string
+          description: Search domain scope
+          enum: [all, public]
+          default: public
+        queryIndices:
+          type: array
+          description: Entity types to search across
+          items:
+            type: string
+            enum:
+              - runtime.collection
+              - collaboration.workspace
+              - runtime.request
+              - flow.flow
+              - apinetwork.team
+        mergeEntities:
+          type: boolean
+          default: true
+        nested:
+          type: boolean
+          default: false
+        nonNestedRequests:
+          type: boolean
+          default: true
+        filter:
+          type: object
+          description: Additional filters (empty object for no filters)
+
+    SearchResult:
+      type: object
+      properties:
+        score:
+          type: number
+          description: Relevance score
+        normalizedScore:
+          type: number
+          description: Normalized relevance score
+        document:
+          type: object
+          properties:
+            id:
+              type: string
+              description: Entity ID
+            name:
+              type: string
+            summary:
+              type: string
+            entityType:
+              type: string
+              enum: [collection, workspace, request, flow, team]
+            documentType:
+              type: string
+            isPublic:
+              type: boolean
+            isPublisherVerified:
+              type: boolean
+            publisherName:
+              type: string
+            publisherHandle:
+              type: string
+            publisherType:
+              type: string
+              enum: [team, user]
+            publisherLogo:
+              type: string
+            categories:
+              type: array
+              items:
+                type: object
+            tags:
+              type: array
+              items:
+                type: string
+            views:
+              type: integer
+            forkCount:
+              type: integer
+            watcherCount:
+              type: integer
+            requestCount:
+              type: integer
+            workspaces:
+              type: array
+              items:
+                type: object
+                properties:
+                  id:
+                    type: string
+                  name:
+                    type: string
+                  slug:
+                    type: string
+                  visibilityStatus:
+                    type: string
+
+    NetworkEntityListResponse:
+      type: object
+      properties:
+        data:
+          type: array
+          items:
+            $ref: "#/components/schemas/NetworkEntity"
+        meta:
+          type: object
+          properties:
+            limit:
+              type: integer
+            offset:
+              type: integer
+            count:
+              type: integer
+            totalCount:
+              type: integer
+            publisherInfo:
+              type: object
+              description: Map of publisher type to array of publisher details
+            model:
+              type: string
+            action:
+              type: string
+
+    EntityCountResponse:
+      type: object
+      properties:
+        data:
+          type: object
+          properties:
+            apiCount:
+              type: integer
+            collectionCount:
+              type: integer
+            flowCount:
+              type: integer
+            notebookCount:
+              type: integer
+            workspaceCount:
+              type: integer
+            teamCount:
+              type: integer
+        meta:
+          type: object
+
+    CategoryListResponse:
+      type: object
+      properties:
+        data:
+          type: array
+          items:
+            $ref: "#/components/schemas/Category"
+
+    CategoryDetailResponse:
+      type: object
+      properties:
+        data:
+          $ref: "#/components/schemas/Category"
+
+    TeamListResponse:
+      type: object
+      properties:
+        data:
+          type: array
+          items:
+            $ref: "#/components/schemas/Team"
+
+    SearchResponse:
+      type: object
+      properties:
+        data:
+          type: array
+          items:
+            $ref: "#/components/schemas/SearchResult"
+        meta:
+          type: object
+          properties:
+            total:
+              type: integer
+            nextCursor:
+              type: string
+
+    ErrorResponse:
+      type: object
+      properties:
+        name:
+          type: string
+        message:
+          type: string
+
+    RateLimitError:
+      type: object
+      properties:
+        error:
+          type: string
+          enum: [rateLimited]
+        message:
+          type: string
diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go
index 57d76c38..b5f69edf 100644
--- a/internal/catalog/catalog.go
+++ b/internal/catalog/catalog.go
@@ -43,6 +43,19 @@ var validTiers = map[string]struct{}{
 	"community": {},
 }
 
+var validSpecSources = map[string]struct{}{
+	"official":  {}, // Published by API vendor (Stripe, GitHub, Discord)
+	"community": {}, // Third-party maintained (apis-guru, community OpenAPI repos)
+	"sniffed":   {}, // Reverse-engineered from browser traffic capture
+	"docs":      {}, // Generated from documentation pages (--docs mode)
+}
+
+var validClientPatterns = map[string]struct{}{
+	"rest":           {}, // Standard REST — default, no special client needed
+	"proxy-envelope": {}, // All requests wrapped in a POST envelope (e.g., Postman _api/ws/proxy)
+	"graphql":        {}, // GraphQL endpoint, needs query/mutation wrapper
+}
+
 type KnownAlt struct {
 	Name     string `yaml:"name"`
 	URL      string `yaml:"url"`
@@ -63,6 +76,14 @@ type Entry struct {
 	Notes             string     `yaml:"notes"`
 	KnownAlternatives []KnownAlt `yaml:"known_alternatives,omitempty"`
 	SandboxEndpoint   string     `yaml:"sandbox_endpoint,omitempty"`
+	// SpecSource describes how the spec was obtained. Empty defaults to "official".
+	// Values: official, community, sniffed, docs.
+	SpecSource string `yaml:"spec_source,omitempty"`
+	// AuthRequired indicates whether the API needs authentication. Empty means unknown.
+	AuthRequired *bool `yaml:"auth_required,omitempty"`
+	// ClientPattern describes the HTTP client pattern needed. Empty defaults to "rest".
+	// Values: rest, proxy-envelope, graphql.
+	ClientPattern string `yaml:"client_pattern,omitempty"`
 }
 
 func ParseEntry(data []byte) (*Entry, error) {
@@ -164,6 +185,18 @@ func (e *Entry) Validate() error {
 		return fmt.Errorf("tier must be one of: official, community")
 	}
 
+	if e.SpecSource != "" {
+		if _, ok := validSpecSources[e.SpecSource]; !ok {
+			return fmt.Errorf("spec_source must be one of: official, community, sniffed, docs")
+		}
+	}
+
+	if e.ClientPattern != "" {
+		if _, ok := validClientPatterns[e.ClientPattern]; !ok {
+			return fmt.Errorf("client_pattern must be one of: rest, proxy-envelope, graphql")
+		}
+	}
+
 	return nil
 }
 
diff --git a/internal/catalog/catalog_test.go b/internal/catalog/catalog_test.go
index 3e5b8e64..e2b2caf3 100644
--- a/internal/catalog/catalog_test.go
+++ b/internal/catalog/catalog_test.go
@@ -112,6 +112,20 @@ func TestValidateEntry(t *testing.T) {
 			},
 			wantErr: "description is required",
 		},
+		{
+			name: "invalid spec_source",
+			mutate: func(e *Entry) {
+				e.SpecSource = "guessed"
+			},
+			wantErr: "spec_source must be one of",
+		},
+		{
+			name: "invalid client_pattern",
+			mutate: func(e *Entry) {
+				e.ClientPattern = "soap"
+			},
+			wantErr: "client_pattern must be one of",
+		},
 	}
 
 	for _, tt := range tests {
@@ -198,6 +212,40 @@ func TestCategoryErrorMessageExcludesExample(t *testing.T) {
 	assert.NotContains(t, err.Error(), "example")
 }
 
+func TestSniffedEntryValid(t *testing.T) {
+	f := false
+	entry := Entry{
+		Name:          "test-sniffed",
+		DisplayName:   "Test Sniffed API",
+		Description:   "A sniffed catalog entry",
+		Category:      "developer-tools",
+		SpecURL:       "https://example.com/specs/sniffed.yaml",
+		SpecFormat:    "yaml",
+		Tier:          "community",
+		SpecSource:    "sniffed",
+		AuthRequired:  &f,
+		ClientPattern: "proxy-envelope",
+	}
+	assert.NoError(t, entry.Validate())
+}
+
+func TestOptionalFieldsOmittedValid(t *testing.T) {
+	// spec_source, auth_required, and client_pattern should all be optional
+	entry := Entry{
+		Name:        "test-minimal",
+		DisplayName: "Minimal API",
+		Description: "A minimal catalog entry without new fields",
+		Category:    "developer-tools",
+		SpecURL:     "https://example.com/openapi.yaml",
+		SpecFormat:  "yaml",
+		Tier:        "official",
+	}
+	assert.NoError(t, entry.Validate())
+	assert.Empty(t, entry.SpecSource)
+	assert.Nil(t, entry.AuthRequired)
+	assert.Empty(t, entry.ClientPattern)
+}
+
 func TestPublicCategoriesExcludeExample(t *testing.T) {
 	categories := PublicCategories()
 	assert.NotContains(t, categories, "example")
diff --git a/internal/cli/catalog.go b/internal/cli/catalog.go
index d5d89946..ded7f735 100644
--- a/internal/cli/catalog.go
+++ b/internal/cli/catalog.go
@@ -116,6 +116,15 @@ func newCatalogShowCmd() *cobra.Command {
 			if entry.Homepage != "" {
 				fmt.Printf("Homepage:       %s\n", entry.Homepage)
 			}
+			if entry.SpecSource != "" {
+				fmt.Printf("Spec Source:    %s\n", entry.SpecSource)
+			}
+			if entry.ClientPattern != "" {
+				fmt.Printf("Client Pattern: %s\n", entry.ClientPattern)
+			}
+			if entry.AuthRequired != nil {
+				fmt.Printf("Auth Required:  %v\n", *entry.AuthRequired)
+			}
 			if entry.Notes != "" {
 				fmt.Printf("Notes:          %s\n", entry.Notes)
 			}

← 283ab9bf feat(cli): add smart-default output format to generator temp  ·  back to Cli Printing Press  ·  feat(cli): add adaptive rate limiting for sniffed APIs (#62) e26505e5 →