← back to Cli Printing Press
feat(cli): add proxy-envelope client pattern to generator (#65)
f0bb0de244f7e61d43523430b8641ad7180bf4a3 · 2026-03-29 21:57:44 -0700 · Trevin Chow
When --client-pattern proxy-envelope is passed to `printing-press generate`,
the generated client wraps all requests in a POST envelope with {service,
method, path, body?} instead of standard REST URL construction. This
eliminates the single most expensive manual customization for sniffed APIs.
- Add ClientPattern and ProxyRoutes fields to spec.APISpec
- Extract x-proxy-routes from OpenAPI info.extensions in the parser
- Add --client-pattern flag (rest, proxy-envelope) to generate command
- Template conditional in client.go.tmpl emits proxy-wrapping do() method
with serviceForPath() routing and buildProxyPath() helper
- Shared retry loop, rate limiter, cache, and error handling across both
REST and proxy-envelope paths
- Add proxy pattern detection heuristic to SKILL.md sniff gate
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Files touched
A docs/brainstorms/2026-03-29-client-pattern-proxy-envelope-requirements.mdA docs/plans/2026-03-29-003-feat-proxy-envelope-client-pattern-plan.mdM internal/cli/root.goM internal/generator/templates/client.go.tmplM internal/openapi/parser.goM internal/spec/spec.goM skills/printing-press/SKILL.md
Diff
commit f0bb0de244f7e61d43523430b8641ad7180bf4a3
Author: Trevin Chow <trevin@trevinchow.com>
Date: Sun Mar 29 21:57:44 2026 -0700
feat(cli): add proxy-envelope client pattern to generator (#65)
When --client-pattern proxy-envelope is passed to `printing-press generate`,
the generated client wraps all requests in a POST envelope with {service,
method, path, body?} instead of standard REST URL construction. This
eliminates the single most expensive manual customization for sniffed APIs.
- Add ClientPattern and ProxyRoutes fields to spec.APISpec
- Extract x-proxy-routes from OpenAPI info.extensions in the parser
- Add --client-pattern flag (rest, proxy-envelope) to generate command
- Template conditional in client.go.tmpl emits proxy-wrapping do() method
with serviceForPath() routing and buildProxyPath() helper
- Shared retry loop, rate limiter, cache, and error handling across both
REST and proxy-envelope paths
- Add proxy pattern detection heuristic to SKILL.md sniff gate
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---
...9-client-pattern-proxy-envelope-requirements.md | 74 ++++++
...-003-feat-proxy-envelope-client-pattern-plan.md | 252 +++++++++++++++++++++
internal/cli/root.go | 18 ++
internal/generator/templates/client.go.tmpl | 146 +++++++++---
internal/openapi/parser.go | 16 ++
internal/spec/spec.go | 24 +-
skills/printing-press/SKILL.md | 24 ++
7 files changed, 516 insertions(+), 38 deletions(-)
diff --git a/docs/brainstorms/2026-03-29-client-pattern-proxy-envelope-requirements.md b/docs/brainstorms/2026-03-29-client-pattern-proxy-envelope-requirements.md
new file mode 100644
index 00000000..c9fdc80d
--- /dev/null
+++ b/docs/brainstorms/2026-03-29-client-pattern-proxy-envelope-requirements.md
@@ -0,0 +1,74 @@
+---
+date: 2026-03-29
+topic: client-pattern-proxy-envelope
+---
+
+# Generator Support for Proxy-Envelope Client Pattern
+
+## Problem Frame
+
+When generating CLIs for sniffed APIs that use a proxy pattern (all requests POST'd to a single URL with a `{service, method, path, body?}` envelope), the generator produces a standard REST client that doesn't work. The skill must then manually rewrite `do()` in the generated `client.go` — a ~50-line change that must preserve rate limiter integration, retry logic, and caching. This customization had to be re-applied 3 times during the postman-explore-pp-cli build (once per regeneration cycle), making it the single most expensive post-generation fix.
+
+The `client_pattern` field already exists in the catalog schema (PR #61) with `proxy-envelope` as a valid value, but the generator ignores it completely.
+
+## Requirements
+
+**Generator Template Selection**
+
+- R1. When `spec.APISpec.ClientPattern` is `"proxy-envelope"`, the generator emits a proxy-wrapping `do()` method in `client.go` instead of the standard REST `do()` method
+- R2. The proxy `do()` always POST's to `c.BaseURL` with a JSON envelope containing `service`, `method` (the logical HTTP method), `path` (with query params inlined), and `body` (for POST/PUT/PATCH requests)
+- R3. All existing client features work unchanged with the proxy pattern: adaptive rate limiter, retry-on-429, exponential backoff on 5xx, caching for GET, dry-run display
+- R4. Auth methods (`authHeader`, `refreshAccessToken`) are still emitted when the spec defines auth. Proxy-envelope does not imply no-auth — that's a separate concern controlled by the spec's `auth` section
+
+**Service Routing**
+
+- R5. Default: all paths route to a single service named after the API slug (e.g., `"postman-explore"`)
+- R6. Override: if the spec contains an `x-proxy-routes` extension (a map of path prefixes to service names), the generated client uses it for routing
+- R7. The `x-proxy-routes` extension is optional. When absent, the single-service default applies
+
+**Plumbing**
+
+- R8. Add `ClientPattern string` field to `spec.APISpec` (alongside the existing `SpecSource`)
+- R9. The generate CLI command sets `ClientPattern` from a new `--client-pattern` flag or from the catalog entry when generating from a catalog API
+- R10. The `client.go.tmpl` template uses `{{if eq .ClientPattern "proxy-envelope"}}` conditionals around the `do()` method and proxy-specific types
+
+**Skill Integration**
+
+- R11. During the sniff phase, when the skill discovers a proxy pattern (repeated calls to a single URL with envelope bodies), it writes `x-proxy-routes` into the generated spec
+- R12. The skill's Phase 2 generate commands for sniff-based CLIs include `--client-pattern proxy-envelope`
+
+## Success Criteria
+
+- A `printing-press generate --spec <sniffed-spec> --client-pattern proxy-envelope` produces a CLI that works against the proxy API without any manual client.go modifications
+- Regenerating the postman-explore-pp-cli with the proxy spec and `--client-pattern proxy-envelope` produces a working CLI where only UX customizations (top-level aliases, store tables) are needed — the client itself works out of the box
+- Standard REST APIs are unaffected — `client_pattern` empty or `"rest"` produces the same `client.go` as today
+
+## Scope Boundaries
+
+- Proxy-envelope pattern only — no GraphQL client pattern in this work
+- No changes to the catalog schema (already has `client_pattern`)
+- No envelope shape configurability — the `{service, method, path, body?}` shape is hardcoded. If a future proxy API uses a different envelope shape, that's a separate enhancement
+- The skill's sniff phase improvements (auto-detecting proxy patterns, writing x-proxy-routes) are included but lightweight — the skill already knows how to identify proxy patterns from the postman-explore experience
+
+## Key Decisions
+
+- **Convention + override for service routing**: Default single-service (API slug) covers most cases. `x-proxy-routes` handles multi-service APIs like Postman. No catalog schema changes needed.
+- **Template conditional, not separate template file**: One `client.go.tmpl` with `{{if eq .ClientPattern "proxy-envelope"}}` blocks. The proxy `do()` is different enough to warrant separate blocks but similar enough (same retry/cache/rate-limit integration) that a completely separate template would duplicate too much.
+- **Proxy URL comes from existing `servers` field**: No new field needed. The spec's server URL is the proxy endpoint.
+
+## Dependencies / Assumptions
+
+- `spec.APISpec` already has `SpecSource` (PR #62). `ClientPattern` follows the same pattern.
+- The catalog schema already has `client_pattern` with `proxy-envelope` as a valid value (PR #61).
+- The `x-proxy-routes` OpenAPI extension is a custom extension — it's our convention, not a standard. That's fine for a generator-specific feature.
+
+## Outstanding Questions
+
+### Deferred to Planning
+- [Affects R10][Technical] How much of the proxy `do()` can share code with the REST `do()`? The retry loop, rate limiter, and cache are identical — only the request building differs. The planner should determine whether to extract a shared `executeWithRetry()` helper or duplicate the loop with different request construction.
+- [Affects R6][Needs research] What does `x-proxy-routes` look like in YAML? The planner should design the extension schema (e.g., `x-proxy-routes: {"/v1/api/": "publishing", "/search-all": "search"}`).
+- [Affects R11][Needs research] How does the skill detect the proxy pattern during sniffing? Currently it's manual observation ("all calls go to _api/ws/proxy"). Can we formalize this as a heuristic (e.g., all captured URLs resolve to the same path)?
+
+## Next Steps
+
+→ `/ce:plan` for structured implementation planning
diff --git a/docs/plans/2026-03-29-003-feat-proxy-envelope-client-pattern-plan.md b/docs/plans/2026-03-29-003-feat-proxy-envelope-client-pattern-plan.md
new file mode 100644
index 00000000..2bdffe06
--- /dev/null
+++ b/docs/plans/2026-03-29-003-feat-proxy-envelope-client-pattern-plan.md
@@ -0,0 +1,252 @@
+---
+title: "feat: Generator support for proxy-envelope client pattern"
+type: feat
+status: active
+date: 2026-03-29
+origin: docs/brainstorms/2026-03-29-client-pattern-proxy-envelope-requirements.md
+---
+
+# feat: Generator Support for Proxy-Envelope Client Pattern
+
+## Overview
+
+Teach the printing-press generator to emit a proxy-wrapping HTTP client when `client_pattern: proxy-envelope` is set. Instead of building `base_url + path` URLs, the generated client wraps all requests as `POST base_url` with a `{service, method, path, body?}` JSON envelope. This eliminates the single most expensive manual customization needed for sniffed APIs.
+
+## Problem Frame
+
+During the postman-explore-pp-cli build, the proxy client wrapper had to be re-applied 3 times across regeneration cycles — each time carefully preserving rate limiter integration, retry logic, and caching. The `client_pattern` field exists in the catalog schema (PR #61) but the generator ignores it. (see origin: docs/brainstorms/2026-03-29-client-pattern-proxy-envelope-requirements.md)
+
+## Requirements Trace
+
+- R1. Proxy-wrapping `do()` when `ClientPattern == "proxy-envelope"`
+- R2. Envelope: `{service, method, path, body?}` POST'd to `BaseURL`
+- R3. Rate limiter, retry, cache, dry-run unchanged
+- R4. Auth still emitted when spec defines it
+- R5-R7. Service routing: default single-service (API slug), override via `x-proxy-routes`
+- R8-R9. `ClientPattern` on APISpec, set via `--client-pattern` flag
+- R10. Template conditional in `client.go.tmpl`
+- R11-R12. Skill integration for sniff detection and generate flags
+
+## Scope Boundaries
+
+- Proxy-envelope only — no GraphQL pattern
+- No envelope shape configurability — `{service, method, path, body?}` is hardcoded
+- No catalog schema changes (already has `client_pattern`)
+- Skill sniff detection is heuristic guidance, not automated detection
+
+## Context & Research
+
+### Relevant Code and Patterns
+
+- `internal/generator/templates/client.go.tmpl` — The `do()` method has clearly separable sections: request construction (lines 231-268) is independent from the retry loop (lines 224-318). The proxy conditional wraps only the request construction, sharing all retry/cache/rate-limiter logic.
+- `internal/openapi/parser.go:140-148` — Existing pattern for extracting OpenAPI extensions (`x-api-name` from `doc.Info.Extensions`). The `x-proxy-routes` extension follows this exact pattern.
+- `internal/spec/spec.go` — `SpecSource` field was added in PR #62 following the same pattern `ClientPattern` will follow.
+- `internal/generator/generator.go:123` — Templates receive `*spec.APISpec` directly via `renderTemplate(tmplName, outPath, g.Spec)`. New fields on APISpec are automatically accessible in templates.
+- `internal/cli/root.go:322` — `--spec-source` flag pattern to follow for `--client-pattern`.
+
+### Institutional Learnings
+
+- `docs/solutions/best-practices/adaptive-rate-limiting-sniffed-apis.md` — Documents the adaptive rate limiter that the proxy client must preserve. The learning confirms that the proxy pattern requires the rate limiter integration to stay intact.
+
+## Key Technical Decisions
+
+- **Template conditional, not separate template file**: The proxy `do()` differs only in request construction (~15 lines). The retry loop, rate limiter calls, response handling, and error classification are identical. A `{{if eq .ClientPattern "proxy-envelope"}}` block wrapping the request construction section avoids duplicating ~100 lines of shared logic. (see origin)
+- **Shared retry loop with strategy injection**: Rather than duplicating the entire `do()` method, the template emits a `buildRequest()` helper that returns `*http.Request`. The `do()` loop calls `buildRequest()` then proceeds with shared retry logic. The conditional controls which `buildRequest()` is emitted.
+- **x-proxy-routes in spec info.extensions**: Follows the existing `x-api-name` pattern. Parsed during OpenAPI loading, stored on APISpec as a `map[string]string`. When absent, a default route maps all paths to the API name slug.
+- **`--client-pattern` flag validated same as `--spec-source`**: Allowed values: `rest`, `proxy-envelope`. Invalid values rejected with a clear error message.
+
+## Open Questions
+
+### Resolved During Planning
+
+- **How much of `do()` can be shared?** The request construction section (lines 231-268) is the only divergent part. The template emits a `buildRequest()` helper selected by `ClientPattern`. The rest of `do()` is shared.
+- **x-proxy-routes schema:** `info.extensions["x-proxy-routes"]` as a `map[string]string` mapping path prefixes to service names. Parsed in `openapi/parser.go` alongside `x-api-name`. Stored on APISpec as `ProxyRoutes map[string]string`.
+- **Proxy pattern detection heuristic:** During sniff, if all captured Performance API URLs resolve to the same path AND intercepted fetch bodies contain `{service, method, path}` keys, it's a proxy-envelope. This is behavioral guidance in SKILL.md, not automated detection.
+
+### Deferred to Implementation
+
+- Exact placement of the `buildRequest()` helper in client.go.tmpl — depends on reading the current template structure at implementation time
+- Whether `x-proxy-routes` needs path prefix matching (longest match) or exact match — start with prefix match, simplify if it turns out to be unnecessary
+
+## High-Level Technical Design
+
+> *This illustrates the intended approach and is directional guidance for review, not implementation specification. The implementing agent should treat it as context, not code to reproduce.*
+
+```
+client.go.tmpl do() method structure:
+
+ func (c *Client) do(method, path, params, body) {
+ url = c.BaseURL + path // ← REST path
+ // OR
+ url = c.BaseURL // ← proxy-envelope (always same URL)
+
+ bodyBytes = marshal(body)
+
+ if c.DryRun { return dryRun(...) }
+
+ for attempt := 0..maxRetries {
+ c.limiter.Wait() // shared
+
+ req = buildRequest(...) // ← CONDITIONAL: REST vs proxy-envelope
+
+ resp = c.HTTPClient.Do(req) // shared
+
+ if resp < 400 { // shared
+ c.limiter.OnSuccess()
+ return resp
+ }
+ if resp == 429 { // shared
+ c.limiter.OnRateLimit()
+ sleep(retryAfter)
+ }
+ if resp >= 500 { ... } // shared
+ }
+ }
+
+ // REST buildRequest: standard HTTP method + URL + params + body
+ // Proxy buildRequest: POST + BaseURL + envelope{service, method, path+params, body}
+```
+
+## Implementation Units
+
+- [ ] **Unit 1: Add ClientPattern and ProxyRoutes to APISpec**
+
+**Goal:** Make `client_pattern` and `x-proxy-routes` available to templates.
+
+**Requirements:** R8
+
+**Dependencies:** None
+
+**Files:**
+- Modify: `internal/spec/spec.go`
+- Modify: `internal/openapi/parser.go`
+- Test: `internal/spec/spec_test.go`
+- Test: `internal/openapi/parser_test.go`
+
+**Approach:**
+- Add `ClientPattern string` and `ProxyRoutes map[string]string` to `APISpec`
+- In `openapi/parser.go`, extract `x-proxy-routes` from `doc.Info.Extensions` (follow the `x-api-name` pattern at line 140)
+- ProxyRoutes is a simple `map[string]string` where keys are path prefixes and values are service names
+
+**Patterns to follow:**
+- `SpecSource` field addition in PR #62
+- `x-api-name` extension extraction in `openapi/parser.go:140-148`
+
+**Test scenarios:**
+- Happy path: Spec with `x-proxy-routes: {"/v1/api/": "publishing", "/search-all": "search"}` → parsed APISpec has ProxyRoutes map with both entries
+- Happy path: Spec without `x-proxy-routes` → ProxyRoutes is nil/empty
+- Edge case: `x-proxy-routes` with wrong type (not a map) → gracefully ignored, ProxyRoutes stays nil
+
+**Verification:**
+- `go test ./internal/spec/... ./internal/openapi/...` passes
+- Template can access `{{.ClientPattern}}` and `{{.ProxyRoutes}}`
+
+- [ ] **Unit 2: Add --client-pattern flag to generate command**
+
+**Goal:** Users can set `ClientPattern` via CLI flag.
+
+**Requirements:** R9
+
+**Dependencies:** Unit 1
+
+**Files:**
+- Modify: `internal/cli/root.go`
+- Test: `internal/cli/root_test.go` (if exists)
+
+**Approach:**
+- Add `--client-pattern` flag (string, default empty)
+- Validate against `rest`, `proxy-envelope` (same pattern as `--spec-source` validation)
+- Set `apiSpec.ClientPattern` after parsing, in both the `--docs` and `--spec` paths
+- Empty string means "rest" (default behavior)
+
+**Patterns to follow:**
+- `--spec-source` flag at line 322 and validation at lines 226-233
+
+**Test scenarios:**
+- Happy path: `--client-pattern proxy-envelope` → apiSpec.ClientPattern set to "proxy-envelope"
+- Happy path: No flag → ClientPattern empty (REST default)
+- Error path: `--client-pattern soap` → exits with validation error naming allowed values
+
+**Verification:**
+- `printing-press generate --help` shows `--client-pattern` flag
+- Invalid values rejected with clear error
+
+- [ ] **Unit 3: Emit proxy-envelope client in client.go.tmpl**
+
+**Goal:** The template emits a proxy-wrapping `do()` method when `ClientPattern == "proxy-envelope"`.
+
+**Requirements:** R1, R2, R3, R4, R5, R6, R7, R10
+
+**Dependencies:** Unit 1, Unit 2
+
+**Files:**
+- Modify: `internal/generator/templates/client.go.tmpl`
+- Test: `internal/generator/generator_test.go`
+
+**Approach:**
+- Add `proxyEnvelope` struct and `serviceForPath()` helper inside a `{{if eq .ClientPattern "proxy-envelope"}}` block
+- `serviceForPath()` checks `ProxyRoutes` map (longest prefix match), falls back to API name slug
+- Replace the request construction section of `do()` with a conditional: proxy-envelope wraps in envelope and POST's to `BaseURL`; REST builds standard HTTP request (existing behavior)
+- Both paths share the retry loop, rate limiter, cache, and response handling
+- Dry-run output shows the envelope for proxy-envelope mode
+- MCP tools template (`mcp_tools.go.tmpl`) also needs the conditional for `newMCPClient()`
+
+**Patterns to follow:**
+- Existing `{{if eq .SpecSource "sniffed"}}` conditionals in `root.go.tmpl` and `mcp_tools.go.tmpl`
+- The hand-built proxy client in postman-explore-pp-cli's `client.go` (the proven implementation)
+
+**Test scenarios:**
+- Happy path: Generate from spec with `ClientPattern = "proxy-envelope"` → client.go contains `proxyEnvelope` struct and `serviceForPath()` function
+- Happy path: Generate from spec with `ClientPattern = ""` → client.go is identical to today's output (no proxy code)
+- Happy path: ProxyRoutes with 2 entries → `serviceForPath()` routes correctly by prefix
+- Edge case: Empty ProxyRoutes → `serviceForPath()` returns API name slug for all paths
+- Integration: Generated proxy-envelope CLI compiles and passes `go vet`
+
+**Verification:**
+- `go test ./internal/generator/...` passes (compilation tests verify generated code compiles)
+- A generated CLI with `--client-pattern proxy-envelope` has a working proxy `do()` method
+
+- [ ] **Unit 4: Update SKILL.md with proxy pattern detection and generate flags**
+
+**Goal:** The skill knows how to detect proxy patterns during sniffing and passes the right flags to generate.
+
+**Requirements:** R11, R12
+
+**Dependencies:** None (parallel with Units 1-3)
+
+**Files:**
+- Modify: `skills/printing-press/SKILL.md`
+
+**Approach:**
+- In the sniff gate section, add a "Proxy Pattern Detection" heuristic: if all captured XHR/fetch URLs resolve to the same path AND intercepted bodies contain `{service, method, path}` keys, flag it as proxy-envelope
+- When a proxy pattern is detected, the skill should: (a) write `x-proxy-routes` into the spec if multiple services are discovered, (b) pass `--client-pattern proxy-envelope` to the generate command
+- Update Phase 2 generate commands for sniff-based CLIs to include `--client-pattern proxy-envelope` when detected
+
+**Test scenarios:**
+Test expectation: none — skill instruction changes are behavioral guidance for Claude, not compiled code.
+
+**Verification:**
+- SKILL.md contains proxy pattern detection heuristic in sniff gate section
+- Phase 2 generate commands include `--client-pattern proxy-envelope` when appropriate
+
+## System-Wide Impact
+
+- **Template backward compatibility:** Empty `ClientPattern` produces identical output to today. No existing CLIs are affected.
+- **Generated CLI dependencies:** No new dependencies. The proxy envelope code uses stdlib only (same as the hand-built version).
+- **Catalog integration:** The catalog already has `client_pattern`. When the generate command learns to read from catalog entries, proxy-envelope will automatically propagate. This is a future enhancement, not required now.
+- **MCP server parity:** The `mcp_tools.go.tmpl` also constructs a client. It needs the same conditional to work with proxy-envelope APIs.
+
+## Risks & Dependencies
+
+| Risk | Mitigation |
+|------|------------|
+| Template conditional makes client.go.tmpl harder to read | The conditional wraps only the request construction section (~15 lines per branch), not the entire 100-line do() method. Code review will catch readability issues. |
+| Envelope shape assumption (`{service, method, path, body?}`) is too rigid | Scoped as a known limitation. The hardcoded shape matches the only proxy pattern we've encountered. Configurability is a future enhancement if needed. |
+| `x-proxy-routes` prefix matching gets complex | Start with simple iteration over the map. Longest-prefix-match is a well-understood algorithm. If the map has <10 entries (typical), performance is irrelevant. |
+
+## Sources & References
+
+- **Origin document:** [docs/brainstorms/2026-03-29-client-pattern-proxy-envelope-requirements.md](docs/brainstorms/2026-03-29-client-pattern-proxy-envelope-requirements.md)
+- Related code: `internal/generator/templates/client.go.tmpl` (do() method), `internal/openapi/parser.go` (x-api-name pattern)
+- Related PRs: #61 (catalog schema with client_pattern), #62 (adaptive rate limiter with SpecSource)
+- Related learning: `docs/solutions/best-practices/adaptive-rate-limiting-sniffed-apis.md`
diff --git a/internal/cli/root.go b/internal/cli/root.go
index 2974e316..01699954 100644
--- a/internal/cli/root.go
+++ b/internal/cli/root.go
@@ -66,6 +66,7 @@ func newGenerateCmd() *cobra.Command {
var asJSON bool
var dryRun bool
var specSource string
+ var clientPattern string
cmd := &cobra.Command{
Use: "generate",
@@ -126,6 +127,14 @@ func newGenerateCmd() *cobra.Command {
} else {
parsed.SpecSource = "docs"
}
+ if clientPattern != "" {
+ switch clientPattern {
+ case "rest", "proxy-envelope":
+ parsed.ClientPattern = clientPattern
+ default:
+ return &ExitError{Code: ExitInputError, Err: fmt.Errorf("--client-pattern must be one of: rest, proxy-envelope (got %q)", clientPattern)}
+ }
+ }
explicitOutput := outputDir != ""
if outputDir == "" {
@@ -231,6 +240,14 @@ func newGenerateCmd() *cobra.Command {
return &ExitError{Code: ExitInputError, Err: fmt.Errorf("--spec-source must be one of: official, community, sniffed, docs (got %q)", specSource)}
}
}
+ if clientPattern != "" {
+ switch clientPattern {
+ case "rest", "proxy-envelope":
+ apiSpec.ClientPattern = clientPattern
+ default:
+ return &ExitError{Code: ExitInputError, Err: fmt.Errorf("--client-pattern must be one of: rest, proxy-envelope (got %q)", clientPattern)}
+ }
+ }
explicitOutput := outputDir != ""
if outputDir == "" {
@@ -320,6 +337,7 @@ func newGenerateCmd() *cobra.Command {
cmd.Flags().BoolVar(&asJSON, "json", false, "Output as JSON")
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Parse spec and show what would be generated without writing files (remote specs are still fetched)")
cmd.Flags().StringVar(&specSource, "spec-source", "", "Spec provenance: official, community, sniffed, docs (affects generated client defaults like rate limiting)")
+ cmd.Flags().StringVar(&clientPattern, "client-pattern", "", "HTTP client pattern: rest (default), proxy-envelope (wraps requests in POST envelope)")
return cmd
}
diff --git a/internal/generator/templates/client.go.tmpl b/internal/generator/templates/client.go.tmpl
index 98c8e12e..c0f13c5f 100644
--- a/internal/generator/templates/client.go.tmpl
+++ b/internal/generator/templates/client.go.tmpl
@@ -117,6 +117,56 @@ func (l *adaptiveLimiter) Rate() float64 {
return l.rate
}
+{{if eq .ClientPattern "proxy-envelope"}}
+// proxyEnvelope is the JSON wrapper sent to the proxy endpoint.
+type proxyEnvelope struct {
+ Service string `json:"service"`
+ Method string `json:"method"`
+ Path string `json:"path"`
+ Body any `json:"body,omitempty"`
+}
+
+// serviceForPath determines the proxy service name from the API path.
+func serviceForPath(path string) string {
+{{- if .ProxyRoutes}}
+ // Route by longest matching prefix
+ bestMatch := ""
+ bestService := ""
+ for prefix, svc := range map[string]string{
+ {{- range $prefix, $svc := .ProxyRoutes}}
+ "{{$prefix}}": "{{$svc}}",
+ {{- end}}
+ } {
+ if strings.HasPrefix(path, prefix) && len(prefix) > len(bestMatch) {
+ bestMatch = prefix
+ bestService = svc
+ }
+ }
+ if bestService != "" {
+ return bestService
+ }
+{{- end}}
+ return "{{.Name}}" // default: API name slug
+}
+
+// buildProxyPath appends query params into the path string for the envelope.
+func buildProxyPath(path string, params map[string]string) string {
+ if len(params) == 0 {
+ return path
+ }
+ parts := make([]string, 0, len(params))
+ for k, v := range params {
+ if v != "" {
+ parts = append(parts, k+"="+v)
+ }
+ }
+ if len(parts) == 0 {
+ return path
+ }
+ return path + "?" + strings.Join(parts, "&")
+}
+{{end}}
+
// APIError carries HTTP status information for structured exit codes.
type APIError struct {
Method string
@@ -205,7 +255,12 @@ func (c *Client) Patch(path string, body any) (json.RawMessage, int, error) {
}
func (c *Client) do(method, path string, params map[string]string, body any) (json.RawMessage, int, error) {
- url := c.BaseURL + path
+{{- if eq .ClientPattern "proxy-envelope"}}
+ // Proxy-envelope: all requests are POST'd to BaseURL with a JSON envelope
+ targetURL := c.BaseURL
+{{- else}}
+ targetURL := c.BaseURL + path
+{{- end}}
var bodyBytes []byte
if body != nil {
@@ -218,7 +273,7 @@ func (c *Client) do(method, path string, params map[string]string, body any) (js
// Build the request for dry-run display or actual execution
if c.DryRun {
- return c.dryRun(method, url, params, bodyBytes)
+ return c.dryRun(method, targetURL, path, params, bodyBytes)
}
const maxRetries = 3
@@ -228,15 +283,50 @@ func (c *Client) do(method, path string, params map[string]string, body any) (js
// Proactive rate limiting — wait before sending
c.limiter.Wait()
+{{- if eq .ClientPattern "proxy-envelope"}}
+ // Build the proxy envelope
+ envelope := proxyEnvelope{
+ Service: serviceForPath(path),
+ Method: method,
+ Path: buildProxyPath(path, params),
+ }
+ if bodyBytes != nil {
+ envelope.Body = json.RawMessage(bodyBytes)
+ }
+ envelopeBytes, err := json.Marshal(envelope)
+ if err != nil {
+ return nil, 0, fmt.Errorf("marshaling proxy envelope: %w", err)
+ }
+
+ req, err := http.NewRequest("POST", targetURL, strings.NewReader(string(envelopeBytes)))
+ if err != nil {
+ return nil, 0, fmt.Errorf("creating request: %w", err)
+ }
+ req.Header.Set("Content-Type", "application/json")
+{{- else}}
var bodyReader io.Reader
if bodyBytes != nil {
bodyReader = strings.NewReader(string(bodyBytes))
}
- req, err := http.NewRequest(method, url, bodyReader)
+ req, err := http.NewRequest(method, targetURL, bodyReader)
if err != nil {
return nil, 0, fmt.Errorf("creating request: %w", err)
}
+ if bodyBytes != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+
+ if params != nil {
+ q := req.URL.Query()
+ for k, v := range params {
+ if v != "" {
+ q.Set(k, v)
+ }
+ }
+ req.URL.RawQuery = q.Encode()
+ }
+{{- end}}
authHeader, err := c.authHeader()
if err != nil {
@@ -252,21 +342,8 @@ func (c *Client) do(method, path string, params map[string]string, body any) (js
req.Header.Set("{{if .Auth.Header}}{{.Auth.Header}}{{else}}Authorization{{end}}", authHeader)
{{- end}}
}
- if bodyBytes != nil {
- req.Header.Set("Content-Type", "application/json")
- }
req.Header.Set("User-Agent", "{{.Name}}-pp-cli/{{.Version}}")
- if params != nil {
- q := req.URL.Query()
- for k, v := range params {
- if v != "" {
- q.Set(k, v)
- }
- }
- req.URL.RawQuery = q.Encode()
- }
-
resp, err := c.HTTPClient.Do(req)
if err != nil {
lastErr = fmt.Errorf("%s %s: %w", method, path, err)
@@ -318,8 +395,22 @@ func (c *Client) do(method, path string, params map[string]string, body any) (js
return nil, 0, lastErr
}
-func (c *Client) dryRun(method, url string, params map[string]string, body []byte) (json.RawMessage, int, error) {
- fmt.Fprintf(os.Stderr, "%s %s\n", method, url)
+func (c *Client) dryRun(method, targetURL, path string, params map[string]string, body []byte) (json.RawMessage, int, error) {
+{{- if eq .ClientPattern "proxy-envelope"}}
+ // Show the proxy envelope that would be sent
+ envelope := proxyEnvelope{
+ Service: serviceForPath(path),
+ Method: method,
+ Path: buildProxyPath(path, params),
+ }
+ if body != nil {
+ envelope.Body = json.RawMessage(body)
+ }
+ fmt.Fprintf(os.Stderr, "POST %s\n", targetURL)
+ envelopeJSON, _ := json.MarshalIndent(envelope, " ", " ")
+ fmt.Fprintf(os.Stderr, " Envelope:\n %s\n", string(envelopeJSON))
+{{- else}}
+ fmt.Fprintf(os.Stderr, "%s %s\n", method, targetURL)
if params != nil {
for k, v := range params {
if v != "" {
@@ -327,6 +418,16 @@ func (c *Client) dryRun(method, url string, params map[string]string, body []byt
}
}
}
+ if body != nil {
+ var pretty json.RawMessage
+ if json.Unmarshal(body, &pretty) == nil {
+ enc := json.NewEncoder(os.Stderr)
+ enc.SetIndent(" ", " ")
+ fmt.Fprintf(os.Stderr, " Body:\n")
+ enc.Encode(pretty)
+ }
+ }
+{{- end}}
authHeader, err := c.authHeader()
if err != nil {
return nil, 0, err
@@ -339,15 +440,6 @@ func (c *Client) dryRun(method, url string, params map[string]string, body []byt
}
fmt.Fprintf(os.Stderr, " Authorization: %s\n", auth)
}
- if body != nil {
- var pretty json.RawMessage
- if json.Unmarshal(body, &pretty) == nil {
- enc := json.NewEncoder(os.Stderr)
- enc.SetIndent(" ", " ")
- fmt.Fprintf(os.Stderr, " Body:\n")
- enc.Encode(pretty)
- }
- }
fmt.Fprintf(os.Stderr, "\n(dry run - no request sent)\n")
return json.RawMessage(`{"dry_run": true}`), 0, nil
}
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index 74ab49d7..74731c38 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -150,6 +150,21 @@ func parse(data []byte, lenient bool) (*spec.APISpec, error) {
version = strings.TrimSpace(doc.Info.Version)
}
+ // Extract x-proxy-routes extension for proxy-envelope client pattern
+ var proxyRoutes map[string]string
+ if doc.Info != nil && doc.Info.Extensions != nil {
+ if raw, ok := doc.Info.Extensions["x-proxy-routes"]; ok {
+ if m, ok := raw.(map[string]interface{}); ok {
+ proxyRoutes = make(map[string]string, len(m))
+ for k, v := range m {
+ if s, ok := v.(string); ok {
+ proxyRoutes[k] = s
+ }
+ }
+ }
+ }
+ }
+
baseURL := ""
basePath := ""
if len(doc.Servers) > 0 && doc.Servers[0] != nil {
@@ -198,6 +213,7 @@ func parse(data []byte, lenient bool) (*spec.APISpec, error) {
Version: version,
BaseURL: baseURL,
BasePath: basePath,
+ ProxyRoutes: proxyRoutes,
Auth: mapAuth(doc, name),
Config: spec.ConfigSpec{
Format: "toml",
diff --git a/internal/spec/spec.go b/internal/spec/spec.go
index d14fbfbf..bda4a3b1 100644
--- a/internal/spec/spec.go
+++ b/internal/spec/spec.go
@@ -8,17 +8,19 @@ import (
)
type APISpec struct {
- Name string `yaml:"name"`
- Description string `yaml:"description"`
- Version string `yaml:"version"`
- BaseURL string `yaml:"base_url"`
- BasePath string `yaml:"base_path,omitempty"`
- Owner string `yaml:"owner,omitempty"` // GitHub owner for import paths and Homebrew tap
- SpecSource string `yaml:"spec_source,omitempty"` // official, community, sniffed, docs — affects generated client defaults
- Auth AuthConfig `yaml:"auth"`
- Config ConfigSpec `yaml:"config"`
- Resources map[string]Resource `yaml:"resources"`
- Types map[string]TypeDef `yaml:"types"`
+ Name string `yaml:"name"`
+ Description string `yaml:"description"`
+ Version string `yaml:"version"`
+ BaseURL string `yaml:"base_url"`
+ BasePath string `yaml:"base_path,omitempty"`
+ Owner string `yaml:"owner,omitempty"` // GitHub owner for import paths and Homebrew tap
+ SpecSource string `yaml:"spec_source,omitempty"` // official, community, sniffed, docs — affects generated client defaults
+ ClientPattern string `yaml:"client_pattern,omitempty"` // rest (default), proxy-envelope — affects generated HTTP client
+ ProxyRoutes map[string]string `yaml:"proxy_routes,omitempty"` // path prefix → service name for proxy-envelope routing
+ Auth AuthConfig `yaml:"auth"`
+ Config ConfigSpec `yaml:"config"`
+ Resources map[string]Resource `yaml:"resources"`
+ Types map[string]TypeDef `yaml:"types"`
}
type AuthConfig struct {
diff --git a/skills/printing-press/SKILL.md b/skills/printing-press/SKILL.md
index d4828ad5..bcd9ccde 100644
--- a/skills/printing-press/SKILL.md
+++ b/skills/printing-press/SKILL.md
@@ -413,6 +413,26 @@ When making API calls during sniff (browser-use eval, fetch, or direct HTTP requ
Track the current delay mentally. Report the effective rate when summarizing sniff results: "Sniffed N endpoints at ~X req/s effective rate."
+#### Proxy Pattern Detection
+
+After capturing API traffic, check if the API uses a proxy-envelope pattern:
+
+1. **Same-URL signal**: If all captured XHR/fetch URLs resolve to the same path (e.g., all calls go to `_api/ws/proxy`), the API likely uses a proxy pattern
+2. **Envelope signal**: If intercepted request bodies contain `service`, `method`, and `path` keys (or similar routing fields), it's a proxy-envelope
+3. **Confirmation**: If both signals are present, classify as `client_pattern: proxy-envelope`
+
+When a proxy pattern is detected:
+- Note the proxy URL (it becomes the spec's `servers[0].url`)
+- Extract the service routing from request bodies — build an `x-proxy-routes` map of path prefixes to service names
+- Write `x-proxy-routes` into the generated spec's `info` extensions:
+ ```yaml
+ info:
+ x-proxy-routes:
+ /v1/api/: publishing
+ /search-all: search
+ ```
+- Pass `--client-pattern proxy-envelope` to the generate command in Phase 2
+
#### Step 1: Detect capture tools
Check which browser automation tools are available:
@@ -775,6 +795,8 @@ printing-press generate \
--output "$PRESS_LIBRARY/<api>-pp-cli" \
--spec-source sniffed \
--force --lenient --validate
+# If proxy pattern was detected during sniff, add:
+# --client-pattern proxy-envelope
```
Sniff-only (no original spec, sniff was the primary source):
@@ -785,6 +807,8 @@ printing-press generate \
--output "$PRESS_LIBRARY/<api>-pp-cli" \
--spec-source sniffed \
--force --lenient --validate
+# If proxy pattern was detected during sniff, add:
+# --client-pattern proxy-envelope
```
Docs-only:
← 244c4845 feat(cli): use local module path at generation, rewrite at p
·
back to Cli Printing Press
·
fix(cli): add smart-default table output for POST endpoints 797049f5 →