diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 007dea1..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,91 +0,0 @@ -name: CI - -on: - push: - branches: [main] - pull_request: - branches: [main] - -jobs: - lint: - name: Lint - runs-on: ubuntu-latest - if: github.event_name == 'pull_request' - steps: - - uses: actions/checkout@v6 - - - uses: actions/setup-go@v6 - with: - go-version-file: go.mod - - - name: golangci-lint - uses: golangci/golangci-lint-action@v9 - with: - version: v2.7.2 - - test: - name: Test - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - - uses: actions/setup-go@v6 - with: - go-version-file: go.mod - - - name: Run tests - run: go test -race -coverprofile=coverage.out ./... - - - name: Upload coverage - uses: actions/upload-artifact@v4 - with: - name: coverage - path: coverage.out - - security: - name: Security - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - - uses: actions/setup-go@v6 - with: - go-version-file: go.mod - - - name: gosec - uses: securego/gosec@master - with: - args: ./... - - - name: govulncheck - run: | - go install golang.org/x/vuln/cmd/govulncheck@latest - govulncheck ./... - - - name: trivy fs - uses: aquasecurity/trivy-action@master - with: - scan-type: fs - scan-ref: . - exit-code: 1 - - build: - name: Build - runs-on: ubuntu-latest - needs: [lint, test, security] - if: always() && needs.test.result == 'success' && needs.security.result == 'success' && (needs.lint.result == 'success' || needs.lint.result == 'skipped') - steps: - - uses: actions/checkout@v6 - - - uses: actions/setup-go@v6 - with: - go-version-file: go.mod - - - name: Build binary - run: go build -o httpmon ./cmd/httpmon - - - name: Upload binary - uses: actions/upload-artifact@v4 - with: - name: httpmon - path: httpmon diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4a1dc0d..cee8846 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,16 +1,13 @@ name: Release on: - workflow_run: - workflows: [CI] - types: [completed] - branches: [main] + push: + tags: ["v*"] jobs: release: name: Release runs-on: ubuntu-latest - if: github.event.workflow_run.conclusion == 'success' permissions: contents: write steps: @@ -22,52 +19,7 @@ jobs: with: go-version-file: go.mod - - name: Get latest tag - id: get_tag - run: | - git fetch --tags - - LATEST_TAG=$(git tag -l 'v*' --sort=-v:refname | head -n 1) - if [ -z "$LATEST_TAG" ]; then - LATEST_TAG="v0.0.0" - fi - echo "latest=$LATEST_TAG" >> $GITHUB_OUTPUT - - VERSION=${LATEST_TAG#v} - MAJOR=$(echo $VERSION | cut -d. -f1) - MINOR=$(echo $VERSION | cut -d. -f2) - PATCH=$(echo $VERSION | cut -d. -f3) - - NEW_PATCH=$((PATCH + 1)) - NEW_TAG="v${MAJOR}.${MINOR}.${NEW_PATCH}" - - if [ "$LATEST_TAG" = "v0.0.0" ]; then - NEW_TAG="v0.1.0" - fi - - echo "new_tag=$NEW_TAG" >> $GITHUB_OUTPUT - echo "Latest: $LATEST_TAG -> New: $NEW_TAG" - - - name: Create tag - id: create_tag - env: - NEW_TAG: ${{ steps.get_tag.outputs.new_tag }} - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - if git ls-remote --tags origin | grep -q "refs/tags/$NEW_TAG$"; then - echo "Tag $NEW_TAG already exists on remote, skipping release" - echo "skip=true" >> $GITHUB_OUTPUT - exit 0 - fi - - git tag "$NEW_TAG" - git push origin "$NEW_TAG" - echo "skip=false" >> $GITHUB_OUTPUT - - name: Run GoReleaser - if: steps.create_tag.outputs.skip != 'true' uses: goreleaser/goreleaser-action@v6 with: version: latest diff --git a/CHANGELOG.md b/CHANGELOG.md index f3a240b..0ee2700 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,18 @@ # Changelog -## feat/config-package-go126 +## feat/bodydecoder-registry + +Protobuf and gRPC-Web bodies are now decoded into human-readable text in the +detail view (#26). A new `--proto-path` flag loads `.proto` files for named +message decoding; without it, raw wire-format decoding is always available. The +`BodyDecoderRegistry` interface threads through the TUI port layer, and a +`renderOpts` refactor replaces six positional render arguments with a single +struct. Protobuf/gRPC MIME types are removed from the binary blocklist so they +flow through the decoder pipeline. A gRPC-Web frame parser fix resolves a gosec +G115 integer overflow, and a new E2E test exercises a full Connect RPC +round-trip with proto decoding. + +## [feat/config-package-go126](https://github.com/kostyay/httpmon/pull/22) - 2026-02-16 Persistent configuration via `~/.httpmon/config.json` replaces pure CLI-flag defaults (#22). A new `internal/config` package handles Load/Save with automatic diff --git a/Makefile b/Makefile index 9d2324a..f14e2be 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all fmt lint test e2e build clean security release +.PHONY: all fmt lint test e2e build clean security release generate-proto all: lint test build @@ -31,6 +31,14 @@ release: lint test security git tag "$$TAG" && git push origin "$$TAG" && \ GITHUB_TOKEN=$$(gh auth token) goreleaser release --clean +# Requires: go install google.golang.org/protobuf/cmd/protoc-gen-go@latest +# go install connectrpc.com/connect/cmd/protoc-gen-connect-go@latest +# go install github.com/bufbuild/buf/cmd/buf@latest +generate-proto: + buf generate \ + --template '{"version":"v2","plugins":[{"remote":"buf.build/protocolbuffers/go","out":"internal/e2e/testpb","opt":"paths=source_relative"},{"remote":"buf.build/connectrpc/go","out":"internal/e2e/testpb","opt":"paths=source_relative"}]}' \ + internal/bodydecoder/testdata/test.proto + security: @echo "==> gosec" @command -v gosec >/dev/null 2>&1 && gosec ./... || echo "gosec not installed (go install github.com/securego/gosec/v2/cmd/gosec@latest)" diff --git a/cmd/httpmon/main.go b/cmd/httpmon/main.go index 2ee285d..ddba2ce 100644 --- a/cmd/httpmon/main.go +++ b/cmd/httpmon/main.go @@ -12,6 +12,7 @@ import ( tea "charm.land/bubbletea/v2" + "github.com/kostyay/httpmon/internal/bodydecoder" "github.com/kostyay/httpmon/internal/breakpoint" "github.com/kostyay/httpmon/internal/certutil" "github.com/kostyay/httpmon/internal/config" @@ -27,6 +28,15 @@ import ( var version = "dev" +// stringSlice implements flag.Value for repeatable string flags. +type stringSlice []string + +func (s *stringSlice) String() string { return strings.Join(*s, ",") } +func (s *stringSlice) Set(v string) error { + *s = append(*s, v) + return nil +} + func main() { flag.Int("port", 8080, "proxy listen port") dataDir := flag.String("data-dir", defaultDataDir(), "data directory for CA certs") @@ -40,6 +50,8 @@ func main() { flag.Bool("mcp", false, "start MCP server on default addr (127.0.0.1:9551)") flag.String("mcp-addr", "", "MCP server listen address (implies --mcp)") mcpTokenFlag := flag.Bool("mcp-token", false, "print MCP bearer token and exit") + var protoPaths stringSlice + flag.Var(&protoPaths, "proto-path", "path to .proto file or directory (repeatable)") flag.Parse() if *showVersion { @@ -53,6 +65,11 @@ func main() { } config.ApplyFlags(cfg, flag.Visit) + // Merge proto paths: config first, CLI appended. + if len(protoPaths) > 0 { + cfg.ProtoPaths = append(cfg.ProtoPaths, protoPaths...) + } + // --mcp-addr implies --mcp. flag.Visit(func(f *flag.Flag) { if f.Name == "mcp-addr" { @@ -146,6 +163,19 @@ func main() { fmt.Fprintf(os.Stderr, "MCP server listening on %s (token: %s)\n", mcpSrv.Addr(), cfg.MCPToken) } + // Build body decoder registry for protobuf/gRPC decoding. + // Even without .proto files, raw wire-format decoding is available. + protoDec := &bodydecoder.RawProtobufDecoder{} + if len(cfg.ProtoPaths) > 0 { + protoReg, protoErrs := bodydecoder.LoadProtoFiles(cfg.ProtoPaths) + for _, e := range protoErrs { + fmt.Fprintf(os.Stderr, "proto: %v\n", e) + } + protoDec.ProtoReg = protoReg + } + grpcDec := &bodydecoder.GRPCWebDecoder{Proto: protoDec} + decoderReg := bodydecoder.NewRegistry(grpcDec, protoDec) + tuiCfg := tui.AppConfig{ Store: s, Proxy: p, @@ -154,6 +184,7 @@ func main() { Throttle: p, Breakpoints: bpCtrl, DataDir: *dataDir, + BodyDecoder: decoderReg, } if mcpSrv != nil { tuiCfg.MCP = mcpSrv diff --git a/docs/plans/2026-02-24-grpc-e2e-tests-design.md b/docs/plans/2026-02-24-grpc-e2e-tests-design.md new file mode 100644 index 0000000..35cad32 --- /dev/null +++ b/docs/plans/2026-02-24-grpc-e2e-tests-design.md @@ -0,0 +1,53 @@ +# gRPC-Web E2E Tests Design + +## Goal + +End-to-end tests for gRPC/protobuf body decoding. Real gRPC-Web server (Connect) → httpmon proxy → store → TUI decode + render. + +## Dependencies + +- `connectrpc.com/connect` — gRPC-Web server + client +- `protoc-gen-go`, `protoc-gen-connect-go` — code generation (dev tools, not Go deps) + +## Generated Code + +Source: `internal/bodydecoder/testdata/test.proto` (Greeter service, HelloRequest/HelloReply). +Output: `internal/e2e/testpb/` (committed). + +``` +internal/e2e/testpb/ + test.pb.go + testpbconnect/ + test.connect.go +``` + +## Harness Extension + +Extend `newHarness` with variadic `harnessOpt`: + +```go +type harnessOpt func(*harnessConfig) +func withBodyDecoder(reg *bodydecoder.Registry) harnessOpt +``` + +Existing tests unchanged (no opts passed). + +`grpcHarness` wraps base harness + Connect Greeter client routed through the proxy with `WithGRPCWeb()`. + +## Test Scenarios (`grpc_test.go`) + +1. **TestGRPCWebDecodeResponse** — SayHello, response tab shows named JSON fields +2. **TestGRPCWebDecodeRequest** — request tab shows named JSON fields +3. **TestGRPCWebRawToggle** — `r` key toggles between decoded and raw +4. **TestGRPCWebWithoutProtoFiles** — no proto paths → field-number JSON ("1", "2") +5. **TestGRPCWebDecodeError** — garbage bytes with grpc-web content type → status bar error +6. **TestGRPCWebNonProtoUnchanged** — regular JSON GET still works normally + +## Makefile + +```makefile +generate-proto: + protoc ... +``` + +Not wired into `make all`. diff --git a/docs/plans/2026-02-24-grpc-protobuf-decoding-design.md b/docs/plans/2026-02-24-grpc-protobuf-decoding-design.md new file mode 100644 index 0000000..8fb11f8 --- /dev/null +++ b/docs/plans/2026-02-24-grpc-protobuf-decoding-design.md @@ -0,0 +1,160 @@ +# gRPC/Protobuf Body Decoding + +## Summary + +Decode protobuf and gRPC-Web bodies in the response viewer. Raw wire-format decode by default; full named-field decode when `.proto` files are supplied. Pluggable body decoder architecture for future format support. + +## Proto Path Configuration + +**Config file** (`~/.httpmon/config.json`): +```json +{ + "proto_paths": ["/home/user/protos", "/home/user/api/service.proto"] +} +``` + +**CLI flag** (repeated, merges with config): +``` +httpmon --proto-path ./protos --proto-path ./other/service.proto +``` + +**Path resolution:** +- File path: load that single `.proto` +- Directory path: recursively glob all `*.proto` +- CLI paths appended after config paths; all searched together +- All directories act as import roots (like `protoc -I`) + +**Startup behavior:** +- Parse proto files into descriptor registry using `bufbuild/protocompile` +- Invalid path or syntax error: warn in TUI status bar, skip, continue +- No hot-reload; restart to pick up changes + +**Config struct field:** `ProtoPaths []string \`json:"proto_paths"\`` + +## Body Decoder Architecture + +New package: `internal/bodydecoder` + +``` +internal/bodydecoder/ +├── decoder.go # Registry + Decoder interface +├── protobuf.go # Wire-format + proto-file decode +└── grpcweb.go # gRPC-Web frame stripping → delegates to protobuf +``` + +Note: JSON pretty-print stays in `highlight.go` — no need to move it into the registry. + +### Interface + +```go +type Decoder interface { + CanDecode(contentType string) bool + Decode(body []byte, metadata DecoderMetadata) (decoded string, resultContentType string, err error) +} + +type DecoderMetadata struct { + RequestPath string // e.g. /api/v1/package.Service/Method + IsRequest bool // true = request body, false = response body +} +``` + +`resultContentType` tells the highlight pipeline which lexer to use. Convention: protobuf decoder returns `"application/json"` so Chroma highlights as JSON. Future decoders may return other MIME types. + +### Registry + +```go +func NewRegistry(protoPaths []string) (*Registry, []error) +func (r *Registry) Decode(body []byte, contentType string, meta DecoderMetadata) (string, string, error) +``` + +Ordered decoder list; first `CanDecode` match wins. Returns `ErrNoDecoder` if no decoder matches — caller falls through to existing highlight behavior. + +### Content Type Routing + +| Content Type | Decoder | Notes | +|---|---|---| +| `application/grpc-web`, `application/grpc-web+proto` | grpcweb → protobuf | Strip 5-byte frame headers first | +| `application/grpc`, `application/grpc+proto` | protobuf | gRPC over HTTP (direct wire format) | +| `application/protobuf`, `application/x-protobuf`, `application/x-google-protobuf` | protobuf | Plain protobuf wire format | + +## Protobuf Decoding + +### Message Type Resolution + +1. **Has gRPC path** (request path contains `/{package.Service}/{Method}`): Extract service+method from last two path segments. Look up input (request) or output (response) message type from service descriptor. +2. **No gRPC path** (plain protobuf): Skip named decode. Go straight to raw wire decode. +3. **Raw wire decode** (fallback, always available): Iterate varint-tagged fields → JSON like `{"1": 42, "2": "base64data"}`. + +Rationale for skipping "try all descriptors" on plain protobuf: wire format is too permissive — almost any byte sequence parses as *some* valid message, producing false positives. + +### Dependencies + +- `bufbuild/protocompile` — parse `.proto` files into linked descriptors +- `google.golang.org/protobuf/types/dynamicpb` — dynamic message construction +- `google.golang.org/protobuf/encoding/protojson` — marshal to JSON +- `google.golang.org/protobuf/encoding/protowire` — raw wire-format decode + +## gRPC-Web Frame Handling + +- 1-byte flags + 4-byte big-endian length per frame +- `0x00` = data frame, `0x01` = compressed data frame, `0x80` = trailers frame +- Extract and concatenate data frame payloads +- Pass concatenated payload to protobuf decoder +- **Compressed frames** (flag `0x01`): not supported initially. Display `[compressed gRPC payload, N bytes]` and fall back to raw hex. Can add gzip decompression later. +- **Truncated frames** (length > remaining body): decode what's available, note truncation in output. +- **Trailer frames**: skip (metadata, not message data). + +## Integration + +### highlight.go Changes + +**Remove protobuf/gRPC content types from `binaryMIMETypes`.** Currently these are hardcoded as binary and `Highlight()` returns `[binary content: ...]` before any decoder runs. Must remove: +- `application/x-protobuf`, `application/protobuf`, `application/x-google-protobuf` +- `application/grpc`, `application/grpc+proto` +- `application/grpc-web`, `application/grpc-web+proto` + +**Skip NUL-byte and UTF-8 heuristics** when a decoder has successfully decoded the body (decoded output is valid UTF-8 JSON, so heuristics won't trigger on the *output* — but the check order matters). + +**New call flow:** + +``` +renderBody() in detail.go + ├── call registry.Decode(body, contentType, metadata) + │ ├── success: use decoded string + resultContentType for highlighting + │ └── ErrNoDecoder: fall through to existing Highlight() path + └── call highlight.Highlight(decodedBody, effectiveContentType, ...) +``` + +The decoder is called **before** `Highlight()`, not inside it. This keeps `Highlight()` focused on syntax highlighting and avoids plumbing `DecoderMetadata` through it. + +### DecoderMetadata Plumbing + +`renderBody()` in `detail.go` currently receives `(label, body, contentType, darkBg, prettyJSON)`. It needs to also receive `DecoderMetadata`. The metadata is available in the detail view — `FlowMeta.Path` for request path, and which tab is active (request vs response) for `IsRequest`. + +Updated signature: +```go +func (a *App) renderBody(label string, body []byte, contentType string, darkBg bool, prettyJSON bool, meta bodydecoder.DecoderMetadata) string +``` + +### Dependency Injection + +`bodydecoder.Registry` created in `cmd/httpmon/main.go`, stored as new field on `tui.AppConfig` struct. `nil` registry is valid (no decoders, all content falls through to existing behavior). + +### Proto path merging + +```go +paths := append(cfg.ProtoPaths, cliProtoPaths...) +``` + +Config file paths first, CLI paths appended. + +### Startup errors + +`NewRegistry()` returns `[]error` for any proto files that failed to parse. These are collected and displayed as warnings in the TUI status bar on first render. Registry still functions with whatever protos loaded successfully. + +## Future Extensibility + +Adding a new body format: +1. Create `internal/bodydecoder/newformat.go` +2. Implement `Decoder` interface +3. Register in `NewRegistry()` diff --git a/go.mod b/go.mod index 51c10fb..7ce6146 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,9 @@ require ( charm.land/bubbles/v2 v2.0.0-rc.1 charm.land/bubbletea/v2 v2.0.0-rc.2 charm.land/lipgloss/v2 v2.0.0-beta.3.0.20260212100304-e18737634dea + connectrpc.com/connect v1.19.1 github.com/alecthomas/chroma/v2 v2.23.1 + github.com/bufbuild/protocompile v0.14.1 github.com/charmbracelet/x/ansi v0.11.6 github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 github.com/lqqyt2423/go-mitmproxy v1.8.8 @@ -17,6 +19,7 @@ require ( github.com/sirupsen/logrus v1.9.4 github.com/stretchr/testify v1.10.0 golang.org/x/image v0.36.0 + google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index c533d66..34783c0 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ charm.land/bubbletea/v2 v2.0.0-rc.2 h1:TdTbUOFzbufDJmSz/3gomL6q+fR6HwfY+P13hXQzD charm.land/bubbletea/v2 v2.0.0-rc.2/go.mod h1:IXFmnCnMLTWw/KQ9rEatSYqbAPAYi8kA3Yqwa1SFnLk= charm.land/lipgloss/v2 v2.0.0-beta.3.0.20260212100304-e18737634dea h1:XBmpGhIKPN8o9VjuXg+X5WXFsEqUs/YtPx0Q0zzmTTA= charm.land/lipgloss/v2 v2.0.0-beta.3.0.20260212100304-e18737634dea/go.mod h1:xylWHUuJWcFJqoGrKdZP8Z0y3THC6xqrnfl1IYDviTE= +connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14= +connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= @@ -20,6 +22,8 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= +github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 h1:OqDqxQZliC7C8adA7KjelW3OjtAxREfeHkNcd66wpeI= @@ -140,6 +144,8 @@ golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/internal/bodydecoder/decoder.go b/internal/bodydecoder/decoder.go new file mode 100644 index 0000000..9d8dcd4 --- /dev/null +++ b/internal/bodydecoder/decoder.go @@ -0,0 +1,48 @@ +// Package bodydecoder provides a pluggable body decoding pipeline. +// Decoders are tried in registration order; first CanDecode match wins. +package bodydecoder + +import "errors" + +// ErrNoDecoder is returned when no registered decoder matches the content type. +var ErrNoDecoder = errors.New("no decoder matched content type") + +// DecoderMetadata carries per-request context that decoders may use +// for message type resolution (e.g. gRPC service/method from path). +type DecoderMetadata struct { + RequestPath string // e.g. /api/v1/package.Service/Method + IsRequest bool // true = request body, false = response body +} + +// Decoder decodes a body from wire format into a human-readable string. +type Decoder interface { + // CanDecode reports whether this decoder handles the given content type. + CanDecode(contentType string) bool + + // Decode decodes body bytes and returns the decoded text plus a + // resultContentType hint for downstream syntax highlighting + // (e.g. "application/json"). + Decode(body []byte, metadata DecoderMetadata) (decoded string, resultContentType string, err error) +} + +// Registry holds an ordered list of decoders and routes decode requests +// to the first matching decoder. +type Registry struct { + decoders []Decoder +} + +// NewRegistry creates a Registry with the given decoders tried in order. +func NewRegistry(decoders ...Decoder) *Registry { + return &Registry{decoders: decoders} +} + +// Decode finds the first decoder that can handle contentType and delegates to it. +// Returns ErrNoDecoder if no decoder matches. +func (r *Registry) Decode(body []byte, contentType string, meta DecoderMetadata) (string, string, error) { + for _, d := range r.decoders { + if d.CanDecode(contentType) { + return d.Decode(body, meta) + } + } + return "", "", ErrNoDecoder +} diff --git a/internal/bodydecoder/decoder_test.go b/internal/bodydecoder/decoder_test.go new file mode 100644 index 0000000..41a2c34 --- /dev/null +++ b/internal/bodydecoder/decoder_test.go @@ -0,0 +1,118 @@ +package bodydecoder + +import ( + "errors" + "testing" +) + +// stubDecoder matches a fixed content type and returns canned output. +type stubDecoder struct { + match string + decoded string + resultContentType string + err error +} + +func (s *stubDecoder) CanDecode(contentType string) bool { + return contentType == s.match +} + +func (s *stubDecoder) Decode(_ []byte, _ DecoderMetadata) (string, string, error) { + return s.decoded, s.resultContentType, s.err +} + +func TestRegistry_FirstMatchWins(t *testing.T) { + first := &stubDecoder{match: "application/protobuf", decoded: "first", resultContentType: "application/json"} + second := &stubDecoder{match: "application/protobuf", decoded: "second", resultContentType: "application/json"} + + reg := NewRegistry(first, second) + got, ct, err := reg.Decode(nil, "application/protobuf", DecoderMetadata{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "first" { + t.Errorf("expected first decoder to win, got %q", got) + } + if ct != "application/json" { + t.Errorf("unexpected resultContentType %q", ct) + } +} + +func TestRegistry_RoutesToCorrectDecoder(t *testing.T) { + proto := &stubDecoder{match: "application/protobuf", decoded: "proto-decoded", resultContentType: "application/json"} + grpc := &stubDecoder{match: "application/grpc-web", decoded: "grpc-decoded", resultContentType: "application/json"} + + reg := NewRegistry(proto, grpc) + + got, _, err := reg.Decode(nil, "application/grpc-web", DecoderMetadata{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "grpc-decoded" { + t.Errorf("expected grpc decoder, got %q", got) + } +} + +func TestRegistry_NoMatch_ReturnsErrNoDecoder(t *testing.T) { + proto := &stubDecoder{match: "application/protobuf", decoded: "proto"} + + reg := NewRegistry(proto) + _, _, err := reg.Decode(nil, "text/html", DecoderMetadata{}) + if !errors.Is(err, ErrNoDecoder) { + t.Fatalf("expected ErrNoDecoder, got %v", err) + } +} + +func TestRegistry_Empty_ReturnsErrNoDecoder(t *testing.T) { + reg := NewRegistry() + _, _, err := reg.Decode(nil, "application/json", DecoderMetadata{}) + if !errors.Is(err, ErrNoDecoder) { + t.Fatalf("expected ErrNoDecoder, got %v", err) + } +} + +func TestRegistry_DecoderError_Propagated(t *testing.T) { + decoderErr := errors.New("decode failed") + bad := &stubDecoder{match: "application/protobuf", err: decoderErr} + + reg := NewRegistry(bad) + _, _, err := reg.Decode([]byte("data"), "application/protobuf", DecoderMetadata{}) + if !errors.Is(err, decoderErr) { + t.Fatalf("expected decoder error, got %v", err) + } +} + +func TestRegistry_MetadataPassedThrough(t *testing.T) { + var captured DecoderMetadata + capturing := &capturingDecoder{match: "application/protobuf", onDecode: func(meta DecoderMetadata) { + captured = meta + }} + + reg := NewRegistry(capturing) + meta := DecoderMetadata{RequestPath: "/pkg.Svc/Method", IsRequest: true} + _, _, _ = reg.Decode(nil, "application/protobuf", meta) + + if captured.RequestPath != "/pkg.Svc/Method" { + t.Errorf("RequestPath not passed through: %q", captured.RequestPath) + } + if !captured.IsRequest { + t.Error("IsRequest not passed through") + } +} + +// capturingDecoder records the metadata it receives. +type capturingDecoder struct { + match string + onDecode func(DecoderMetadata) +} + +func (c *capturingDecoder) CanDecode(contentType string) bool { + return contentType == c.match +} + +func (c *capturingDecoder) Decode(_ []byte, meta DecoderMetadata) (string, string, error) { + if c.onDecode != nil { + c.onDecode(meta) + } + return "", "", nil +} diff --git a/internal/bodydecoder/grpcweb.go b/internal/bodydecoder/grpcweb.go new file mode 100644 index 0000000..5d14e3e --- /dev/null +++ b/internal/bodydecoder/grpcweb.go @@ -0,0 +1,105 @@ +package bodydecoder + +import ( + "encoding/binary" + "fmt" + "strings" +) + +// grpcWebContentTypes are the MIME types handled by the gRPC-Web decoder. +var grpcWebContentTypes = []string{ + "application/grpc-web", + "application/grpc-web+proto", +} + +// GRPCWebDecoder strips gRPC-Web frame headers and delegates the +// extracted payload to a protobuf decoder. +type GRPCWebDecoder struct { + Proto *RawProtobufDecoder +} + +func (d *GRPCWebDecoder) CanDecode(contentType string) bool { + ct := stripParams(contentType) + for _, t := range grpcWebContentTypes { + if ct == t { + return true + } + } + return false +} + +func (d *GRPCWebDecoder) Decode(body []byte, meta DecoderMetadata) (string, string, error) { + payload, notes, err := extractDataFrames(body) + if err != nil { + return "", "", fmt.Errorf("grpc-web frame: %w", err) + } + + var result string + var resultCT string + + if len(payload) > 0 { + decoded, ct, decErr := d.Proto.Decode(payload, meta) + if decErr != nil { + return "", "", fmt.Errorf("grpc-web payload decode: %w", decErr) + } + result = decoded + resultCT = ct + } else { + result = "{}" + resultCT = "application/json" + } + + if len(notes) > 0 { + result += "\n\n// " + strings.Join(notes, "\n// ") + } + return result, resultCT, nil +} + +const ( + frameHeaderLen = 5 + flagData = 0x00 + flagCompressed = 0x01 + flagTrailers = 0x80 +) + +// extractDataFrames parses gRPC-Web framing and returns the concatenated +// data frame payloads. Compressed and trailer frames are skipped with notes. +func extractDataFrames(body []byte) (payload []byte, notes []string, err error) { + remaining := body + for len(remaining) > 0 { + if len(remaining) < frameHeaderLen { + return nil, nil, fmt.Errorf("truncated frame header: %d bytes remaining", len(remaining)) + } + + flag := remaining[0] + length := binary.BigEndian.Uint32(remaining[1:frameHeaderLen]) + remaining = remaining[frameHeaderLen:] + + truncated := false + frameData := remaining + if uint64(len(remaining)) < uint64(length) { + // Truncated frame — decode what we have. + truncated = true + notes = append(notes, fmt.Sprintf("truncated frame: expected %d bytes, got %d", length, len(remaining))) + } else { + frameData = remaining[:length] + remaining = remaining[length:] + } + + switch { + case flag == flagData: + payload = append(payload, frameData...) + case flag == flagCompressed: + notes = append(notes, fmt.Sprintf("[compressed gRPC payload, %d bytes]", length)) + case flag&flagTrailers != 0: + // Trailer frame — skip. + default: + notes = append(notes, fmt.Sprintf("[unknown frame flag 0x%02x, %d bytes]", flag, length)) + } + + if truncated { + break + } + } + return payload, notes, nil +} diff --git a/internal/bodydecoder/grpcweb_test.go b/internal/bodydecoder/grpcweb_test.go new file mode 100644 index 0000000..1356a4f --- /dev/null +++ b/internal/bodydecoder/grpcweb_test.go @@ -0,0 +1,165 @@ +package bodydecoder + +import ( + "encoding/binary" + "encoding/json" + "strings" + "testing" + + "google.golang.org/protobuf/encoding/protowire" +) + +func TestGRPCWebDecoder_CanDecode(t *testing.T) { + d := &GRPCWebDecoder{} + yes := []string{ + "application/grpc-web", + "application/grpc-web+proto", + "application/grpc-web; charset=utf-8", + } + for _, ct := range yes { + if !d.CanDecode(ct) { + t.Errorf("should match %q", ct) + } + } + no := []string{ + "application/protobuf", + "application/grpc", + "text/plain", + } + for _, ct := range no { + if d.CanDecode(ct) { + t.Errorf("should not match %q", ct) + } + } +} + +// buildFrame constructs a gRPC-Web frame: 1-byte flag + 4-byte length + payload. +func buildFrame(flag byte, payload []byte) []byte { + frame := make([]byte, frameHeaderLen+len(payload)) + frame[0] = flag + binary.BigEndian.PutUint32(frame[1:frameHeaderLen], uint32(len(payload))) + copy(frame[frameHeaderLen:], payload) + return frame +} + +func TestGRPCWebDecoder_SingleDataFrame(t *testing.T) { + proto := buildProto(varintField(1, 42)) + body := buildFrame(flagData, proto) + + d := &GRPCWebDecoder{Proto: &RawProtobufDecoder{}} + decoded, ct, err := d.Decode(body, DecoderMetadata{}) + if err != nil { + t.Fatalf("decode: %v", err) + } + if ct != "application/json" { + t.Errorf("content type = %q", ct) + } + + var got map[string]any + if err := json.Unmarshal([]byte(decoded), &got); err != nil { + t.Fatalf("json: %v", err) + } + if got["1"] != float64(42) { + t.Errorf("field 1 = %v", got["1"]) + } +} + +func TestGRPCWebDecoder_MultipleDataFrames(t *testing.T) { + // Two data frames whose payloads concatenate into one protobuf message. + var part1, part2 []byte + part1 = protowire.AppendTag(part1, 1, protowire.VarintType) + part1 = protowire.AppendVarint(part1, 10) + part2 = protowire.AppendTag(part2, 2, protowire.VarintType) + part2 = protowire.AppendVarint(part2, 20) + + body := append(buildFrame(flagData, part1), buildFrame(flagData, part2)...) + + d := &GRPCWebDecoder{Proto: &RawProtobufDecoder{}} + decoded, _, err := d.Decode(body, DecoderMetadata{}) + if err != nil { + t.Fatalf("decode: %v", err) + } + + var got map[string]any + if err := json.Unmarshal([]byte(decoded), &got); err != nil { + t.Fatalf("json: %v", err) + } + if got["1"] != float64(10) || got["2"] != float64(20) { + t.Errorf("fields = %v", got) + } +} + +func TestGRPCWebDecoder_TrailerFrameSkipped(t *testing.T) { + proto := buildProto(varintField(1, 99)) + body := append(buildFrame(flagData, proto), buildFrame(flagTrailers, []byte("grpc-status:0\r\n"))...) + + d := &GRPCWebDecoder{Proto: &RawProtobufDecoder{}} + decoded, _, err := d.Decode(body, DecoderMetadata{}) + if err != nil { + t.Fatalf("decode: %v", err) + } + // Trailer should not affect output. + if strings.Contains(decoded, "grpc-status") { + t.Error("trailer content leaked into output") + } +} + +func TestGRPCWebDecoder_CompressedFrame(t *testing.T) { + fakePayload := []byte("compressed-data") + body := buildFrame(flagCompressed, fakePayload) + + d := &GRPCWebDecoder{Proto: &RawProtobufDecoder{}} + decoded, _, err := d.Decode(body, DecoderMetadata{}) + if err != nil { + t.Fatalf("decode: %v", err) + } + if !strings.Contains(decoded, "[compressed gRPC payload,") { + t.Errorf("missing compressed note: %q", decoded) + } +} + +func TestGRPCWebDecoder_TruncatedFrame(t *testing.T) { + // Build a valid protobuf payload that we'll claim is truncated. + pb := buildProto(varintField(1, 7)) + + // Frame header declares 100 bytes, but only len(pb) bytes follow. + frame := make([]byte, frameHeaderLen+len(pb)) + frame[0] = flagData + binary.BigEndian.PutUint32(frame[1:frameHeaderLen], 100) + copy(frame[frameHeaderLen:], pb) + + d := &GRPCWebDecoder{Proto: &RawProtobufDecoder{}} + decoded, _, err := d.Decode(frame, DecoderMetadata{}) + if err != nil { + t.Fatalf("decode: %v", err) + } + if !strings.Contains(decoded, "truncated") { + t.Errorf("missing truncation note: %q", decoded) + } + // Should still decode the available protobuf data. + if !strings.Contains(decoded, "\"1\"") { + t.Errorf("missing decoded field: %q", decoded) + } +} + +func TestGRPCWebDecoder_EmptyBody(t *testing.T) { + d := &GRPCWebDecoder{Proto: &RawProtobufDecoder{}} + decoded, ct, err := d.Decode([]byte{}, DecoderMetadata{}) + if err != nil { + t.Fatalf("decode: %v", err) + } + if ct != "application/json" { + t.Errorf("content type = %q", ct) + } + if decoded != "{}" { + t.Errorf("expected empty object, got %q", decoded) + } +} + +func TestGRPCWebDecoder_TruncatedHeader(t *testing.T) { + // Only 3 bytes — not enough for a frame header. + _, _, err := extractDataFrames([]byte{0x00, 0x01, 0x02}) + if err == nil { + t.Error("expected error for truncated header") + } +} diff --git a/internal/bodydecoder/protobuf.go b/internal/bodydecoder/protobuf.go new file mode 100644 index 0000000..5bf9e88 --- /dev/null +++ b/internal/bodydecoder/protobuf.go @@ -0,0 +1,232 @@ +package bodydecoder + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "math" + "strings" + + "google.golang.org/protobuf/encoding/protowire" +) + +// protobufContentTypes are the MIME types handled by the raw wire decoder. +var protobufContentTypes = []string{ + "application/protobuf", + "application/x-protobuf", + "application/x-google-protobuf", + "application/grpc", + "application/grpc+proto", +} + +// RawProtobufDecoder decodes protobuf wire format into a JSON-like +// representation using field numbers as keys. When ProtoReg is set and +// the request has a gRPC path, it attempts named message decode first. +type RawProtobufDecoder struct { + ProtoReg *ProtoRegistry +} + +func (d *RawProtobufDecoder) CanDecode(contentType string) bool { + ct := stripParams(contentType) + for _, t := range protobufContentTypes { + if ct == t { + return true + } + } + return false +} + +func (d *RawProtobufDecoder) Decode(body []byte, meta DecoderMetadata) (string, string, error) { + // Try named decode if proto registry is available and we have a gRPC path. + if d.ProtoReg != nil && d.ProtoReg.HasMethods() && meta.RequestPath != "" { + if decoded, err := d.ProtoReg.DecodeNamed(body, meta.RequestPath, meta.IsRequest); err == nil { + return decoded, "application/json", nil + } + // Fall through to raw wire decode on failure. + } + + return d.decodeRaw(body) +} + +// decodeRaw performs raw wire-format decoding (field numbers as keys). +func (d *RawProtobufDecoder) decodeRaw(body []byte) (string, string, error) { + fields, err := decodeWireFields(body) + if err != nil { + return "", "", fmt.Errorf("protobuf wire decode: %w", err) + } + out, err := json.MarshalIndent(fields, "", " ") + if err != nil { + return "", "", fmt.Errorf("protobuf json marshal: %w", err) + } + return string(out), "application/json", nil +} + +// decodeWireFields parses protobuf wire format and returns an ordered map +// of field_number → value(s). Repeated fields produce arrays. +func decodeWireFields(b []byte) (orderedFields, error) { + fields := orderedFields{} + for len(b) > 0 { + num, wtype, n := protowire.ConsumeTag(b) + if n < 0 { + return nil, fmt.Errorf("invalid tag at offset %d", len(b)) + } + b = b[n:] + + var val any + var consumed int + + switch wtype { + case protowire.VarintType: + v, n := protowire.ConsumeVarint(b) + if n < 0 { + return nil, fmt.Errorf("invalid varint for field %d", num) + } + consumed = n + val = v + + case protowire.Fixed32Type: + v, n := protowire.ConsumeFixed32(b) + if n < 0 { + return nil, fmt.Errorf("invalid fixed32 for field %d", num) + } + consumed = n + // Show both uint and float interpretations if float looks meaningful. + if f := math.Float32frombits(v); isReasonableFloat(float64(f)) { + val = f + } else { + val = v + } + + case protowire.Fixed64Type: + v, n := protowire.ConsumeFixed64(b) + if n < 0 { + return nil, fmt.Errorf("invalid fixed64 for field %d", num) + } + consumed = n + if f := math.Float64frombits(v); isReasonableFloat(f) { + val = f + } else { + val = v + } + + case protowire.BytesType: + v, n := protowire.ConsumeBytes(b) + if n < 0 { + return nil, fmt.Errorf("invalid bytes for field %d", num) + } + consumed = n + val = interpretBytes(v) + + case protowire.StartGroupType: + v, n := protowire.ConsumeGroup(num, b) + if n < 0 { + return nil, fmt.Errorf("invalid group for field %d", num) + } + consumed = n + nested, err := decodeWireFields(v) + if err != nil { + return nil, fmt.Errorf("group field %d: %w", num, err) + } + val = nested + + default: + return nil, fmt.Errorf("unknown wire type %d for field %d", wtype, num) + } + + b = b[consumed:] + fields = fields.append(num, val) + } + return fields, nil +} + +// interpretBytes tries to decode a bytes field: first as a nested message, +// then as UTF-8 string, finally as base64. +func interpretBytes(b []byte) any { + // Try nested message. + if nested, err := decodeWireFields(b); err == nil && len(nested) > 0 { + return nested + } + // Try UTF-8 string (reject if contains control chars other than \t \n \r). + if isReadableString(b) { + return string(b) + } + return base64.StdEncoding.EncodeToString(b) +} + +// isReadableString checks if bytes form valid, human-readable UTF-8. +func isReadableString(b []byte) bool { + if len(b) == 0 { + return false + } + for _, c := range b { + if c < 0x20 && c != '\t' && c != '\n' && c != '\r' { + return false + } + } + // Must be valid UTF-8. + return len(b) == len([]rune(string(b))) +} + +// isReasonableFloat returns true if the float value looks like a real number +// (not NaN, not Inf, and has a magnitude that suggests intentional use). +func isReasonableFloat(f float64) bool { + if math.IsNaN(f) || math.IsInf(f, 0) { + return false + } + abs := math.Abs(f) + return abs > 1e-10 && abs < 1e15 +} + +// stripParams removes MIME type parameters (e.g. "; charset=utf-8"). +func stripParams(ct string) string { + if i := strings.IndexByte(ct, ';'); i >= 0 { + ct = ct[:i] + } + return strings.TrimSpace(strings.ToLower(ct)) +} + +// orderedFields preserves field insertion order for JSON output. +// Repeated field numbers produce JSON arrays. +type orderedFields []fieldEntry + +type fieldEntry struct { + Key string + Val any +} + +func (o orderedFields) append(num protowire.Number, val any) orderedFields { + key := fmt.Sprintf("%d", num) + for i, e := range o { + if e.Key == key { + // Convert to array if not already. + switch existing := e.Val.(type) { + case []any: + o[i].Val = append(existing, val) + default: + o[i].Val = []any{existing, val} + } + return o + } + } + return append(o, fieldEntry{Key: key, Val: val}) +} + +func (o orderedFields) MarshalJSON() ([]byte, error) { + var buf strings.Builder + buf.WriteByte('{') + for i, e := range o { + if i > 0 { + buf.WriteByte(',') + } + keyJSON, _ := json.Marshal(e.Key) + buf.Write(keyJSON) + buf.WriteByte(':') + valJSON, err := json.Marshal(e.Val) + if err != nil { + return nil, err + } + buf.Write(valJSON) + } + buf.WriteByte('}') + return []byte(buf.String()), nil +} diff --git a/internal/bodydecoder/protobuf_test.go b/internal/bodydecoder/protobuf_test.go new file mode 100644 index 0000000..c9367b3 --- /dev/null +++ b/internal/bodydecoder/protobuf_test.go @@ -0,0 +1,273 @@ +package bodydecoder + +import ( + "encoding/json" + "math" + "testing" + + "google.golang.org/protobuf/encoding/protowire" +) + +func TestRawProtobufDecoder_CanDecode(t *testing.T) { + d := &RawProtobufDecoder{} + yes := []string{ + "application/protobuf", + "application/x-protobuf", + "application/x-google-protobuf", + "application/grpc", + "application/grpc+proto", + "application/protobuf; charset=utf-8", + "Application/Protobuf", + } + for _, ct := range yes { + if !d.CanDecode(ct) { + t.Errorf("should match %q", ct) + } + } + no := []string{ + "application/json", + "text/html", + "application/grpc-web", + } + for _, ct := range no { + if d.CanDecode(ct) { + t.Errorf("should not match %q", ct) + } + } +} + +// buildProto encodes fields into protobuf wire format. +func buildProto(fields ...func([]byte) []byte) []byte { + var b []byte + for _, f := range fields { + b = f(b) + } + return b +} + +func varintField(num protowire.Number, val uint64) func([]byte) []byte { + return func(b []byte) []byte { + b = protowire.AppendTag(b, num, protowire.VarintType) + b = protowire.AppendVarint(b, val) + return b + } +} + +func bytesField(num protowire.Number, val []byte) func([]byte) []byte { + return func(b []byte) []byte { + b = protowire.AppendTag(b, num, protowire.BytesType) + b = protowire.AppendBytes(b, val) + return b + } +} + +func fixed32Field(num protowire.Number, val uint32) func([]byte) []byte { + return func(b []byte) []byte { + b = protowire.AppendTag(b, num, protowire.Fixed32Type) + b = protowire.AppendFixed32(b, val) + return b + } +} + +func fixed64Field(num protowire.Number, val uint64) func([]byte) []byte { + return func(b []byte) []byte { + b = protowire.AppendTag(b, num, protowire.Fixed64Type) + b = protowire.AppendFixed64(b, val) + return b + } +} + +func TestRawProtobufDecoder_Varint(t *testing.T) { + body := buildProto(varintField(1, 42)) + d := &RawProtobufDecoder{} + decoded, ct, err := d.Decode(body, DecoderMetadata{}) + if err != nil { + t.Fatalf("decode: %v", err) + } + if ct != "application/json" { + t.Errorf("content type = %q", ct) + } + + var got map[string]any + if err := json.Unmarshal([]byte(decoded), &got); err != nil { + t.Fatalf("json parse: %v", err) + } + if v, ok := got["1"]; !ok || v != float64(42) { + t.Errorf("field 1 = %v", got["1"]) + } +} + +func TestRawProtobufDecoder_StringField(t *testing.T) { + body := buildProto(bytesField(2, []byte("hello world"))) + d := &RawProtobufDecoder{} + decoded, _, err := d.Decode(body, DecoderMetadata{}) + if err != nil { + t.Fatalf("decode: %v", err) + } + + var got map[string]any + if err := json.Unmarshal([]byte(decoded), &got); err != nil { + t.Fatalf("json parse: %v", err) + } + if got["2"] != "hello world" { + t.Errorf("field 2 = %v", got["2"]) + } +} + +func TestRawProtobufDecoder_NestedMessage(t *testing.T) { + inner := buildProto(varintField(1, 99)) + body := buildProto( + varintField(1, 1), + bytesField(2, inner), + ) + d := &RawProtobufDecoder{} + decoded, _, err := d.Decode(body, DecoderMetadata{}) + if err != nil { + t.Fatalf("decode: %v", err) + } + + var got map[string]any + if err := json.Unmarshal([]byte(decoded), &got); err != nil { + t.Fatalf("json parse: %v", err) + } + nested, ok := got["2"].(map[string]any) + if !ok { + t.Fatalf("field 2 is not object: %T", got["2"]) + } + if nested["1"] != float64(99) { + t.Errorf("nested field 1 = %v", nested["1"]) + } +} + +func TestRawProtobufDecoder_RepeatedField(t *testing.T) { + body := buildProto( + varintField(1, 10), + varintField(1, 20), + varintField(1, 30), + ) + d := &RawProtobufDecoder{} + decoded, _, err := d.Decode(body, DecoderMetadata{}) + if err != nil { + t.Fatalf("decode: %v", err) + } + + var got map[string]any + if err := json.Unmarshal([]byte(decoded), &got); err != nil { + t.Fatalf("json parse: %v", err) + } + arr, ok := got["1"].([]any) + if !ok { + t.Fatalf("field 1 should be array, got %T", got["1"]) + } + if len(arr) != 3 { + t.Errorf("expected 3 elements, got %d", len(arr)) + } +} + +func TestRawProtobufDecoder_Fixed32Float(t *testing.T) { + val := math.Float32bits(3.14) + body := buildProto(fixed32Field(1, val)) + d := &RawProtobufDecoder{} + decoded, _, err := d.Decode(body, DecoderMetadata{}) + if err != nil { + t.Fatalf("decode: %v", err) + } + + var got map[string]any + if err := json.Unmarshal([]byte(decoded), &got); err != nil { + t.Fatalf("json parse: %v", err) + } + f, ok := got["1"].(float64) + if !ok { + t.Fatalf("field 1 type = %T", got["1"]) + } + if math.Abs(f-3.14) > 0.01 { + t.Errorf("field 1 = %v, want ~3.14", f) + } +} + +func TestRawProtobufDecoder_Fixed64Float(t *testing.T) { + val := math.Float64bits(2.718281828) + body := buildProto(fixed64Field(1, val)) + d := &RawProtobufDecoder{} + decoded, _, err := d.Decode(body, DecoderMetadata{}) + if err != nil { + t.Fatalf("decode: %v", err) + } + + var got map[string]any + if err := json.Unmarshal([]byte(decoded), &got); err != nil { + t.Fatalf("json parse: %v", err) + } + f, ok := got["1"].(float64) + if !ok { + t.Fatalf("field 1 type = %T", got["1"]) + } + if math.Abs(f-2.718281828) > 0.0001 { + t.Errorf("field 1 = %v, want ~2.718", f) + } +} + +func TestRawProtobufDecoder_EmptyBody(t *testing.T) { + d := &RawProtobufDecoder{} + decoded, ct, err := d.Decode([]byte{}, DecoderMetadata{}) + if err != nil { + t.Fatalf("decode: %v", err) + } + if ct != "application/json" { + t.Errorf("content type = %q", ct) + } + if decoded != "{}" { + t.Errorf("expected empty object, got %q", decoded) + } +} + +func TestRawProtobufDecoder_InvalidData(t *testing.T) { + d := &RawProtobufDecoder{} + // 0xFF is not a valid protobuf tag. + _, _, err := d.Decode([]byte{0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x01}, DecoderMetadata{}) + if err == nil { + t.Error("expected error for invalid protobuf data") + } +} + +func TestRawProtobufDecoder_MultipleFieldTypes(t *testing.T) { + body := buildProto( + varintField(1, 42), + bytesField(2, []byte("test")), + fixed32Field(3, 100), + ) + d := &RawProtobufDecoder{} + decoded, _, err := d.Decode(body, DecoderMetadata{}) + if err != nil { + t.Fatalf("decode: %v", err) + } + + var got map[string]any + if err := json.Unmarshal([]byte(decoded), &got); err != nil { + t.Fatalf("json parse: %v", err) + } + if got["1"] != float64(42) { + t.Errorf("field 1 = %v", got["1"]) + } + if got["2"] != "test" { + t.Errorf("field 2 = %v", got["2"]) + } +} + +func TestStripParams(t *testing.T) { + tests := []struct { + input, want string + }{ + {"application/protobuf", "application/protobuf"}, + {"application/protobuf; charset=utf-8", "application/protobuf"}, + {"Application/Protobuf", "application/protobuf"}, + {" TEXT/HTML ; foo=bar ", "text/html"}, + } + for _, tt := range tests { + got := stripParams(tt.input) + if got != tt.want { + t.Errorf("stripParams(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} diff --git a/internal/bodydecoder/protoregistry.go b/internal/bodydecoder/protoregistry.go new file mode 100644 index 0000000..49c68f6 --- /dev/null +++ b/internal/bodydecoder/protoregistry.go @@ -0,0 +1,179 @@ +package bodydecoder + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/bufbuild/protocompile" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/types/dynamicpb" +) + +// ProtoRegistry holds compiled proto file descriptors and provides +// service/method lookup for named message decoding. +type ProtoRegistry struct { + // methods maps "package.Service/Method" → MethodDescriptor. + methods map[string]protoreflect.MethodDescriptor +} + +// LoadProtoFiles compiles .proto files from the given paths into a registry. +// Paths may be files or directories (recursively globbed for *.proto). +// Returns the registry and any non-fatal errors (bad files are skipped). +func LoadProtoFiles(paths []string) (*ProtoRegistry, []error) { + protoFiles, importDirs, errs := resolveProtoPaths(paths) + if len(protoFiles) == 0 { + return &ProtoRegistry{methods: map[string]protoreflect.MethodDescriptor{}}, errs + } + + compiler := protocompile.Compiler{ + Resolver: &protocompile.SourceResolver{ + ImportPaths: importDirs, + }, + } + + compiled, err := compiler.Compile(context.Background(), protoFiles...) + if err != nil { + errs = append(errs, fmt.Errorf("proto compile: %w", err)) + return &ProtoRegistry{methods: map[string]protoreflect.MethodDescriptor{}}, errs + } + + reg := &ProtoRegistry{methods: map[string]protoreflect.MethodDescriptor{}} + for _, f := range compiled { + services := f.Services() + for i := 0; i < services.Len(); i++ { + svc := services.Get(i) + methods := svc.Methods() + for j := 0; j < methods.Len(); j++ { + m := methods.Get(j) + key := string(svc.FullName()) + "/" + string(m.Name()) + reg.methods[key] = m + } + } + } + return reg, errs +} + +// LookupMethod finds a method descriptor by matching the gRPC path suffix. +// The path format is /{prefix}/{package.Service}/{Method}; the last two +// segments are used for lookup. +func (r *ProtoRegistry) LookupMethod(requestPath string) (protoreflect.MethodDescriptor, bool) { + if r == nil || len(r.methods) == 0 { + return nil, false + } + svc, method := extractGRPCPath(requestPath) + if svc == "" || method == "" { + return nil, false + } + m, ok := r.methods[svc+"/"+method] + return m, ok +} + +// DecodeNamed attempts to decode body as a named protobuf message. +// isRequest selects input vs output message type. +func (r *ProtoRegistry) DecodeNamed(body []byte, requestPath string, isRequest bool) (string, error) { + method, ok := r.LookupMethod(requestPath) + if !ok { + return "", fmt.Errorf("no method descriptor for path %q", requestPath) + } + + var msgDesc protoreflect.MessageDescriptor + if isRequest { + msgDesc = method.Input() + } else { + msgDesc = method.Output() + } + + msg := dynamicpb.NewMessage(msgDesc) + if err := proto.Unmarshal(body, msg); err != nil { + return "", fmt.Errorf("unmarshal %s: %w", msgDesc.FullName(), err) + } + + opts := protojson.MarshalOptions{ + Multiline: true, + Indent: " ", + EmitUnpopulated: false, + } + out, err := opts.Marshal(msg) + if err != nil { + return "", fmt.Errorf("marshal json: %w", err) + } + return string(out), nil +} + +// HasMethods returns true if any service methods were loaded. +func (r *ProtoRegistry) HasMethods() bool { + return r != nil && len(r.methods) > 0 +} + +// extractGRPCPath extracts "package.Service" and "Method" from a gRPC path. +// Handles paths with arbitrary prefix: /prefix/package.Service/Method +func extractGRPCPath(path string) (service, method string) { + path = strings.TrimPrefix(path, "/") + parts := strings.Split(path, "/") + if len(parts) < 2 { + return "", "" + } + // Last two segments are service and method. + method = parts[len(parts)-1] + service = parts[len(parts)-2] + if service == "" || method == "" { + return "", "" + } + return service, method +} + +// resolveProtoPaths expands paths into individual .proto files and +// collects import root directories. +func resolveProtoPaths(paths []string) (files []string, importDirs []string, errs []error) { + seen := map[string]bool{} + dirSet := map[string]bool{} + + for _, p := range paths { + info, err := os.Stat(p) + if err != nil { + errs = append(errs, fmt.Errorf("proto path %q: %w", p, err)) + continue + } + + if info.IsDir() { + dirSet[p] = true + err := filepath.WalkDir(p, func(path string, d os.DirEntry, err error) error { + if err != nil { + return nil // skip errors + } + if !d.IsDir() && strings.HasSuffix(path, ".proto") { + rel, relErr := filepath.Rel(p, path) + if relErr != nil { + rel = path + } + if !seen[rel] { + seen[rel] = true + files = append(files, rel) + } + } + return nil + }) + if err != nil { + errs = append(errs, fmt.Errorf("walk %q: %w", p, err)) + } + } else { + dir := filepath.Dir(p) + dirSet[dir] = true + base := filepath.Base(p) + if !seen[base] { + seen[base] = true + files = append(files, base) + } + } + } + + for d := range dirSet { + importDirs = append(importDirs, d) + } + return files, importDirs, errs +} diff --git a/internal/bodydecoder/protoregistry_test.go b/internal/bodydecoder/protoregistry_test.go new file mode 100644 index 0000000..46fa400 --- /dev/null +++ b/internal/bodydecoder/protoregistry_test.go @@ -0,0 +1,222 @@ +package bodydecoder + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestLoadProtoFiles_SingleFile(t *testing.T) { + reg, errs := LoadProtoFiles([]string{"testdata/test.proto"}) + if len(errs) > 0 { + t.Fatalf("unexpected errors: %v", errs) + } + if !reg.HasMethods() { + t.Fatal("expected methods to be loaded") + } + m, ok := reg.LookupMethod("/testpkg.Greeter/SayHello") + if !ok { + t.Fatal("SayHello not found") + } + if string(m.Input().FullName()) != "testpkg.HelloRequest" { + t.Errorf("input = %s", m.Input().FullName()) + } + if string(m.Output().FullName()) != "testpkg.HelloReply" { + t.Errorf("output = %s", m.Output().FullName()) + } +} + +func TestLoadProtoFiles_Directory(t *testing.T) { + reg, errs := LoadProtoFiles([]string{"testdata"}) + if len(errs) > 0 { + t.Fatalf("unexpected errors: %v", errs) + } + if !reg.HasMethods() { + t.Fatal("expected methods from directory scan") + } +} + +func TestLoadProtoFiles_BadPath(t *testing.T) { + reg, errs := LoadProtoFiles([]string{"/nonexistent/path"}) + if len(errs) == 0 { + t.Error("expected error for bad path") + } + // Registry should still be usable (empty). + if reg.HasMethods() { + t.Error("expected no methods") + } +} + +func TestLoadProtoFiles_EmptyPaths(t *testing.T) { + reg, errs := LoadProtoFiles(nil) + if len(errs) > 0 { + t.Errorf("unexpected errors: %v", errs) + } + if reg.HasMethods() { + t.Error("expected no methods") + } +} + +func TestLoadProtoFiles_InvalidProto(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "bad.proto"), []byte("this is not proto"), 0o600); err != nil { + t.Fatal(err) + } + _, errs := LoadProtoFiles([]string{dir}) + if len(errs) == 0 { + t.Error("expected errors for invalid proto syntax") + } +} + +func TestLookupMethod_PrefixedPath(t *testing.T) { + reg, errs := LoadProtoFiles([]string{"testdata/test.proto"}) + if len(errs) > 0 { + t.Fatalf("unexpected errors: %v", errs) + } + // Path with arbitrary prefix. + m, ok := reg.LookupMethod("/api/v1/testpkg.Greeter/SayHello") + if !ok { + t.Fatal("should match with prefix") + } + if string(m.Name()) != "SayHello" { + t.Errorf("method name = %s", m.Name()) + } +} + +func TestLookupMethod_NoMatch(t *testing.T) { + reg, _ := LoadProtoFiles([]string{"testdata/test.proto"}) + _, ok := reg.LookupMethod("/unknown.Service/Method") + if ok { + t.Error("should not match unknown service") + } +} + +func TestLookupMethod_EmptyPath(t *testing.T) { + reg, _ := LoadProtoFiles([]string{"testdata/test.proto"}) + _, ok := reg.LookupMethod("") + if ok { + t.Error("should not match empty path") + } +} + +func TestDecodeNamed_Request(t *testing.T) { + reg, errs := LoadProtoFiles([]string{"testdata/test.proto"}) + if len(errs) > 0 { + t.Fatalf("errors: %v", errs) + } + + // Build a HelloRequest: name="Alice", age=30 + body := buildProto( + bytesField(1, []byte("Alice")), + varintField(2, 30), + ) + + decoded, err := reg.DecodeNamed(body, "/testpkg.Greeter/SayHello", true) + if err != nil { + t.Fatalf("decode: %v", err) + } + + var got map[string]any + if err := json.Unmarshal([]byte(decoded), &got); err != nil { + t.Fatalf("json: %v", err) + } + if got["name"] != "Alice" { + t.Errorf("name = %v", got["name"]) + } + if got["age"] != float64(30) { + t.Errorf("age = %v", got["age"]) + } +} + +func TestDecodeNamed_Response(t *testing.T) { + reg, errs := LoadProtoFiles([]string{"testdata/test.proto"}) + if len(errs) > 0 { + t.Fatalf("errors: %v", errs) + } + + // Build a HelloReply: message="Hi!", success=true + body := buildProto( + bytesField(1, []byte("Hi!")), + varintField(2, 1), + ) + + decoded, err := reg.DecodeNamed(body, "/testpkg.Greeter/SayHello", false) + if err != nil { + t.Fatalf("decode: %v", err) + } + + var got map[string]any + if err := json.Unmarshal([]byte(decoded), &got); err != nil { + t.Fatalf("json: %v", err) + } + if got["message"] != "Hi!" { + t.Errorf("message = %v", got["message"]) + } + if got["success"] != true { + t.Errorf("success = %v", got["success"]) + } +} + +func TestRawProtobufDecoder_NamedFallbackToRaw(t *testing.T) { + reg, _ := LoadProtoFiles([]string{"testdata/test.proto"}) + d := &RawProtobufDecoder{ProtoReg: reg} + + // Unknown path: should fall back to raw wire decode. + body := buildProto(varintField(1, 42)) + decoded, _, err := d.Decode(body, DecoderMetadata{RequestPath: "/unknown.Svc/Method"}) + if err != nil { + t.Fatalf("decode: %v", err) + } + // Raw decode uses field numbers. + if !strings.Contains(decoded, `"1"`) { + t.Errorf("expected raw field number in output: %s", decoded) + } +} + +func TestRawProtobufDecoder_NamedDecode(t *testing.T) { + reg, _ := LoadProtoFiles([]string{"testdata/test.proto"}) + d := &RawProtobufDecoder{ProtoReg: reg} + + body := buildProto( + bytesField(1, []byte("Bob")), + varintField(2, 25), + ) + decoded, ct, err := d.Decode(body, DecoderMetadata{ + RequestPath: "/testpkg.Greeter/SayHello", + IsRequest: true, + }) + if err != nil { + t.Fatalf("decode: %v", err) + } + if ct != "application/json" { + t.Errorf("content type = %q", ct) + } + // Named decode uses field names. + if !strings.Contains(decoded, `"name"`) { + t.Errorf("expected named field: %s", decoded) + } +} + +func TestExtractGRPCPath(t *testing.T) { + tests := []struct { + path string + service string + method string + }{ + {"/pkg.Svc/Method", "pkg.Svc", "Method"}, + {"/api/v1/pkg.Svc/Method", "pkg.Svc", "Method"}, + {"/Method", "", ""}, + {"", "", ""}, + {"/", "", ""}, + {"//", "", ""}, + } + for _, tt := range tests { + svc, method := extractGRPCPath(tt.path) + if svc != tt.service || method != tt.method { + t.Errorf("extractGRPCPath(%q) = (%q, %q), want (%q, %q)", + tt.path, svc, method, tt.service, tt.method) + } + } +} diff --git a/internal/bodydecoder/testdata/test.proto b/internal/bodydecoder/testdata/test.proto new file mode 100644 index 0000000..7a69137 --- /dev/null +++ b/internal/bodydecoder/testdata/test.proto @@ -0,0 +1,19 @@ +syntax = "proto3"; + +package testpkg; + +option go_package = "github.com/kostyay/httpmon/internal/e2e/testpb"; + +service Greeter { + rpc SayHello (HelloRequest) returns (HelloReply) {} +} + +message HelloRequest { + string name = 1; + int32 age = 2; +} + +message HelloReply { + string message = 1; + bool success = 2; +} diff --git a/internal/config/config.go b/internal/config/config.go index 67ca328..17fba09 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -20,14 +20,15 @@ const ( // Config holds persistent httpmon settings stored in ~/.httpmon/config.json. type Config struct { - ProxyPort int `json:"proxy_port"` - MCPEnabled bool `json:"mcp_enabled"` - MCPAddr string `json:"mcp_addr"` - MCPToken string `json:"mcp_token"` - BufferSize int `json:"buffer_size"` - ThrottlePreset string `json:"throttle_preset"` - ListMode string `json:"list_mode"` - TreeGroupBy string `json:"tree_group_by"` + ProxyPort int `json:"proxy_port"` + MCPEnabled bool `json:"mcp_enabled"` + MCPAddr string `json:"mcp_addr"` + MCPToken string `json:"mcp_token"` + BufferSize int `json:"buffer_size"` + ThrottlePreset string `json:"throttle_preset"` + ListMode string `json:"list_mode"` + TreeGroupBy string `json:"tree_group_by"` + ProtoPaths []string `json:"proto_paths,omitempty"` } // DefaultConfig returns a Config with sensible defaults. diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 2e59629..72b34fe 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -5,6 +5,7 @@ import ( "flag" "os" "path/filepath" + "slices" "testing" ) @@ -46,7 +47,11 @@ func TestSaveLoadRoundtrip(t *testing.T) { if err != nil { t.Fatalf("Load: %v", err) } - if *got != *orig { + if got.ProxyPort != orig.ProxyPort || got.MCPEnabled != orig.MCPEnabled || + got.MCPAddr != orig.MCPAddr || got.MCPToken != orig.MCPToken || + got.BufferSize != orig.BufferSize || got.ThrottlePreset != orig.ThrottlePreset || + got.ListMode != orig.ListMode || got.TreeGroupBy != orig.TreeGroupBy || + !slices.Equal(got.ProtoPaths, orig.ProtoPaths) { t.Errorf("roundtrip mismatch:\n got %+v\nwant %+v", got, orig) } } @@ -220,3 +225,54 @@ func TestLoad_UnreadableFile(t *testing.T) { t.Fatal("expected error for unreadable file") } } + +func TestProtoPaths_RoundTrip(t *testing.T) { + dir := t.TempDir() + cfg := DefaultConfig() + cfg.ProtoPaths = []string{"/home/user/protos", "/home/user/api/service.proto"} + + if err := cfg.Save(dir); err != nil { + t.Fatalf("save: %v", err) + } + loaded, err := Load(dir) + if err != nil { + t.Fatalf("load: %v", err) + } + if !slices.Equal(loaded.ProtoPaths, cfg.ProtoPaths) { + t.Errorf("ProtoPaths = %v, want %v", loaded.ProtoPaths, cfg.ProtoPaths) + } +} + +func TestProtoPaths_OmittedWhenEmpty(t *testing.T) { + dir := t.TempDir() + cfg := DefaultConfig() + if err := cfg.Save(dir); err != nil { + t.Fatalf("save: %v", err) + } + + data, err := os.ReadFile(filepath.Join(dir, configFile)) + if err != nil { + t.Fatalf("read: %v", err) + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if _, ok := raw["proto_paths"]; ok { + t.Error("proto_paths should be omitted when empty") + } +} + +func TestProtoPaths_MissingFieldLoadsNil(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, configFile), []byte(`{"proxy_port":8080}`), 0o600); err != nil { + t.Fatal(err) + } + loaded, err := Load(dir) + if err != nil { + t.Fatalf("load: %v", err) + } + if len(loaded.ProtoPaths) != 0 { + t.Errorf("expected nil/empty ProtoPaths, got %v", loaded.ProtoPaths) + } +} diff --git a/internal/e2e/capture_test.go b/internal/e2e/capture_test.go index bcccdaa..dd53d6d 100644 --- a/internal/e2e/capture_test.go +++ b/internal/e2e/capture_test.go @@ -131,13 +131,14 @@ func TestE2E_RapidRequests(t *testing.T) { t.Parallel() h := newHarness(t, echoHandler()) + // Fire 20 requests rapidly (sequential, not concurrent — concurrent + // goroutines are flaky under heavy parallel test load when the proxy + // is slow to accept connections). for i := range 20 { - go func(n int) { - resp, err := h.client.Get(h.upstream.URL + "/rapid/" + http.StatusText(n)) - if err == nil { - resp.Body.Close() - } - }(i) + resp, err := h.client.Get(h.upstream.URL + "/rapid/" + http.StatusText(i)) + if err == nil { + resp.Body.Close() + } } // Wait for all 20 to appear in the store. diff --git a/internal/e2e/grpc_test.go b/internal/e2e/grpc_test.go new file mode 100644 index 0000000..1d57a7d --- /dev/null +++ b/internal/e2e/grpc_test.go @@ -0,0 +1,272 @@ +//go:build e2e + +package e2e + +import ( + "context" + "fmt" + "net/http" + "path/filepath" + "runtime" + "strings" + "testing" + + "connectrpc.com/connect" + + "github.com/kostyay/httpmon/internal/bodydecoder" + "github.com/kostyay/httpmon/internal/e2e/testpb" + "github.com/kostyay/httpmon/internal/e2e/testpb/testpbconnect" + + tea "charm.land/bubbletea/v2" +) + +// greeterServer implements the Greeter Connect service. +type greeterServer struct{} + +func (s *greeterServer) SayHello(_ context.Context, req *connect.Request[testpb.HelloRequest]) (*connect.Response[testpb.HelloReply], error) { + msg := fmt.Sprintf("Hello, %s! You are %d years old.", req.Msg.Name, req.Msg.Age) + return connect.NewResponse(&testpb.HelloReply{ + Message: msg, + Success: true, + }), nil +} + +// testProtoDir returns the absolute path to the bodydecoder testdata directory. +func testProtoDir() string { + _, file, _, _ := runtime.Caller(0) + return filepath.Join(filepath.Dir(file), "..", "bodydecoder", "testdata") +} + +// grpcHarness extends harness with a gRPC-Web Connect client. +type grpcHarness struct { + *harness + greeter testpbconnect.GreeterClient +} + +// newGRPCDecoderRegistry builds a body decoder registry for gRPC-Web. +// If protoPaths is non-empty, named field decoding is enabled. +func newGRPCDecoderRegistry(t *testing.T, protoPaths []string) *bodydecoder.Registry { + t.Helper() + protoDec := &bodydecoder.RawProtobufDecoder{} + if len(protoPaths) > 0 { + protoReg, errs := bodydecoder.LoadProtoFiles(protoPaths) + for _, e := range errs { + t.Logf("proto load warning: %v", e) + } + protoDec.ProtoReg = protoReg + } + grpcDec := &bodydecoder.GRPCWebDecoder{Proto: protoDec} + return bodydecoder.NewRegistry(grpcDec, protoDec) +} + +// newGRPCHarness creates a harness with a real Connect gRPC-Web server and client. +// If protoPaths is non-empty, named field decoding is enabled. +func newGRPCHarness(t *testing.T, protoPaths []string) *grpcHarness { + t.Helper() + + // Set up Connect gRPC-Web server on a mux. + mux := http.NewServeMux() + path, handler := testpbconnect.NewGreeterHandler(&greeterServer{}) + mux.Handle(path, handler) + // Also serve a plain JSON endpoint for regression test. + mux.HandleFunc("/json", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"items":[1,2,3],"ok":true}`) + }) + + h := newHarness(t, mux, withBodyDecoder(newGRPCDecoderRegistry(t, protoPaths))) + + // Create Connect gRPC-Web client routed through the proxy. + // Disable gzip so responses are uncompressed (our decoder doesn't handle compressed gRPC frames). + greeter := testpbconnect.NewGreeterClient( + h.client, + h.upstream.URL, + connect.WithGRPCWeb(), + connect.WithAcceptCompression("gzip", nil, nil), + ) + + return &grpcHarness{harness: h, greeter: greeter} +} + +// callSayHello makes a SayHello gRPC-Web call through the proxy. +func (g *grpcHarness) callSayHello(t *testing.T, name string, age int32) { + t.Helper() + _, err := g.greeter.SayHello(context.Background(), connect.NewRequest(&testpb.HelloRequest{ + Name: name, + Age: age, + })) + if err != nil { + t.Fatalf("SayHello: %v", err) + } +} + +// openResponseTab opens the detail view and switches to the response tab. +func (g *grpcHarness) openResponseTab() { + g.sendSpecialKey(tea.KeyEnter) + g.sendKey("2") + g.tick() +} + +// assertNoBinaryFallback fails if the view shows the "[binary content" marker, +// meaning the gRPC decoder did not run. +func (g *grpcHarness) assertNoBinaryFallback(t *testing.T) { + t.Helper() + if strings.Contains(g.view(), "[binary content") { + t.Error("should decode gRPC body, not show [binary content]") + } +} + +func TestGRPCWebDecodeResponse(t *testing.T) { + t.Parallel() + g := newGRPCHarness(t, []string{testProtoDir()}) + + g.callSayHello(t, "Alice", 30) + g.waitForText(t, "Greeter") + g.openResponseTab() + + view := g.view() + // Named field decode should show field names as JSON. + if !strings.Contains(view, "message") { + t.Errorf("response should contain decoded field 'message', got:\n%s", view) + } + if !strings.Contains(view, "success") { + t.Errorf("response should contain decoded field 'success', got:\n%s", view) + } + g.assertNoBinaryFallback(t) +} + +func TestGRPCWebDecodeRequest(t *testing.T) { + t.Parallel() + g := newGRPCHarness(t, []string{testProtoDir()}) + + g.callSayHello(t, "Bob", 25) + g.waitForText(t, "Greeter") + + // Open detail view — request tab is default (tab 0). + g.sendSpecialKey(tea.KeyEnter) + g.tick() + + view := g.view() + if !strings.Contains(view, "name") { + t.Errorf("request should contain decoded field 'name', got:\n%s", view) + } + if !strings.Contains(view, "Bob") { + t.Errorf("request should contain value 'Bob', got:\n%s", view) + } + g.assertNoBinaryFallback(t) +} + +func TestGRPCWebRawToggle(t *testing.T) { + t.Parallel() + g := newGRPCHarness(t, []string{testProtoDir()}) + + g.callSayHello(t, "Charlie", 40) + g.waitForText(t, "Greeter") + g.openResponseTab() + + // Pretty mode — should show decoded JSON. + prettyView := g.view() + if !strings.Contains(prettyView, "message") { + t.Fatal("pretty mode should show decoded field names") + } + + // Toggle to raw mode. + g.sendKey("p") + g.tick() + rawView := g.view() + + // Raw mode shows the raw bytes through the decoder, which may still decode + // but without pretty-printing, or show the original binary. + if prettyView == rawView { + t.Error("raw toggle should change the view") + } + + // Toggle back. + g.sendKey("p") + g.tick() + backView := g.view() + if !strings.Contains(backView, "message") { + t.Error("toggling back should restore decoded view") + } +} + +func TestGRPCWebWithoutProtoFiles(t *testing.T) { + t.Parallel() + // No proto paths — raw wire-format decode. + g := newGRPCHarness(t, nil) + + g.callSayHello(t, "Dave", 35) + g.waitForText(t, "Greeter") + g.openResponseTab() + + view := g.view() + // Without proto files, fields show as numbers, not names. + // Field 1 = message (string), field 2 = success (bool). + if !strings.Contains(view, `"1"`) && !strings.Contains(view, `"2"`) { + t.Errorf("raw wire decode should show field numbers, got:\n%s", view) + } + // Should NOT show named fields. + if strings.Contains(view, `"message"`) { + t.Error("without proto files, should NOT show named field 'message'") + } + g.assertNoBinaryFallback(t) +} + +func TestGRPCWebDecodeError(t *testing.T) { + t.Parallel() + + // Custom upstream that returns grpc-web content type with garbage bytes. + garbage := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/grpc-web+proto") + w.Write([]byte{0xFF, 0xFF, 0xFF, 0xFF}) //nolint:errcheck + }) + + h := newHarness(t, garbage, withBodyDecoder(newGRPCDecoderRegistry(t, nil))) + h.doGet(t, "/bad-grpc") + h.waitForText(t, "/bad-grpc") + + h.sendSpecialKey(tea.KeyEnter) + h.sendKey("2") + h.tick() + + view := h.view() + // Status bar should show a proto decode error. + if !strings.Contains(view, "proto:") && !strings.Contains(view, "grpc-web") && !strings.Contains(view, "truncated") { + t.Errorf("should show decode error in status bar, got:\n%s", view) + } +} + +func TestGRPCWebNonProtoUnchanged(t *testing.T) { + t.Parallel() + g := newGRPCHarness(t, []string{testProtoDir()}) + + // Make a regular JSON GET — should render normally. + g.doGet(t, "/json") + g.waitForText(t, "/json") + g.openResponseTab() + + view := g.view() + if !strings.Contains(view, "items") { + t.Errorf("JSON response should render normally, got:\n%s", view) + } + if !strings.Contains(view, "ok") { + t.Errorf("JSON response should contain 'ok', got:\n%s", view) + } +} + +func TestGRPCWebInvalidProtoPath(t *testing.T) { + t.Parallel() + // Invalid proto path — decoder falls back to raw wire decode. + g := newGRPCHarness(t, []string{"/nonexistent/proto/path"}) + + g.callSayHello(t, "Eve", 28) + g.waitForText(t, "Greeter") + g.openResponseTab() + + // Should still decode (raw wire format), not crash or show binary. + g.assertNoBinaryFallback(t) + // Without valid protos, no named fields. + if strings.Contains(g.view(), `"message"`) { + t.Error("invalid proto path should not produce named fields") + } +} diff --git a/internal/e2e/harness_test.go b/internal/e2e/harness_test.go index 682478d..cdfd715 100644 --- a/internal/e2e/harness_test.go +++ b/internal/e2e/harness_test.go @@ -25,6 +25,18 @@ import ( "github.com/kostyay/httpmon/internal/tui" ) +// harnessOpt configures optional harness behavior. +type harnessOpt func(*harnessConfig) + +type harnessConfig struct { + bodyDecoder tui.BodyDecoderRegistry +} + +// withBodyDecoder injects a body decoder registry into the TUI app. +func withBodyDecoder(reg tui.BodyDecoderRegistry) harnessOpt { + return func(c *harnessConfig) { c.bodyDecoder = reg } +} + // harness wires a real upstream server, MITM proxy, store, and TUI app. type harness struct { upstream *httptest.Server @@ -36,7 +48,7 @@ type harness struct { proxyAddr string } -func newHarness(t *testing.T, handler http.Handler) *harness { +func newHarness(t *testing.T, handler http.Handler, opts ...harnessOpt) *harness { t.Helper() upstream := httptest.NewServer(handler) @@ -58,11 +70,17 @@ func newHarness(t *testing.T, handler http.Handler) *harness { waitForListener(t, addr) + var cfg harnessConfig + for _, o := range opts { + o(&cfg) + } + app := tui.NewApp(tui.AppConfig{ - Store: s, - Proxy: p, - CATrusted: true, - Throttle: p, + Store: s, + Proxy: p, + CATrusted: true, + Throttle: p, + BodyDecoder: cfg.bodyDecoder, }) app.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) diff --git a/internal/e2e/testpb/test.pb.go b/internal/e2e/testpb/test.pb.go new file mode 100644 index 0000000..b56dc88 --- /dev/null +++ b/internal/e2e/testpb/test.pb.go @@ -0,0 +1,194 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: test.proto + +package testpb + +import ( + reflect "reflect" + sync "sync" + unsafe "unsafe" + + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type HelloRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Age int32 `protobuf:"varint,2,opt,name=age,proto3" json:"age,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *HelloRequest) Reset() { + *x = HelloRequest{} + mi := &file_test_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *HelloRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HelloRequest) ProtoMessage() {} + +func (x *HelloRequest) ProtoReflect() protoreflect.Message { + mi := &file_test_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HelloRequest.ProtoReflect.Descriptor instead. +func (*HelloRequest) Descriptor() ([]byte, []int) { + return file_test_proto_rawDescGZIP(), []int{0} +} + +func (x *HelloRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *HelloRequest) GetAge() int32 { + if x != nil { + return x.Age + } + return 0 +} + +type HelloReply struct { + state protoimpl.MessageState `protogen:"open.v1"` + Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"` + Success bool `protobuf:"varint,2,opt,name=success,proto3" json:"success,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *HelloReply) Reset() { + *x = HelloReply{} + mi := &file_test_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *HelloReply) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HelloReply) ProtoMessage() {} + +func (x *HelloReply) ProtoReflect() protoreflect.Message { + mi := &file_test_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HelloReply.ProtoReflect.Descriptor instead. +func (*HelloReply) Descriptor() ([]byte, []int) { + return file_test_proto_rawDescGZIP(), []int{1} +} + +func (x *HelloReply) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *HelloReply) GetSuccess() bool { + if x != nil { + return x.Success + } + return false +} + +var File_test_proto protoreflect.FileDescriptor + +const file_test_proto_rawDesc = "" + + "\n" + + "\n" + + "test.proto\x12\atestpkg\"4\n" + + "\fHelloRequest\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x10\n" + + "\x03age\x18\x02 \x01(\x05R\x03age\"@\n" + + "\n" + + "HelloReply\x12\x18\n" + + "\amessage\x18\x01 \x01(\tR\amessage\x12\x18\n" + + "\asuccess\x18\x02 \x01(\bR\asuccess2C\n" + + "\aGreeter\x128\n" + + "\bSayHello\x12\x15.testpkg.HelloRequest\x1a\x13.testpkg.HelloReply\"\x00B0Z.github.com/kostyay/httpmon/internal/e2e/testpbb\x06proto3" + +var ( + file_test_proto_rawDescOnce sync.Once + file_test_proto_rawDescData []byte +) + +func file_test_proto_rawDescGZIP() []byte { + file_test_proto_rawDescOnce.Do(func() { + file_test_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_test_proto_rawDesc), len(file_test_proto_rawDesc))) + }) + return file_test_proto_rawDescData +} + +var file_test_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_test_proto_goTypes = []any{ + (*HelloRequest)(nil), // 0: testpkg.HelloRequest + (*HelloReply)(nil), // 1: testpkg.HelloReply +} +var file_test_proto_depIdxs = []int32{ + 0, // 0: testpkg.Greeter.SayHello:input_type -> testpkg.HelloRequest + 1, // 1: testpkg.Greeter.SayHello:output_type -> testpkg.HelloReply + 1, // [1:2] is the sub-list for method output_type + 0, // [0:1] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_test_proto_init() } +func file_test_proto_init() { + if File_test_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_test_proto_rawDesc), len(file_test_proto_rawDesc)), + NumEnums: 0, + NumMessages: 2, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_test_proto_goTypes, + DependencyIndexes: file_test_proto_depIdxs, + MessageInfos: file_test_proto_msgTypes, + }.Build() + File_test_proto = out.File + file_test_proto_goTypes = nil + file_test_proto_depIdxs = nil +} diff --git a/internal/e2e/testpb/testpbconnect/test.connect.go b/internal/e2e/testpb/testpbconnect/test.connect.go new file mode 100644 index 0000000..17ebdb3 --- /dev/null +++ b/internal/e2e/testpb/testpbconnect/test.connect.go @@ -0,0 +1,109 @@ +// Code generated by protoc-gen-connect-go. DO NOT EDIT. +// +// Source: test.proto + +package testpbconnect + +import ( + context "context" + errors "errors" + http "net/http" + strings "strings" + + connect "connectrpc.com/connect" + testpb "github.com/kostyay/httpmon/internal/e2e/testpb" +) + +// This is a compile-time assertion to ensure that this generated file and the connect package are +// compatible. If you get a compiler error that this constant is not defined, this code was +// generated with a version of connect newer than the one compiled into your binary. You can fix the +// problem by either regenerating this code with an older version of connect or updating the connect +// version compiled into your binary. +const _ = connect.IsAtLeastVersion1_13_0 + +const ( + // GreeterName is the fully-qualified name of the Greeter service. + GreeterName = "testpkg.Greeter" +) + +// These constants are the fully-qualified names of the RPCs defined in this package. They're +// exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route. +// +// Note that these are different from the fully-qualified method names used by +// google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to +// reflection-formatted method names, remove the leading slash and convert the remaining slash to a +// period. +const ( + // GreeterSayHelloProcedure is the fully-qualified name of the Greeter's SayHello RPC. + GreeterSayHelloProcedure = "/testpkg.Greeter/SayHello" +) + +// GreeterClient is a client for the testpkg.Greeter service. +type GreeterClient interface { + SayHello(context.Context, *connect.Request[testpb.HelloRequest]) (*connect.Response[testpb.HelloReply], error) +} + +// NewGreeterClient constructs a client for the testpkg.Greeter service. By default, it uses the +// Connect protocol with the binary Protobuf Codec, asks for gzipped responses, and sends +// uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the connect.WithGRPC() or +// connect.WithGRPCWeb() options. +// +// The URL supplied here should be the base URL for the Connect or gRPC server (for example, +// http://api.acme.com or https://acme.com/grpc). +func NewGreeterClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) GreeterClient { + baseURL = strings.TrimRight(baseURL, "/") + greeterMethods := testpb.File_test_proto.Services().ByName("Greeter").Methods() + return &greeterClient{ + sayHello: connect.NewClient[testpb.HelloRequest, testpb.HelloReply]( + httpClient, + baseURL+GreeterSayHelloProcedure, + connect.WithSchema(greeterMethods.ByName("SayHello")), + connect.WithClientOptions(opts...), + ), + } +} + +// greeterClient implements GreeterClient. +type greeterClient struct { + sayHello *connect.Client[testpb.HelloRequest, testpb.HelloReply] +} + +// SayHello calls testpkg.Greeter.SayHello. +func (c *greeterClient) SayHello(ctx context.Context, req *connect.Request[testpb.HelloRequest]) (*connect.Response[testpb.HelloReply], error) { + return c.sayHello.CallUnary(ctx, req) +} + +// GreeterHandler is an implementation of the testpkg.Greeter service. +type GreeterHandler interface { + SayHello(context.Context, *connect.Request[testpb.HelloRequest]) (*connect.Response[testpb.HelloReply], error) +} + +// NewGreeterHandler builds an HTTP handler from the service implementation. It returns the path on +// which to mount the handler and the handler itself. +// +// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf +// and JSON codecs. They also support gzip compression. +func NewGreeterHandler(svc GreeterHandler, opts ...connect.HandlerOption) (string, http.Handler) { + greeterMethods := testpb.File_test_proto.Services().ByName("Greeter").Methods() + greeterSayHelloHandler := connect.NewUnaryHandler( + GreeterSayHelloProcedure, + svc.SayHello, + connect.WithSchema(greeterMethods.ByName("SayHello")), + connect.WithHandlerOptions(opts...), + ) + return "/testpkg.Greeter/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case GreeterSayHelloProcedure: + greeterSayHelloHandler.ServeHTTP(w, r) + default: + http.NotFound(w, r) + } + }) +} + +// UnimplementedGreeterHandler returns CodeUnimplemented from all methods. +type UnimplementedGreeterHandler struct{} + +func (UnimplementedGreeterHandler) SayHello(context.Context, *connect.Request[testpb.HelloRequest]) (*connect.Response[testpb.HelloReply], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("testpkg.Greeter.SayHello is not implemented")) +} diff --git a/internal/highlight/highlight.go b/internal/highlight/highlight.go index fdb5d50..8fd7ef1 100644 --- a/internal/highlight/highlight.go +++ b/internal/highlight/highlight.go @@ -115,11 +115,6 @@ var binaryMIMETypes = map[string]bool{ "application/pdf": true, "application/wasm": true, "application/x-shockwave-flash": true, - "application/x-protobuf": true, - "application/protobuf": true, - "application/x-google-protobuf": true, - "application/grpc": true, - "application/grpc+proto": true, "application/vnd.ms-fontobject": true, "application/x-font-ttf": true, "application/x-font-opentype": true, diff --git a/internal/tui/app.go b/internal/tui/app.go index 4b4b64e..ccc8201 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -140,6 +140,9 @@ type App struct { detailRaw bool // false=pretty-print, true=raw detailImagePreview bool // true=show image as terminal art detailCollapsed map[string]bool // collapsed sections: "general", "headers", "body" + detailDecodeErr string // proto decode error for status bar + + bodyDecoder BodyDecoderRegistry width, height int ready bool @@ -155,6 +158,7 @@ type AppConfig struct { Breakpoints breakpoint.Controller MCP MCPServer DataDir string + BodyDecoder BodyDecoderRegistry } // NewApp creates a TUI application from the given config. @@ -175,6 +179,7 @@ func NewApp(cfg AppConfig) *App { throttle: cfg.Throttle, mcp: cfg.MCP, dataDir: cfg.DataDir, + bodyDecoder: cfg.BodyDecoder, filterInput: ti, searchInput: si, groupExpanded: make(map[string]bool), @@ -815,8 +820,15 @@ func (a *App) updateDetailContent() { a.detailImagePreview = false } - darkBg := lipgloss.HasDarkBackground(os.Stdin, os.Stdout) - a.detailVP.SetContent(renderDetailBody(meta, data, a.detailTab, a.width, darkBg, !a.detailRaw, a.detailCollapsed)) + opts := renderOpts{ + DarkBg: lipgloss.HasDarkBackground(os.Stdin, os.Stdout), + PrettyJSON: !a.detailRaw, + Collapsed: a.detailCollapsed, + Decoder: a.bodyDecoder, + } + content, decErr := renderDetailBody(meta, data, a.detailTab, opts) + a.detailDecodeErr = decErr + a.detailVP.SetContent(content) } func (a *App) proxyAddr() string { diff --git a/internal/tui/detail.go b/internal/tui/detail.go index e346b40..979fa88 100644 --- a/internal/tui/detail.go +++ b/internal/tui/detail.go @@ -1,10 +1,12 @@ package tui import ( + "errors" "fmt" "sort" "strings" + "github.com/kostyay/httpmon/internal/bodydecoder" "github.com/kostyay/httpmon/internal/highlight" "github.com/kostyay/httpmon/internal/store" ) @@ -70,6 +72,8 @@ func (a *App) viewDetail() string { } else { bar = fmt.Sprintf("search: %s %s n/N:next/prev Esc:clear", a.searchQuery, matchInfo) } + } else if a.detailDecodeErr != "" { + bar = fmt.Sprintf("proto: %s %s", a.detailDecodeErr, scrollPct) } else { bar = fmt.Sprintf("1/2 tabs p:%s e:edit%s g/h/b:sections Space:actions Esc back %s", mode, imageHint, scrollPct) } @@ -107,26 +111,35 @@ func sectionIcon(collapsed bool) string { return "▾" } -func renderDetailBody(meta *store.FlowMeta, data *store.FlowData, tab int, width int, darkBg bool, prettyJSON bool, collapsed map[string]bool) string { +// renderOpts bundles display options threaded through the detail render pipeline. +type renderOpts struct { + DarkBg bool + PrettyJSON bool + Collapsed map[string]bool + Decoder BodyDecoderRegistry +} + +func renderDetailBody(meta *store.FlowMeta, data *store.FlowData, tab int, opts renderOpts) (string, string) { if meta == nil { - return "Flow no longer available." + return "Flow no longer available.", "" } var b strings.Builder + var decErr string if tab == 0 { - renderRequestDetail(&b, meta, data, darkBg, prettyJSON, collapsed) + decErr = renderRequestDetail(&b, meta, data, opts) } else { - renderResponseDetail(&b, meta, data, darkBg, prettyJSON, collapsed) + decErr = renderResponseDetail(&b, meta, data, opts) } - return b.String() + return b.String(), decErr } -func renderRequestDetail(b *strings.Builder, meta *store.FlowMeta, data *store.FlowData, darkBg bool, prettyJSON bool, collapsed map[string]bool) { - b.WriteString(styleSection.Render(sectionIcon(collapsed["general"]) + " General")) +func renderRequestDetail(b *strings.Builder, meta *store.FlowMeta, data *store.FlowData, opts renderOpts) string { + b.WriteString(styleSection.Render(sectionIcon(opts.Collapsed["general"]) + " General")) b.WriteString("\n") - if !collapsed["general"] { + if !opts.Collapsed["general"] { fmt.Fprintf(b, " Method: %s\n", meta.Method) fmt.Fprintf(b, " URL: %s://%s%s\n", meta.Scheme, meta.Host, meta.Path) fmt.Fprintf(b, " Scheme: %s\n", meta.Scheme) @@ -134,38 +147,39 @@ func renderRequestDetail(b *strings.Builder, meta *store.FlowMeta, data *store.F b.WriteString("\n") if data != nil && data.ProcessPID != 0 { - renderProcessSection(b, meta, data, collapsed["process"]) + renderProcessSection(b, meta, data, opts.Collapsed["process"]) } if data == nil { - return + return "" } if data.RequestHeaders != nil { - renderHeaders(b, "Request Headers", data.RequestHeaders, collapsed["headers"]) + renderHeaders(b, "Request Headers", data.RequestHeaders, opts.Collapsed["headers"]) } if len(data.RequestBody) > 0 { - b.WriteString(styleSection.Render(sectionIcon(collapsed["body"]) + " Body")) + b.WriteString(styleSection.Render(sectionIcon(opts.Collapsed["body"]) + " Body")) b.WriteString("\n") - if !collapsed["body"] { - renderBody(b, data.RequestBody, data.RequestHeaders.Get("Content-Type"), darkBg, prettyJSON) - } else { - b.WriteString("\n") + if !opts.Collapsed["body"] { + dm := bodydecoder.DecoderMetadata{RequestPath: meta.Path, IsRequest: true} + return renderBody(b, data.RequestBody, data.RequestHeaders.Get("Content-Type"), opts, dm) } + b.WriteString("\n") } + return "" } -func renderResponseDetail(b *strings.Builder, meta *store.FlowMeta, data *store.FlowData, darkBg bool, prettyJSON bool, collapsed map[string]bool) { +func renderResponseDetail(b *strings.Builder, meta *store.FlowMeta, data *store.FlowData, opts renderOpts) string { if meta.State == store.StateInProgress { b.WriteString(styleMuted.Render("Awaiting response...")) b.WriteString("\n") - return + return "" } - b.WriteString(styleSection.Render(sectionIcon(collapsed["general"]) + " General")) + b.WriteString(styleSection.Render(sectionIcon(opts.Collapsed["general"]) + " General")) b.WriteString("\n") - if !collapsed["general"] { + if !opts.Collapsed["general"] { fmt.Fprintf(b, " Status: %d\n", meta.StatusCode) fmt.Fprintf(b, " Content-Type: %s\n", meta.ContentType) fmt.Fprintf(b, " Duration: %s\n", formatDuration(meta.Duration)) @@ -174,22 +188,23 @@ func renderResponseDetail(b *strings.Builder, meta *store.FlowMeta, data *store. b.WriteString("\n") if data == nil { - return + return "" } if data.ResponseHeaders != nil { - renderHeaders(b, "Response Headers", data.ResponseHeaders, collapsed["headers"]) + renderHeaders(b, "Response Headers", data.ResponseHeaders, opts.Collapsed["headers"]) } if len(data.ResponseBody) > 0 { - b.WriteString(styleSection.Render(sectionIcon(collapsed["body"]) + " Body")) + b.WriteString(styleSection.Render(sectionIcon(opts.Collapsed["body"]) + " Body")) b.WriteString("\n") - if !collapsed["body"] { - renderBody(b, data.ResponseBody, meta.ContentType, darkBg, prettyJSON) - } else { - b.WriteString("\n") + if !opts.Collapsed["body"] { + dm := bodydecoder.DecoderMetadata{RequestPath: meta.Path, IsRequest: false} + return renderBody(b, data.ResponseBody, meta.ContentType, opts, dm) } + b.WriteString("\n") } + return "" } func renderHeaders(b *strings.Builder, title string, h map[string][]string, collapsed bool) { @@ -229,8 +244,22 @@ func renderProcessSection(b *strings.Builder, meta *store.FlowMeta, data *store. b.WriteString("\n") } -func renderBody(b *strings.Builder, body []byte, contentType string, darkBg bool, prettyJSON bool) { - highlighted := highlight.Highlight(body, contentType, darkBg, prettyJSON) +func renderBody(b *strings.Builder, body []byte, contentType string, opts renderOpts, meta bodydecoder.DecoderMetadata) string { + displayBody := body + displayCT := contentType + var decErr string + + if opts.Decoder != nil { + decoded, resultCT, err := opts.Decoder.Decode(body, contentType, meta) + if err == nil { + displayBody = []byte(decoded) + displayCT = resultCT + } else if !errors.Is(err, bodydecoder.ErrNoDecoder) { + decErr = err.Error() + } + } + + highlighted := highlight.Highlight(displayBody, displayCT, opts.DarkBg, opts.PrettyJSON) lines := strings.Split(highlighted, "\n") if len(lines) > maxBodyLines { totalLines := len(lines) @@ -241,4 +270,5 @@ func renderBody(b *strings.Builder, body []byte, contentType string, darkBg bool b.WriteString(" " + line + "\n") } b.WriteString("\n") + return decErr } diff --git a/internal/tui/detail_test.go b/internal/tui/detail_test.go index ce8cfeb..b1c1550 100644 --- a/internal/tui/detail_test.go +++ b/internal/tui/detail_test.go @@ -79,7 +79,7 @@ func TestDetailHidesProcessWhenNoPID(t *testing.T) { } func TestRenderDetailBodyNilMeta(t *testing.T) { - out := renderDetailBody(nil, nil, 0, 80, true, true, nil) + out, _ := renderDetailBody(nil, nil, 0, renderOpts{DarkBg: true, PrettyJSON: true}) if !strings.Contains(out, "no longer available") { t.Errorf("expected 'no longer available', got: %s", out) } @@ -90,7 +90,7 @@ func TestRenderResponseDetailInProgress(t *testing.T) { ID: "ip1", Method: "GET", Host: "example.com", Path: "/", State: store.StateInProgress, } - out := renderDetailBody(meta, nil, 1, 80, true, true, nil) + out, _ := renderDetailBody(meta, nil, 1, renderOpts{DarkBg: true, PrettyJSON: true}) stripped := ansi.Strip(out) if !strings.Contains(stripped, "Awaiting response") { t.Errorf("expected 'Awaiting response', got: %s", stripped) @@ -110,7 +110,7 @@ func TestRenderResponseDetailCompleted(t *testing.T) { }, ResponseBody: []byte(`{"ok":true}`), } - out := renderDetailBody(meta, data, 1, 80, true, true, nil) + out, _ := renderDetailBody(meta, data, 1, renderOpts{DarkBg: true, PrettyJSON: true}) stripped := ansi.Strip(out) for _, want := range []string{"200", "application/json", "X-Custom", "hello", "ok"} { if !strings.Contains(stripped, want) { diff --git a/internal/tui/list_test.go b/internal/tui/list_test.go index 9800f37..2b3c958 100644 --- a/internal/tui/list_test.go +++ b/internal/tui/list_test.go @@ -7,6 +7,7 @@ import ( "time" "github.com/charmbracelet/x/ansi" + "github.com/kostyay/httpmon/internal/bodydecoder" "github.com/kostyay/httpmon/internal/store" ) @@ -77,7 +78,7 @@ func TestRenderBodyTruncation(t *testing.T) { body := []byte(strings.Join(lines, "\n")) var b strings.Builder - renderBody(&b, body, "text/plain", true, false) + renderBody(&b, body, "text/plain", renderOpts{DarkBg: true}, bodydecoder.DecoderMetadata{}) out := ansi.Strip(b.String()) if !strings.Contains(out, "truncated") { t.Errorf("expected 'truncated' in output, got: %s", out) @@ -88,7 +89,7 @@ func TestRenderBodyTruncation(t *testing.T) { body := []byte("short\nbody\n") var b strings.Builder - renderBody(&b, body, "text/plain", true, false) + renderBody(&b, body, "text/plain", renderOpts{DarkBg: true}, bodydecoder.DecoderMetadata{}) out := ansi.Strip(b.String()) if strings.Contains(out, "truncated") { t.Errorf("short body should not be truncated, got: %s", out) diff --git a/internal/tui/ports.go b/internal/tui/ports.go index 9f2fbf3..9235f16 100644 --- a/internal/tui/ports.go +++ b/internal/tui/ports.go @@ -3,6 +3,7 @@ package tui import ( "time" + "github.com/kostyay/httpmon/internal/bodydecoder" "github.com/kostyay/httpmon/internal/scripting" "github.com/kostyay/httpmon/internal/store" ) @@ -41,3 +42,9 @@ type MCPServer interface { Running() bool Port() int } + +// BodyDecoderRegistry decodes wire-format bodies (e.g. protobuf) into +// human-readable text for display in the detail view. +type BodyDecoderRegistry interface { + Decode(body []byte, contentType string, meta bodydecoder.DecoderMetadata) (decoded string, resultContentType string, err error) +} diff --git a/internal/tui/settings.go b/internal/tui/settings.go index 345f18e..26d241f 100644 --- a/internal/tui/settings.go +++ b/internal/tui/settings.go @@ -13,12 +13,12 @@ import ( ) type settingsField struct { - label string - kind string // "int", "bool", "enum", "string" - options []string - restart bool - get func(*config.Config) string - set func(*config.Config, string) + label string + kind string // "int", "bool", "enum", "string" + options []string + restart bool + get func(*config.Config) string + set func(*config.Config, string) } var settingsFields = []settingsField{