Skip to content

Commit 778478c

Browse files
vigoclaude
andauthored
fix: handle Content-Type with charset parameter for JSON payload (#32)
* fix: handle Content-Type with charset parameter for JSON payload The JSON payload case used exact string match against "application/json", so requests with parameters like "application/json; charset=utf-8" fell through to the default branch and the body was rendered as a single raw line, breaking the table layout. Switched to strings.HasPrefix to match the same approach already used for x-www-form-urlencoded and multipart/form-data, and added tests for charset-suffixed JSON. Also addressed pre-existing lint issues touched along the way: sort.Strings → slices.Sort, WriteString(fmt.Sprintf) → fmt.Fprintf, and a nolint on the text/plain response Fprintf flagged by gosec G705. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: render JSON and raw payload outside the table Long lines inside an AutoMerge'd payload row (e.g. an Azure DevOps webhook with a long URL inside detailedMessage.html) were inflating column 1's max width, pushing the value column past the terminal width and making 2-column rows wrap so values like "0.6.2" appeared empty on narrow terminals. The table now renders only metadata, headers, secret/HMAC validation and the incoming Content-Type. The pretty-printed JSON body and any default raw body are written below the table, framed by separator lines, so the table stays compact at any terminal width and the body follows the terminal's natural flow. Form-urlencoded and multipart key=value rows are unchanged — they are short and fit naturally inside the table. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: parse media type and unmarshal arbitrary JSON for payload classification Address review feedback from Codex and Copilot on PR #32: - Parse Content-Type with mime.ParseMediaType once and dispatch on the parsed media type with a tagged switch. application/json-patch+json, application/jsonp and other types whose subtype merely starts with "json" no longer fall into the JSON branch and incorrectly emit json.Unmarshal errors; they go through the raw-body path instead. - Hoist the multipart media-type parsing out of its case (it is now done once at the top) and reuse the parsed boundary parameter. - Switch the JSON unmarshal target from map[string]any to any, so arrays and primitives (e.g. JSON Patch, [1,2,3], "hello") are pretty-printed instead of erroring with "cannot unmarshal array into Go value of type map[string]any". - Upgrade the JSON-charset tests to write through WithOutputWriter to a temp file and assert observable handler output: pretty-printed multi-line JSON for valid input, json.Unmarshal error for invalid input. Add tests for JSON arrays and application/json-patch+json to lock in the new behaviour. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(webui): add copy-to-clipboard for body and individual header values Adds a copy button next to the Body section heading that copies the displayed body text (raw text or pretty-printed JSON) to the clipboard. Each row in the Headers table also gets an inline copy button that appears on row hover and copies the raw header value. Both buttons flash a "Copied!" confirmation for 1.5s on success and fall back to a hidden-textarea + execCommand path when navigator.clipboard is unavailable (older browsers / non-secure contexts). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * add copy * fix: surface mime.ParseMediaType errors instead of swallowing them Previously the parse error was discarded with `_`, so a malformed non-empty Content-Type would silently fall through to the raw-body default branch — losing the explicit error feedback the old multipart branch used to provide. Now any parse failure on a non-empty Content-Type renders an error row and short-circuits to RENDER. An absent Content-Type still falls through to raw body as before, since "no media type" is not a parse failure worth surfacing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: address Copilot review feedback - Show the request body under the "Payload" section even when mime.ParseMediaType fails. Previously the parse-error short-circuit jumped to RENDER without rendering the body, leaving the malformed payload visible only in the Raw HTTP dump. Set payloadAfterTable before goto RENDER so it still appears below the table. - Replace the hard-coded white background on .headers-table tr:hover with var(--highlight-bg) so the dark theme keeps usable contrast. - Make the per-header copy button discoverable for keyboard users by also revealing it on tr:focus-within / .copy-btn:focus-visible, and give every copy button a visible focus outline. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: address Copilot review feedback (round 2) - Close server.OutputWriter before reading the temp output file in WithOutputWriter-based subtests. Avoids potential file-locking failures on Windows and guarantees writes are flushed before the assertions run. - Keep the per-header copy button visible on touch devices via a @media (hover: none) rule, so the affordance is discoverable when no hover state is available. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: address Copilot review feedback (round 3) - Set payloadAfterTable = bodyAsString on json.Unmarshal and json.MarshalIndent failures so the raw request body still appears under the table when JSON parsing fails, matching the mime.ParseMediaType error path. - Drop the .trim() on the body copy handler so the clipboard receives the body's exact textContent (no leading/trailing whitespace lost). - Extend the invalid-JSON subtest to assert the raw body is rendered below the table on the unmarshal-error path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7d4dc0d commit 778478c

3 files changed

Lines changed: 316 additions & 43 deletions

File tree

internal/httpserver/httpserver.go

Lines changed: 40 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import (
1818
"net/url"
1919
"os"
2020
"path/filepath"
21-
"sort"
21+
"slices"
2222
"strings"
2323
"time"
2424

@@ -292,14 +292,15 @@ func debugHandlerFunc(options *debugHandlerOptions) http.HandlerFunc {
292292
for key := range r.Header {
293293
headerKeys = append(headerKeys, key)
294294
}
295-
sort.Strings(headerKeys)
295+
slices.Sort(headerKeys)
296296

297297
for _, key := range headerKeys {
298298
t.AppendRow(table.Row{key, strings.Join(r.Header[key], ",")})
299299
}
300300

301301
var bodyAsString string
302302
var storeFiles []requeststore.FileAttachment
303+
var payloadAfterTable string
303304

304305
switch r.Method {
305306
case http.MethodPost, http.MethodPut, http.MethodPatch:
@@ -365,9 +366,23 @@ func debugHandlerFunc(options *debugHandlerOptions) http.HandlerFunc {
365366

366367
bodyAsString = string(body)
367368

368-
switch {
369-
case requestContentType == "application/json":
370-
var jsonBody map[string]any
369+
mediaType, mediaParams, mediaErr := mime.ParseMediaType(requestContentType)
370+
if mediaErr != nil && requestContentType != "" {
371+
txtErrorMedia := colorError.Sprintf("mime.ParseMediaType error: %s", mediaErr.Error())
372+
t.AppendRow(table.Row{txtErrorMedia, txtErrorMedia}, table.RowConfig{
373+
AutoMerge: true,
374+
AutoMergeAlign: text.AlignLeft,
375+
})
376+
t.AppendSeparator()
377+
378+
payloadAfterTable = bodyAsString
379+
380+
goto RENDER
381+
}
382+
383+
switch mediaType {
384+
case "application/json":
385+
var jsonBody any
371386
if err = json.Unmarshal(body, &jsonBody); err != nil {
372387
txtErrorUnmarshal := colorError.Sprintf("json.Unmarshal error: %s", err.Error())
373388
t.AppendRow(table.Row{txtErrorUnmarshal, txtErrorUnmarshal}, table.RowConfig{
@@ -376,6 +391,8 @@ func debugHandlerFunc(options *debugHandlerOptions) http.HandlerFunc {
376391
})
377392
t.AppendSeparator()
378393

394+
payloadAfterTable = bodyAsString
395+
379396
goto RENDER
380397
}
381398

@@ -388,16 +405,13 @@ func debugHandlerFunc(options *debugHandlerOptions) http.HandlerFunc {
388405
})
389406
t.AppendSeparator()
390407

408+
payloadAfterTable = bodyAsString
409+
391410
goto RENDER
392411
}
393412

394-
t.AppendSeparator()
395-
payloadJSON := colorPayload.Sprintf("%s", prettyJSON)
396-
t.AppendRow(table.Row{payloadJSON, payloadJSON}, table.RowConfig{
397-
AutoMerge: true,
398-
AutoMergeAlign: text.AlignLeft,
399-
})
400-
case strings.HasPrefix(requestContentType, "application/x-www-form-urlencoded"):
413+
payloadAfterTable = string(prettyJSON)
414+
case "application/x-www-form-urlencoded":
401415
formData, errForm := url.ParseQuery(bodyAsString)
402416
if errForm != nil {
403417
txtErrorForm := colorError.Sprintf("url.ParseQuery error: %s", errForm.Error())
@@ -421,27 +435,15 @@ func debugHandlerFunc(options *debugHandlerOptions) http.HandlerFunc {
421435
for key := range formData {
422436
formKeys = append(formKeys, key)
423437
}
424-
sort.Strings(formKeys)
438+
slices.Sort(formKeys)
425439

426440
for _, key := range formKeys {
427441
values := formData[key]
428442
valueStr := colorPayload.Sprint(strings.Join(values, ", "))
429443
t.AppendRow(table.Row{key, valueStr})
430444
}
431-
case strings.HasPrefix(requestContentType, "multipart/form-data"):
432-
_, params, errMedia := mime.ParseMediaType(requestContentType)
433-
if errMedia != nil {
434-
txtErrorMedia := colorError.Sprintf("mime.ParseMediaType error: %s", errMedia.Error())
435-
t.AppendRow(table.Row{txtErrorMedia, txtErrorMedia}, table.RowConfig{
436-
AutoMerge: true,
437-
AutoMergeAlign: text.AlignLeft,
438-
})
439-
t.AppendSeparator()
440-
441-
goto RENDER
442-
}
443-
444-
boundary := params["boundary"]
445+
case "multipart/form-data":
446+
boundary := mediaParams["boundary"]
445447
if boundary == "" {
446448
txtErrorBoundary := colorError.Sprint("multipart boundary not found")
447449
t.AppendRow(table.Row{txtErrorBoundary, txtErrorBoundary}, table.RowConfig{
@@ -549,7 +551,7 @@ func debugHandlerFunc(options *debugHandlerOptions) http.HandlerFunc {
549551
for key := range formFields {
550552
fieldKeys = append(fieldKeys, key)
551553
}
552-
sort.Strings(fieldKeys)
554+
slices.Sort(fieldKeys)
553555

554556
for _, key := range fieldKeys {
555557
values := formFields[key]
@@ -600,20 +602,18 @@ func debugHandlerFunc(options *debugHandlerOptions) http.HandlerFunc {
600602
storeFiles = append(storeFiles, sf)
601603
}
602604
default:
603-
payloadText := colorPayload.Sprintf("%s", body)
604-
t.AppendSeparator()
605-
t.AppendRow(
606-
table.Row{payloadText, payloadText},
607-
table.RowConfig{
608-
AutoMerge: true,
609-
AutoMergeAlign: text.AlignLeft,
610-
},
611-
)
605+
payloadAfterTable = bodyAsString
612606
}
613607
}
614608
RENDER:
615609
t.Render()
616610

611+
if payloadAfterTable != "" {
612+
options.drawLine()
613+
fmt.Fprintln(options.writer, colorPayload.Sprintf("%s", payloadAfterTable))
614+
options.drawLine()
615+
}
616+
617617
mwr := io.MultiWriter(options.writer)
618618
var rawHRw *os.File
619619
var rawErr error
@@ -628,7 +628,8 @@ func debugHandlerFunc(options *debugHandlerOptions) http.HandlerFunc {
628628
}
629629

630630
mwr = io.MultiWriter(options.writer, rawHRw)
631-
fmt.Fprintf(w, "Raw HTTP Request is saved to: %s\n", formattedFilename)
631+
// response is text/plain and filename is sanitized in stringutils.GetFormattedFilename
632+
fmt.Fprintf(w, "Raw HTTP Request is saved to: %s\n", formattedFilename) //nolint:gosec
632633
}
633634

634635
WRITERHR:
@@ -855,7 +856,7 @@ func sanitizeBodyForDisplay(body, contentType string) string {
855856
// Remove trailing boundary markers for size calculation
856857
cleanContent := strings.TrimSuffix(content, "\r\n")
857858
cleanContent = strings.TrimSuffix(cleanContent, "\n")
858-
result.WriteString(fmt.Sprintf("[binary data: %s]", formatFileSize(len(cleanContent))))
859+
fmt.Fprintf(&result, "[binary data: %s]", formatFileSize(len(cleanContent)))
859860
// Preserve the trailing newlines
860861
if strings.HasSuffix(content, "\r\n") {
861862
result.WriteString("\r\n")

internal/httpserver/httpserver_test.go

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"net/http/httptest"
1212
"net/textproto"
1313
"os"
14+
"path/filepath"
1415
"strings"
1516
"testing"
1617
"time"
@@ -176,6 +177,138 @@ func TestDebugHandler(t *testing.T) {
176177
assert.Contains(t, rec.Body.String(), "OK")
177178
})
178179

180+
t.Run("POST request with JSON body and charset pretty-prints below the table", func(t *testing.T) {
181+
out := filepath.Join(t.TempDir(), "out.log")
182+
server, err := httpserver.New(httpserver.WithOutputWriter(out))
183+
require.NoError(t, err)
184+
185+
body := `{"name":"test","value":123}`
186+
req := httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader(body))
187+
req.Header.Set("Content-Type", "application/json; charset=utf-8")
188+
rec := httptest.NewRecorder()
189+
190+
server.HTTPServer.Handler.ServeHTTP(rec, req)
191+
require.Equal(t, http.StatusOK, rec.Code)
192+
require.NoError(t, server.OutputWriter.Close())
193+
194+
got, errR := os.ReadFile(out)
195+
require.NoError(t, errR)
196+
s := string(got)
197+
198+
assert.Contains(t, s, "application/json; charset=utf-8")
199+
assert.Contains(t, s, "\"name\": \"test\"")
200+
assert.Contains(t, s, "\"value\": 123")
201+
assert.NotContains(t, s, "json.Unmarshal error")
202+
})
203+
204+
t.Run("POST request with JSON array body pretty-prints", func(t *testing.T) {
205+
out := filepath.Join(t.TempDir(), "out.log")
206+
server, err := httpserver.New(httpserver.WithOutputWriter(out))
207+
require.NoError(t, err)
208+
209+
body := `[1,2,3]`
210+
req := httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader(body))
211+
req.Header.Set("Content-Type", "application/json")
212+
rec := httptest.NewRecorder()
213+
214+
server.HTTPServer.Handler.ServeHTTP(rec, req)
215+
require.Equal(t, http.StatusOK, rec.Code)
216+
require.NoError(t, server.OutputWriter.Close())
217+
218+
got, errR := os.ReadFile(out)
219+
require.NoError(t, errR)
220+
s := string(got)
221+
222+
assert.NotContains(t, s, "json.Unmarshal error")
223+
assert.Contains(t, s, "[\n 1,\n 2,\n 3\n]")
224+
})
225+
226+
t.Run("POST request with json-patch+json is not parsed as JSON", func(t *testing.T) {
227+
out := filepath.Join(t.TempDir(), "out.log")
228+
server, err := httpserver.New(httpserver.WithOutputWriter(out))
229+
require.NoError(t, err)
230+
231+
body := `[{"op":"add","path":"/foo","value":"bar"}]`
232+
req := httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader(body))
233+
req.Header.Set("Content-Type", "application/json-patch+json")
234+
rec := httptest.NewRecorder()
235+
236+
server.HTTPServer.Handler.ServeHTTP(rec, req)
237+
require.Equal(t, http.StatusOK, rec.Code)
238+
require.NoError(t, server.OutputWriter.Close())
239+
240+
got, errR := os.ReadFile(out)
241+
require.NoError(t, errR)
242+
s := string(got)
243+
244+
assert.NotContains(t, s, "json.Unmarshal error")
245+
assert.Contains(t, s, body)
246+
})
247+
248+
t.Run("POST request with malformed Content-Type reports parse error", func(t *testing.T) {
249+
out := filepath.Join(t.TempDir(), "out.log")
250+
server, err := httpserver.New(httpserver.WithOutputWriter(out))
251+
require.NoError(t, err)
252+
253+
body := `whatever`
254+
req := httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader(body))
255+
req.Header.Set("Content-Type", "application/json; charset=") // missing param value
256+
rec := httptest.NewRecorder()
257+
258+
server.HTTPServer.Handler.ServeHTTP(rec, req)
259+
require.Equal(t, http.StatusOK, rec.Code)
260+
require.NoError(t, server.OutputWriter.Close())
261+
262+
got, errR := os.ReadFile(out)
263+
require.NoError(t, errR)
264+
s := string(got)
265+
assert.Contains(t, s, "mime.ParseMediaType error")
266+
assert.Contains(t, s, body) // body still rendered below the table
267+
})
268+
269+
t.Run("POST request with empty Content-Type does not report parse error", func(t *testing.T) {
270+
out := filepath.Join(t.TempDir(), "out.log")
271+
server, err := httpserver.New(httpserver.WithOutputWriter(out))
272+
require.NoError(t, err)
273+
274+
body := `raw payload without content type`
275+
req := httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader(body))
276+
req.Header.Del("Content-Type")
277+
rec := httptest.NewRecorder()
278+
279+
server.HTTPServer.Handler.ServeHTTP(rec, req)
280+
require.Equal(t, http.StatusOK, rec.Code)
281+
require.NoError(t, server.OutputWriter.Close())
282+
283+
got, errR := os.ReadFile(out)
284+
require.NoError(t, errR)
285+
s := string(got)
286+
assert.NotContains(t, s, "mime.ParseMediaType error")
287+
assert.Contains(t, s, body)
288+
})
289+
290+
t.Run("POST request with invalid JSON and charset reports unmarshal error", func(t *testing.T) {
291+
out := filepath.Join(t.TempDir(), "out.log")
292+
server, err := httpserver.New(httpserver.WithOutputWriter(out))
293+
require.NoError(t, err)
294+
295+
body := `not-valid-json`
296+
req := httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader(body))
297+
req.Header.Set("Content-Type", "application/json; charset=utf-8")
298+
rec := httptest.NewRecorder()
299+
300+
server.HTTPServer.Handler.ServeHTTP(rec, req)
301+
require.Equal(t, http.StatusOK, rec.Code)
302+
require.NoError(t, server.OutputWriter.Close())
303+
304+
got, errR := os.ReadFile(out)
305+
require.NoError(t, errR)
306+
s := string(got)
307+
308+
assert.Contains(t, s, "json.Unmarshal error")
309+
assert.Contains(t, s, body) // raw body still rendered below the table
310+
})
311+
179312
t.Run("POST request with plain text body", func(t *testing.T) {
180313
server, err := httpserver.New()
181314
require.NoError(t, err)

0 commit comments

Comments
 (0)