[object Object]

← back to Cli Printing Press

fix(cli): emit default Accept */* header in generated HTTP clients (#1112)

c626efca9403e829bafbee3c160ce7354c47d3f4 · 2026-05-11 13:56:58 -0700 · Trevin Chow

* fix(cli): emit default Accept */* header in generated HTTP clients

Go's net/http omits Accept by default; browsers, curl, and other stdlibs
always send it. Fingerprint-checking WAFs (Imperva, Akamai, Cloudflare
bot-mode, DataDome) flag the absence as a bot signal and answer with
empty-body HTTP 500s, breaking every reverse-engineered consumer API a
printed CLI tries to talk to.

The generator now mirrors the existing User-Agent default in
client.go.tmpl's do() method: an if-empty guard sets Accept to "*/*"
only when nothing earlier in the request pipeline already set it.
Per-endpoint headerOverrides, spec-declared RequiredHeaders, and
content-negotiated overrides all win because they run before the guard.
The fallback is gated by UsesBrowserManagedUserAgent so surf's Chrome
impersonation transports keep managing the full header bundle.

Closes #920

* docs(cli): broaden WAF response-code description in Accept-default comment

Greptile review flagged that the inline comment overspecified the
WAF response code. The Resy case (issue #920 origin) actually
returned empty-body HTTP 500, but Imperva, Akamai, Cloudflare
bot-mode, and DataDome more commonly return 403 or a JS-challenge
redirect depending on vendor and rule tier. The new wording covers
all three so future readers tracing the guard's rationale aren't
misled.

Files touched

Diff

commit c626efca9403e829bafbee3c160ce7354c47d3f4
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Mon May 11 13:56:58 2026 -0700

    fix(cli): emit default Accept */* header in generated HTTP clients (#1112)
    
    * fix(cli): emit default Accept */* header in generated HTTP clients
    
    Go's net/http omits Accept by default; browsers, curl, and other stdlibs
    always send it. Fingerprint-checking WAFs (Imperva, Akamai, Cloudflare
    bot-mode, DataDome) flag the absence as a bot signal and answer with
    empty-body HTTP 500s, breaking every reverse-engineered consumer API a
    printed CLI tries to talk to.
    
    The generator now mirrors the existing User-Agent default in
    client.go.tmpl's do() method: an if-empty guard sets Accept to "*/*"
    only when nothing earlier in the request pipeline already set it.
    Per-endpoint headerOverrides, spec-declared RequiredHeaders, and
    content-negotiated overrides all win because they run before the guard.
    The fallback is gated by UsesBrowserManagedUserAgent so surf's Chrome
    impersonation transports keep managing the full header bundle.
    
    Closes #920
    
    * docs(cli): broaden WAF response-code description in Accept-default comment
    
    Greptile review flagged that the inline comment overspecified the
    WAF response code. The Resy case (issue #920 origin) actually
    returned empty-body HTTP 500, but Imperva, Akamai, Cloudflare
    bot-mode, and DataDome more commonly return 403 or a JS-challenge
    redirect depending on vendor and rule tier. The new wording covers
    all three so future readers tracing the guard's rationale aren't
    misled.
---
 internal/generator/generator_test.go               | 30 ++++++++++++++++++++++
 internal/generator/templates/client.go.tmpl        |  8 ++++++
 .../internal/client/client.go                      |  8 ++++++
 .../internal/client/client.go                      |  8 ++++++
 .../tier-routing-golden/internal/client/client.go  |  8 ++++++
 5 files changed, 62 insertions(+)

diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index 17e07ff4..4680161a 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -1496,6 +1496,10 @@ func TestGenerateBrowserChromeH3Transport(t *testing.T) {
 	require.NoError(t, err)
 	assert.Contains(t, string(clientGo), `"github.com/enetx/surf"`)
 	assert.Contains(t, string(clientGo), "ForceHTTP3()")
+	// surf's Chrome impersonation manages the Accept header alongside
+	// User-Agent on the H3 transport too; the generator must not emit a
+	// competing default.
+	assert.NotContains(t, string(clientGo), `req.Header.Set("Accept", "*/*")`)
 
 	runGoCommand(t, outputDir, "mod", "tidy")
 	runGoCommand(t, outputDir, "test", "./internal/client")
@@ -5768,6 +5772,8 @@ func TestGenerate_UserAgentOverrideGatedByBrowserTransport(t *testing.T) {
 	standardClient, err := os.ReadFile(filepath.Join(standardDir, "internal", "client", "client.go"))
 	require.NoError(t, err)
 	assert.Contains(t, string(standardClient), `req.Header.Set("User-Agent", "standard-pp-cli/0.1.0")`)
+	assert.Contains(t, string(standardClient), `req.Header.Set("Accept", "*/*")`)
+	assert.Contains(t, string(standardClient), `if req.Header.Get("Accept") == "" {`)
 
 	browserDir := filepath.Join(t.TempDir(), "browser-pp-cli")
 	browserSpec := baseSpec("browser")
@@ -5776,6 +5782,9 @@ func TestGenerate_UserAgentOverrideGatedByBrowserTransport(t *testing.T) {
 	browserClient, err := os.ReadFile(filepath.Join(browserDir, "internal", "client", "client.go"))
 	require.NoError(t, err)
 	assert.NotContains(t, string(browserClient), `req.Header.Set("User-Agent"`)
+	// surf's Chrome impersonation manages the Accept header alongside
+	// User-Agent; the generator must not emit a competing default for either.
+	assert.NotContains(t, string(browserClient), `req.Header.Set("Accept", "*/*")`)
 	// auth.go is not emitted for auth.type:none specs (see Generator.renderAuthFiles).
 	// Stronger assertion than the previous "no newAuthRefreshCmd in auth.go": there's
 	// no auth.go at all for no-auth CLIs.
@@ -5783,6 +5792,27 @@ func TestGenerate_UserAgentOverrideGatedByBrowserTransport(t *testing.T) {
 	assert.True(t, os.IsNotExist(err), "auth.go should not be emitted for auth.type:none specs")
 }
 
+func TestGenerateRequiredAcceptHeaderBeatsDefaultAccept(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("acceptoverride")
+	apiSpec.RequiredHeaders = []spec.RequiredHeader{
+		{Name: "Accept", Value: "application/vnd.api.v3+json"},
+	}
+	apiSpec.Auth.VerifyPath = "/items"
+
+	outputDir := filepath.Join(t.TempDir(), "acceptoverride-pp-cli")
+	require.NoError(t, New(apiSpec, outputDir).Generate())
+
+	clientSrc := readGeneratedFile(t, outputDir, "internal", "client", "client.go")
+	requiredIdx := strings.Index(clientSrc, `req.Header.Set("Accept", "application/vnd.api.v3+json")`)
+	defaultIdx := strings.Index(clientSrc, `req.Header.Set("Accept", "*/*")`)
+	require.GreaterOrEqual(t, requiredIdx, 0, "spec-required Accept header must be emitted")
+	require.GreaterOrEqual(t, defaultIdx, 0, "Accept fallback default must still be emitted")
+	assert.Less(t, requiredIdx, defaultIdx, "spec-required Accept must be set before the if-empty default")
+	assert.Contains(t, clientSrc, `if req.Header.Get("Accept") == "" {`)
+}
+
 func TestGenerateRequiredUserAgentHeaderBeatsDefaultUserAgent(t *testing.T) {
 	t.Parallel()
 
diff --git a/internal/generator/templates/client.go.tmpl b/internal/generator/templates/client.go.tmpl
index 638dbfce..78d632f3 100644
--- a/internal/generator/templates/client.go.tmpl
+++ b/internal/generator/templates/client.go.tmpl
@@ -863,6 +863,14 @@ func (c *Client) do(method, path string, params map[string]string, body any, hea
 		if req.Header.Get("User-Agent") == "" {
 			req.Header.Set("User-Agent", "{{.Name}}-pp-cli/{{.Version}}")
 		}
+		// Go's net/http omits Accept by default; browsers, curl, and other
+		// stdlibs always send it. Fingerprint-checking WAFs (Imperva, Akamai,
+		// Cloudflare bot-mode, DataDome) flag the absence as a bot signal
+		// and answer with empty-body 5xx, 403, or a challenge redirect
+		// depending on vendor and rule tier.
+		if req.Header.Get("Accept") == "" {
+			req.Header.Set("Accept", "*/*")
+		}
 {{- end}}
 
 		resp, err := c.HTTPClient.Do(req)
diff --git a/testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/client/client.go b/testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/client/client.go
index adf20abe..496b28e3 100644
--- a/testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/client/client.go
+++ b/testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/client/client.go
@@ -253,6 +253,14 @@ func (c *Client) do(method, path string, params map[string]string, body any, hea
 		if req.Header.Get("User-Agent") == "" {
 			req.Header.Set("User-Agent", "printing-press-oauth2-pp-cli/1.0.0")
 		}
+		// Go's net/http omits Accept by default; browsers, curl, and other
+		// stdlibs always send it. Fingerprint-checking WAFs (Imperva, Akamai,
+		// Cloudflare bot-mode, DataDome) flag the absence as a bot signal
+		// and answer with empty-body 5xx, 403, or a challenge redirect
+		// depending on vendor and rule tier.
+		if req.Header.Get("Accept") == "" {
+			req.Header.Set("Accept", "*/*")
+		}
 
 		resp, err := c.HTTPClient.Do(req)
 		if err != nil {
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/client/client.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/client/client.go
index 47cf766f..dffda2dc 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/client/client.go
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/client/client.go
@@ -247,6 +247,14 @@ func (c *Client) do(method, path string, params map[string]string, body any, hea
 		if req.Header.Get("User-Agent") == "" {
 			req.Header.Set("User-Agent", "printing-press-golden-pp-cli/2026.04")
 		}
+		// Go's net/http omits Accept by default; browsers, curl, and other
+		// stdlibs always send it. Fingerprint-checking WAFs (Imperva, Akamai,
+		// Cloudflare bot-mode, DataDome) flag the absence as a bot signal
+		// and answer with empty-body 5xx, 403, or a challenge redirect
+		// depending on vendor and rule tier.
+		if req.Header.Get("Accept") == "" {
+			req.Header.Set("Accept", "*/*")
+		}
 
 		resp, err := c.HTTPClient.Do(req)
 		if err != nil {
diff --git a/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/client/client.go b/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/client/client.go
index feb16071..c855a5d1 100644
--- a/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/client/client.go
+++ b/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/client/client.go
@@ -350,6 +350,14 @@ func (c *Client) do(method, path string, params map[string]string, body any, hea
 		if req.Header.Get("User-Agent") == "" {
 			req.Header.Set("User-Agent", "tier-routing-golden-pp-cli/1.0.0")
 		}
+		// Go's net/http omits Accept by default; browsers, curl, and other
+		// stdlibs always send it. Fingerprint-checking WAFs (Imperva, Akamai,
+		// Cloudflare bot-mode, DataDome) flag the absence as a bot signal
+		// and answer with empty-body 5xx, 403, or a challenge redirect
+		// depending on vendor and rule tier.
+		if req.Header.Get("Accept") == "" {
+			req.Header.Set("Accept", "*/*")
+		}
 
 		resp, err := c.HTTPClient.Do(req)
 		if err != nil {

← c6420747 fix(cli): honor Spec.BasePath in generated client URL constr  ·  back to Cli Printing Press  ·  fix(cli): infer pagination defaults from plain params; prese 10cc48a8 →