← 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
A catalog/postman-explore.yamlA catalog/specs/postman-explore-spec.yamlM internal/catalog/catalog.goM internal/catalog/catalog_test.goM internal/cli/catalog.go
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 →