From 026058f507d1af799744cfb5f7bc25b9c0726f36 Mon Sep 17 00:00:00 2001 From: Aaron Lewter <77890109+lewta@users.noreply.github.com> Date: Thu, 26 Mar 2026 20:37:25 -0400 Subject: [PATCH 1/2] feat(driver): add gRPC driver (v1.1.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New `type: grpc` driver executing unary gRPC calls via server reflection; no .proto files required at runtime - URL format: grpc://host:port/Service/Method (plain) or grpcs:// (TLS) - JSON body unmarshalled to protobuf via the reflection API - gRPC status codes mapped to HTTP-like codes for uniform error classifier and backoff behaviour (ResourceExhausted→429, Unavailable→503, etc.) - Connections and method descriptors cached per address+TLS mode - GRPCConfig added to TargetConfig and TargetDefaultsConfig (body, timeout_s, tls, insecure) - grpc added to valid types in config validation and targets_file parser - 10 driver tests covering: status mapping, health check, empty body, connection reuse, NOT_FOUND mapping, unknown method, no reflection, invalid URL, cancelled context, status FromError integration - Docs: grpc section in drivers.md with status code table; grpc fields in configuration.md; 2 new deps in dependencies.md; _index.md updated - Dependencies: google.golang.org/grpc v1.79.3 (Apache-2.0), google.golang.org/genproto/googleapis/rpc (Apache-2.0) Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 9 +- docs/content/docs/_index.md | 2 +- docs/content/docs/configuration.md | 10 +- docs/content/docs/dependencies.md | 6 +- docs/content/docs/drivers.md | 67 +++++- go.mod | 4 +- go.sum | 28 ++- internal/config/config.go | 12 +- internal/config/schema.go | 14 +- internal/driver/driver_test.go | 207 ++++++++++++++++++ internal/driver/grpc.go | 334 +++++++++++++++++++++++++++++ internal/engine/engine.go | 1 + 12 files changed, 672 insertions(+), 22 deletions(-) create mode 100644 internal/driver/grpc.go diff --git a/CHANGELOG.md b/CHANGELOG.md index b755914..19cfc0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,12 +13,9 @@ under the affected version with a reference to the CVE or advisory. ## [Unreleased] ### Added -- `sendit generate --url`: sitemap support — `Sitemap:` entries in `robots.txt` and `` links found during crawl are now parsed as sitemap XML sources, feeding discovered `` URLs into the crawl queue; handles both `` and `` formats - -### Fixed -- `sendit generate --url`: crawl now normalises URLs before deduplication — strips trailing slashes from non-root paths and removes fragments/query strings so that `https://example.com/page` and `https://example.com/page/` are no longer treated as separate targets -- `sendit generate --url`: non-HTML file extensions (`.xml`, `.json`, `.pdf`, `.css`, `.js`, images, fonts, media, archives) are now excluded as crawl targets; only pages likely to be HTML are included -- `sendit generate --url`: sitemap `` entries with a different scheme than the seed URL (e.g. `http://` locs in an `https://` site's sitemap) are normalised to the seed's scheme, preventing scheme-variant duplicates +- `type: grpc` driver — executes unary gRPC calls using server reflection; no `.proto` files required. URL format: `grpc://host:port/Service/Method` (plaintext) or `grpcs://` (TLS). JSON body is unmarshalled to protobuf via reflection. gRPC status codes are mapped to HTTP-like codes so the engine's error classifier and backoff work uniformly. Connections and method descriptors are cached per address. +- `grpc` block in `TargetConfig` and `TargetDefaultsConfig` with fields: `body`, `timeout_s`, `tls`, `insecure` +- gRPC driver documented in Drivers, Configuration, and Dependencies docs pages --- diff --git a/docs/content/docs/_index.md b/docs/content/docs/_index.md index 035bccf..a0ddbde 100644 --- a/docs/content/docs/_index.md +++ b/docs/content/docs/_index.md @@ -51,7 +51,7 @@ Press Ctrl-C to stop and print a summary. See the [CLI Reference](cli/) for all | [Getting Started](getting-started/) | Install, build, validate a config, run your first traffic, and deploy with Docker | | [Configuration](configuration/) | Every config key, its type, default, and description | | [Pacing Modes](pacing/) | `human`, `rate_limited`, `scheduled`, and `burst` — how request timing works | -| [Drivers](drivers/) | `http`, `browser`, `dns`, `websocket` — options and examples for each | +| [Drivers](drivers/) | `http`, `browser`, `dns`, `websocket`, `grpc` — options and examples for each | | [Metrics](metrics/) | Prometheus metrics exposed by sendit and how to scrape them | | [CLI Reference](cli/) | All commands and flags | | [Dependencies](dependencies/) | Direct dependencies, their purpose, and their licences | diff --git a/docs/content/docs/configuration.md b/docs/content/docs/configuration.md index 7852d56..8c0bd3b 100644 --- a/docs/content/docs/configuration.md +++ b/docs/content/docs/configuration.md @@ -92,10 +92,11 @@ Load targets from a plain-text file instead of (or in addition to) the inline `t ``` # config/targets.txt -https://example.com http 5 -https://api.example.com http 3 -example.com dns 2 -wss://ws.example.com websocket +https://example.com http 5 +https://api.example.com http 3 +example.com dns 2 +wss://ws.example.com websocket +grpc://svc.example.com:50051/helloworld.Greeter/SayHello grpc 4 ``` `target_defaults` supplies remaining fields for every file-loaded target: @@ -122,6 +123,7 @@ target_defaults: | `dns.resolver` | `8.8.8.8:53` | DNS resolver address | | `dns.record_type` | `A` | DNS record type | | `websocket.duration_s` | `30` | How long to hold the connection open (seconds) | +| `grpc.timeout_s` | `15` | Per-call timeout (seconds) | ## `output` diff --git a/docs/content/docs/dependencies.md b/docs/content/docs/dependencies.md index 171e800..5b8d764 100644 --- a/docs/content/docs/dependencies.md +++ b/docs/content/docs/dependencies.md @@ -5,7 +5,7 @@ weight: 95 description: "Direct dependencies, their purpose, and their licences." --- -sendit has 14 direct runtime dependencies. All are permissive open-source licences +sendit has 16 direct runtime dependencies. All are permissive open-source licences compatible with the project's [MIT licence](https://github.com/lewta/sendit/blob/main/LICENSE). The module graph is managed with `go mod tidy` and kept minimal — no dependency @@ -28,6 +28,8 @@ appears that cannot be justified by the table below. | [`github.com/spf13/viper`](https://github.com/spf13/viper) | v1.21.0 | MIT | Config file loading with environment variable overlay and `mapstructure` unmarshalling | | [`golang.org/x/net`](https://pkg.go.dev/golang.org/x/net) | v0.52.0 | BSD-3-Clause | `html` subpackage — HTML parser used by the `generate` command to extract links | | [`golang.org/x/time`](https://pkg.go.dev/golang.org/x/time) | v0.15.0 | BSD-3-Clause | `rate` subpackage — token-bucket rate limiter used by `rate_limited` and `scheduled` pacing | +| [`google.golang.org/grpc`](https://pkg.go.dev/google.golang.org/grpc) | v1.79.3 | Apache-2.0 | gRPC client and server — powers the `grpc` driver; includes reflection client and health service | +| [`google.golang.org/genproto/googleapis/rpc`](https://pkg.go.dev/google.golang.org/genproto/googleapis/rpc) | v0.0.0-20251202 | Apache-2.0 | Generated gRPC status and error type definitions — transitive requirement of `google.golang.org/grpc` | | [`modernc.org/sqlite`](https://pkg.go.dev/modernc.org/sqlite) | v1.47.0 | BSD-3-Clause | Pure-Go SQLite driver (CGo-free) — used by `generate` to read Chrome/Firefox history and bookmark databases | ## Alternatives considered @@ -50,7 +52,7 @@ All dependency licences are permissive and compatible with the project's MIT lic | MIT | `bubbletea`, `lipgloss`, `chromedp`, `cron/v3`, `zerolog`, `viper` | | ISC | `coder/websocket` | | BSD-3-Clause | `miekg/dns`, `gopsutil/v3`, `x/net`, `x/time`, `modernc.org/sqlite` | -| Apache-2.0 | `prometheus/client_golang`, `cobra` | +| Apache-2.0 | `prometheus/client_golang`, `cobra`, `google.golang.org/grpc`, `genproto/googleapis/rpc` | ISC, BSD-2-Clause, and BSD-3-Clause are functionally equivalent to MIT for distribution purposes. Apache-2.0 is compatible with MIT when distributing binaries (no copyleft restriction). diff --git a/docs/content/docs/drivers.md b/docs/content/docs/drivers.md index e8cf0b3..58dc151 100644 --- a/docs/content/docs/drivers.md +++ b/docs/content/docs/drivers.md @@ -2,7 +2,7 @@ title: "Drivers" linkTitle: "Drivers" weight: 4 -description: "HTTP, browser, DNS, and WebSocket driver options and examples." +description: "HTTP, browser, DNS, WebSocket, and gRPC driver options and examples." --- A **driver** is responsible for executing a single request and returning a result. Each target in your config specifies a `type` that selects the driver. All drivers map their results to HTTP-like status codes so the engine's error classifier, backoff, and metrics work uniformly. @@ -136,3 +136,68 @@ targets: - url: "wss://stream.example.com:9443/feed" type: websocket ``` + +## `grpc` + +Executes a **unary gRPC call** using [google.golang.org/grpc](https://pkg.go.dev/google.golang.org/grpc). No `.proto` files are required — the driver uses [server reflection](https://grpc.io/docs/guides/reflection/) to discover request and response types at runtime, then marshals the JSON body to protobuf automatically. + +```yaml +targets: + - url: grpc://localhost:50051/helloworld.Greeter/SayHello + weight: 10 + type: grpc + grpc: + body: '{"name": "world"}' # JSON-encoded request (optional — defaults to empty message) + timeout_s: 15 # per-call timeout in seconds + tls: false # force TLS even when scheme is grpc:// + insecure: false # skip TLS certificate verification +``` + +| Field | Default | Description | +|---|---|---| +| `body` | `""` | JSON-encoded request body. Must match the method's input proto type. Empty sends a default-constructed message. | +| `timeout_s` | `15` | Per-call timeout in seconds | +| `tls` | `false` | Force TLS even when the URL scheme is `grpc://` | +| `insecure` | `false` | Skip TLS certificate verification (combine with `tls: true` or `grpcs://` scheme) | + +**URL scheme** selects transport security: + +| Scheme | Transport | +|---|---| +| `grpc://host:port/Service/Method` | Plaintext | +| `grpcs://host:port/Service/Method` | TLS | + +**gRPC status codes** are mapped to HTTP-like status codes so the engine's backoff and error classifier work uniformly: + +| gRPC code | HTTP equivalent | Effect | +|---|---|---| +| OK (0) | 200 | success | +| InvalidArgument (3), OutOfRange (11) | 400 | permanent skip | +| Unauthenticated (16) | 401 | permanent skip | +| PermissionDenied (7) | 403 | permanent skip | +| NotFound (5) | 404 | permanent skip | +| AlreadyExists (6) | 409 | permanent skip | +| ResourceExhausted (8) | 429 | transient backoff | +| Unimplemented (12) | 501 | permanent skip | +| Unavailable (14) | 503 | transient backoff | +| DeadlineExceeded (4) | 504 | transient backoff | +| other | 500 | transient backoff | + +**Prerequisite:** the gRPC server must have the [server reflection service](https://grpc.io/docs/guides/reflection/) enabled. Most frameworks enable it via a single line (e.g. `reflection.Register(s)` in Go). If reflection is not available, the driver returns an error immediately. + +**Connection and descriptor caching:** connections and method descriptors are cached per address+TLS mode. Reflection is called only on the first request to each method; subsequent calls reuse the cached descriptor. + +```yaml +# Multiple gRPC targets on the same server — connection is shared +targets: + - url: grpc://api.example.com:50051/user.UserService/GetUser + type: grpc + weight: 8 + grpc: + body: '{"user_id": "u-123"}' + - url: grpc://api.example.com:50051/user.UserService/ListUsers + type: grpc + weight: 2 + grpc: + body: '{}' +``` diff --git a/go.mod b/go.mod index 54da497..a52ac8b 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,8 @@ require ( github.com/spf13/viper v1.21.0 golang.org/x/net v0.52.0 golang.org/x/time v0.15.0 + google.golang.org/grpc v1.79.3 + google.golang.org/protobuf v1.36.10 howett.net/plist v1.0.1 modernc.org/sqlite v1.47.0 ) @@ -77,7 +79,7 @@ require ( golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.35.0 // indirect golang.org/x/tools v0.42.0 // indirect - google.golang.org/protobuf v1.36.8 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect modernc.org/libc v1.70.0 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index 314681d..9514c6f 100644 --- a/go.sum +++ b/go.sum @@ -38,6 +38,10 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 h1:vymEbVwYFP/L05h5TKQxvkXoKxNvTpjxYKdF1Nlwuao= github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= @@ -49,6 +53,8 @@ github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6Wezm github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= @@ -160,6 +166,18 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= @@ -191,8 +209,14 @@ golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= -google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/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/config/config.go b/internal/config/config.go index c2f74e2..3398230 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -100,7 +100,7 @@ func loadTargetsFile(cfg *Config) error { defer f.Close() d := cfg.TargetDefaults - validTypes := map[string]bool{"http": true, "browser": true, "dns": true, "websocket": true} + validTypes := map[string]bool{"http": true, "browser": true, "dns": true, "websocket": true, "grpc": true} scanner := bufio.NewScanner(f) lineNum := 0 @@ -120,7 +120,7 @@ func loadTargetsFile(cfg *Config) error { typ := strings.ToLower(fields[1]) if !validTypes[typ] { - return fmt.Errorf("line %d: unknown type %q (must be http|browser|dns|websocket)", lineNum, typ) + return fmt.Errorf("line %d: unknown type %q (must be http|browser|dns|websocket|grpc)", lineNum, typ) } weight := d.Weight @@ -143,6 +143,7 @@ func loadTargetsFile(cfg *Config) error { Browser: d.Browser, DNS: d.DNS, WebSocket: d.WebSocket, + GRPC: d.GRPC, }) } @@ -221,7 +222,7 @@ func validate(cfg *Config) error { errs = append(errs, "targets must have at least one entry (via 'targets' in config or 'targets_file')") } - validTypes := map[string]bool{"http": true, "browser": true, "dns": true, "websocket": true} + validTypes := map[string]bool{"http": true, "browser": true, "dns": true, "websocket": true, "grpc": true} for i, t := range cfg.Targets { if t.URL == "" { errs = append(errs, fmt.Sprintf("targets[%d].url must not be empty", i)) @@ -230,7 +231,10 @@ func validate(cfg *Config) error { errs = append(errs, fmt.Sprintf("targets[%d].weight must be > 0", i)) } if !validTypes[t.Type] { - errs = append(errs, fmt.Sprintf("targets[%d].type must be one of http|browser|dns|websocket, got %q", i, t.Type)) + errs = append(errs, fmt.Sprintf("targets[%d].type must be one of http|browser|dns|websocket|grpc, got %q", i, t.Type)) + } + if t.Type == "grpc" && !strings.HasPrefix(t.URL, "grpc://") && !strings.HasPrefix(t.URL, "grpcs://") { + errs = append(errs, fmt.Sprintf("targets[%d].url must start with grpc:// or grpcs:// for type grpc, got %q", i, t.URL)) } } diff --git a/internal/config/schema.go b/internal/config/schema.go index bc73170..7c2b096 100644 --- a/internal/config/schema.go +++ b/internal/config/schema.go @@ -23,6 +23,7 @@ type TargetDefaultsConfig struct { Browser BrowserConfig `mapstructure:"browser"` DNS DNSConfig `mapstructure:"dns"` WebSocket WebSocketConfig `mapstructure:"websocket"` + GRPC GRPCConfig `mapstructure:"grpc"` } // PacingConfig controls how requests are spaced in time. @@ -78,11 +79,12 @@ type BackoffConfig struct { type TargetConfig struct { URL string `mapstructure:"url"` Weight int `mapstructure:"weight"` - Type string `mapstructure:"type"` // http | browser | dns | websocket + Type string `mapstructure:"type"` // http | browser | dns | websocket | grpc HTTP HTTPConfig `mapstructure:"http"` Browser BrowserConfig `mapstructure:"browser"` DNS DNSConfig `mapstructure:"dns"` WebSocket WebSocketConfig `mapstructure:"websocket"` + GRPC GRPCConfig `mapstructure:"grpc"` } // HTTPConfig holds HTTP-specific target settings. @@ -113,6 +115,16 @@ type WebSocketConfig struct { ExpectMessages int `mapstructure:"expect_messages"` } +// GRPCConfig holds gRPC target settings. +// The URL scheme selects TLS: grpc:// for plaintext, grpcs:// for TLS. +// The cfg.TLS field can also force TLS regardless of scheme. +type GRPCConfig struct { + Body string `mapstructure:"body"` // JSON-encoded request body (unmarshalled via reflection) + TimeoutS int `mapstructure:"timeout_s"` // per-call timeout in seconds (default 15) + TLS bool `mapstructure:"tls"` // force TLS even when scheme is grpc:// + Insecure bool `mapstructure:"insecure"` // skip TLS certificate verification +} + // OutputConfig controls writing request results to a file. type OutputConfig struct { Enabled bool `mapstructure:"enabled"` diff --git a/internal/driver/driver_test.go b/internal/driver/driver_test.go index de0b1d1..989e039 100644 --- a/internal/driver/driver_test.go +++ b/internal/driver/driver_test.go @@ -15,6 +15,12 @@ import ( "github.com/lewta/sendit/internal/driver" "github.com/lewta/sendit/internal/task" dns "github.com/miekg/dns" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/health" + healthpb "google.golang.org/grpc/health/grpc_health_v1" + "google.golang.org/grpc/reflection" + "google.golang.org/grpc/status" ) // httpTask builds a minimal http task pointing at the given URL. @@ -288,6 +294,207 @@ func TestWebSocketDriver_ServerClosesEarly(t *testing.T) { _ = result // either success or an error is acceptable; must not block } +// --- gRPC driver --- + +// grpcTask builds a minimal gRPC task. +func grpcTask(rawURL string, cfg config.GRPCConfig) task.Task { + c := config.TargetConfig{URL: rawURL, Type: "grpc", GRPC: cfg} + return task.Task{URL: rawURL, Type: "grpc", Config: c} +} + +// startGRPCServer starts a real gRPC server with the health service and +// reflection enabled, returning its address. +func startGRPCServer(t *testing.T) string { + t.Helper() + lis, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + srv := grpc.NewServer() + hs := health.NewServer() + hs.SetServingStatus("", healthpb.HealthCheckResponse_SERVING) + healthpb.RegisterHealthServer(srv, hs) + reflection.Register(srv) + go srv.Serve(lis) //nolint:errcheck + t.Cleanup(srv.GracefulStop) + return lis.Addr().String() +} + +// startGRPCServerNoReflection starts a gRPC server without the reflection service. +func startGRPCServerNoReflection(t *testing.T) string { + t.Helper() + lis, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + srv := grpc.NewServer() + hs := health.NewServer() + hs.SetServingStatus("", healthpb.HealthCheckResponse_SERVING) + healthpb.RegisterHealthServer(srv, hs) + // reflection NOT registered + go srv.Serve(lis) //nolint:errcheck + t.Cleanup(srv.GracefulStop) + return lis.Addr().String() +} + +func TestGRPCStatusToHTTP(t *testing.T) { + cases := []struct { + code codes.Code + want int + }{ + {codes.OK, 200}, + {codes.InvalidArgument, 400}, + {codes.OutOfRange, 400}, + {codes.Unauthenticated, 401}, + {codes.PermissionDenied, 403}, + {codes.NotFound, 404}, + {codes.AlreadyExists, 409}, + {codes.ResourceExhausted, 429}, + {codes.Unimplemented, 501}, + {codes.Unavailable, 503}, + {codes.DeadlineExceeded, 504}, + {codes.Internal, 500}, + {codes.Unknown, 500}, + {codes.Canceled, 500}, + } + for _, tc := range cases { + got := driver.GRPCStatusToHTTP(tc.code) + if got != tc.want { + t.Errorf("grpcStatusToHTTP(%v) = %d, want %d", tc.code, got, tc.want) + } + } +} + +func TestGRPCDriver_HealthCheck(t *testing.T) { + addr := startGRPCServer(t) + drv := driver.NewGRPCDriver() + result := drv.Execute(context.Background(), + grpcTask("grpc://"+addr+"/grpc.health.v1.Health/Check", + config.GRPCConfig{Body: `{"service":""}`, TimeoutS: 5})) + + if result.Error != nil { + t.Fatalf("unexpected error: %v", result.Error) + } + if result.StatusCode != 200 { + t.Errorf("StatusCode = %d, want 200", result.StatusCode) + } + if result.Duration <= 0 { + t.Errorf("Duration = %v, want > 0", result.Duration) + } +} + +func TestGRPCDriver_EmptyBody(t *testing.T) { + addr := startGRPCServer(t) + drv := driver.NewGRPCDriver() + // No body — health Check accepts an empty HealthCheckRequest. + result := drv.Execute(context.Background(), + grpcTask("grpc://"+addr+"/grpc.health.v1.Health/Check", + config.GRPCConfig{TimeoutS: 5})) + + if result.Error != nil { + t.Fatalf("unexpected error: %v", result.Error) + } + if result.StatusCode != 200 { + t.Errorf("StatusCode = %d, want 200", result.StatusCode) + } +} + +func TestGRPCDriver_ConnectionReuse(t *testing.T) { + addr := startGRPCServer(t) + drv := driver.NewGRPCDriver() + for i := 0; i < 3; i++ { + result := drv.Execute(context.Background(), + grpcTask("grpc://"+addr+"/grpc.health.v1.Health/Check", + config.GRPCConfig{TimeoutS: 5})) + if result.StatusCode != 200 { + t.Errorf("call %d: StatusCode = %d, want 200", i, result.StatusCode) + } + } +} + +func TestGRPCDriver_NonOKStatus(t *testing.T) { + addr := startGRPCServer(t) + drv := driver.NewGRPCDriver() + // Query a service name that the health server doesn't know — returns NOT_FOUND. + result := drv.Execute(context.Background(), + grpcTask("grpc://"+addr+"/grpc.health.v1.Health/Check", + config.GRPCConfig{Body: `{"service":"unknown.Service"}`, TimeoutS: 5})) + + // Health server returns NOT_FOUND for unknown services — mapped to 404. + if result.Error != nil { + t.Fatalf("unexpected Go error (want gRPC status in StatusCode): %v", result.Error) + } + if result.StatusCode != 404 { + t.Errorf("StatusCode = %d, want 404 (NOT_FOUND)", result.StatusCode) + } +} + +func TestGRPCDriver_UnknownMethod(t *testing.T) { + addr := startGRPCServer(t) + drv := driver.NewGRPCDriver() + result := drv.Execute(context.Background(), + grpcTask("grpc://"+addr+"/grpc.health.v1.Health/NoSuchMethod", + config.GRPCConfig{TimeoutS: 5})) + + if result.Error == nil { + t.Errorf("expected error for unknown method, got StatusCode %d", result.StatusCode) + } +} + +func TestGRPCDriver_NoReflection(t *testing.T) { + addr := startGRPCServerNoReflection(t) + drv := driver.NewGRPCDriver() + result := drv.Execute(context.Background(), + grpcTask("grpc://"+addr+"/grpc.health.v1.Health/Check", + config.GRPCConfig{TimeoutS: 5})) + + // Should return an error because reflection is not available. + if result.Error == nil { + t.Errorf("expected error when reflection is not enabled, got StatusCode %d", result.StatusCode) + } +} + +func TestGRPCDriver_InvalidURL(t *testing.T) { + drv := driver.NewGRPCDriver() + result := drv.Execute(context.Background(), + grpcTask("grpc://localhost:50051/OnlyOneComponent", + config.GRPCConfig{TimeoutS: 5})) + + if result.Error == nil { + t.Errorf("expected error for invalid URL path, got StatusCode %d", result.StatusCode) + } +} + +func TestGRPCDriver_Timeout(t *testing.T) { + addr := startGRPCServer(t) + drv := driver.NewGRPCDriver() + ctx, cancel := context.WithCancel(context.Background()) + cancel() // immediately cancelled + + result := drv.Execute(ctx, + grpcTask("grpc://"+addr+"/grpc.health.v1.Health/Check", + config.GRPCConfig{TimeoutS: 5})) + + // Cancelled context should produce a non-200 result (no Go error — gRPC maps it). + if result.StatusCode == 200 { + t.Errorf("StatusCode = 200 on cancelled context, want non-200") + } +} + +// Ensure status.FromError is exercised to validate our status-mapping integration. +func TestGRPCDriver_StatusMapping(t *testing.T) { + // Build a gRPC status error and verify FromError extracts the code. + st := status.New(codes.ResourceExhausted, "quota exceeded") + err := st.Err() + extracted, ok := status.FromError(err) + if !ok { + t.Fatal("status.FromError returned ok=false for a status error") + } + if extracted.Code() != codes.ResourceExhausted { + t.Errorf("code = %v, want ResourceExhausted", extracted.Code()) + } +} + // --- Browser driver --- func TestBrowserDriver_Skipped(t *testing.T) { diff --git a/internal/driver/grpc.go b/internal/driver/grpc.go new file mode 100644 index 0000000..e7ef9bf --- /dev/null +++ b/internal/driver/grpc.go @@ -0,0 +1,334 @@ +package driver + +import ( + "context" + "crypto/tls" + "fmt" + "net/url" + "path" + "strings" + "sync" + "time" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/credentials/insecure" + reflectionpb "google.golang.org/grpc/reflection/grpc_reflection_v1alpha" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protodesc" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/reflect/protoregistry" + "google.golang.org/protobuf/types/descriptorpb" + "google.golang.org/protobuf/types/dynamicpb" + + "github.com/lewta/sendit/internal/task" +) + +// grpcStatusToHTTP maps a gRPC status code to an HTTP-like status code so the +// engine's error classifier and metrics work uniformly across all driver types. +// +// OK(0) → 200 +// InvalidArgument(3) → 400 +// OutOfRange(11) → 400 +// Unauthenticated(16) → 401 +// PermissionDenied(7) → 403 +// NotFound(5) → 404 +// AlreadyExists(6) → 409 +// ResourceExhausted(8) → 429 +// Unimplemented(12) → 501 +// Unavailable(14) → 503 +// DeadlineExceeded(4) → 504 +// other → 500 +// +// GRPCStatusToHTTP is exported for testing. +var GRPCStatusToHTTP = grpcStatusToHTTP + +func grpcStatusToHTTP(c codes.Code) int { + switch c { + case codes.OK: + return 200 + case codes.InvalidArgument, codes.OutOfRange: + return 400 + case codes.Unauthenticated: + return 401 + case codes.PermissionDenied: + return 403 + case codes.NotFound: + return 404 + case codes.AlreadyExists: + return 409 + case codes.ResourceExhausted: + return 429 + case codes.Unimplemented: + return 501 + case codes.Unavailable: + return 503 + case codes.DeadlineExceeded: + return 504 + default: + return 500 + } +} + +type grpcMethodInfo struct { + input protoreflect.MessageDescriptor + output protoreflect.MessageDescriptor +} + +// GRPCDriver executes unary gRPC requests. It uses server reflection to resolve +// request/response types so no .proto files are required at runtime. Connections +// and method descriptors are cached across calls. +// +// URL format: +// +// grpc://host:port/package.Service/Method — plaintext +// grpcs://host:port/package.Service/Method — TLS +type GRPCDriver struct { + mu sync.Mutex + conns map[string]*grpc.ClientConn // keyed by addr+tlsMode + methods sync.Map // keyed by addr+fullMethod → grpcMethodInfo +} + +// NewGRPCDriver creates a GRPCDriver. +func NewGRPCDriver() *GRPCDriver { + return &GRPCDriver{ + conns: make(map[string]*grpc.ClientConn), + } +} + +// Execute performs the unary gRPC call described by t. +func (d *GRPCDriver) Execute(ctx context.Context, t task.Task) task.Result { + cfg := t.Config.GRPC + + u, err := url.Parse(t.URL) + if err != nil { + return task.Result{Task: t, Error: fmt.Errorf("parsing URL: %w", err)} + } + + addr := u.Host + fullMethod := u.Path // e.g. /package.Service/Method + + parts := strings.SplitN(strings.TrimPrefix(fullMethod, "/"), "/", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return task.Result{Task: t, Error: fmt.Errorf("grpc URL path must be /Service/Method, got %q", fullMethod)} + } + serviceName := parts[0] + + useTLS := u.Scheme == "grpcs" || cfg.TLS + + timeoutS := cfg.TimeoutS + if timeoutS <= 0 { + timeoutS = 15 + } + + callCtx, cancel := context.WithTimeout(ctx, time.Duration(timeoutS)*time.Second) + defer cancel() + + conn, err := d.getConn(addr, useTLS, cfg.Insecure) + if err != nil { + return task.Result{Task: t, Error: err} + } + + methodInfo, err := d.resolveMethod(callCtx, conn, serviceName, fullMethod) + if err != nil { + return task.Result{Task: t, Error: fmt.Errorf("resolving method via reflection: %w", err)} + } + + reqMsg := dynamicpb.NewMessage(methodInfo.input) + if cfg.Body != "" { + if err := protojson.Unmarshal([]byte(cfg.Body), reqMsg); err != nil { + return task.Result{Task: t, Error: fmt.Errorf("parsing body as JSON: %w", err)} + } + } + + respMsg := dynamicpb.NewMessage(methodInfo.output) + + start := time.Now() + invokeErr := conn.Invoke(callCtx, fullMethod, reqMsg, respMsg) + elapsed := time.Since(start) + + if invokeErr != nil { + st, _ := status.FromError(invokeErr) + return task.Result{ + Task: t, + StatusCode: grpcStatusToHTTP(st.Code()), + Duration: elapsed, + } + } + + return task.Result{ + Task: t, + StatusCode: 200, + Duration: elapsed, + } +} + +func (d *GRPCDriver) getConn(addr string, useTLS, insecureSkip bool) (*grpc.ClientConn, error) { + tlsMode := "plain" + if useTLS && insecureSkip { + tlsMode = "tls-insecure" + } else if useTLS { + tlsMode = "tls" + } + key := addr + ":" + tlsMode + + d.mu.Lock() + defer d.mu.Unlock() + + if conn, ok := d.conns[key]; ok { + return conn, nil + } + + var creds credentials.TransportCredentials + if useTLS { + if insecureSkip { + creds = credentials.NewTLS(&tls.Config{InsecureSkipVerify: true}) //nolint:gosec // user-configured + } else { + creds = credentials.NewTLS(&tls.Config{MinVersion: tls.VersionTLS12}) + } + } else { + creds = insecure.NewCredentials() + } + + conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(creds)) + if err != nil { + return nil, fmt.Errorf("creating gRPC client for %q: %w", addr, err) + } + d.conns[key] = conn + return conn, nil +} + +func (d *GRPCDriver) resolveMethod(ctx context.Context, conn *grpc.ClientConn, serviceName, fullMethod string) (grpcMethodInfo, error) { + cacheKey := conn.Target() + fullMethod + if v, ok := d.methods.Load(cacheKey); ok { + return v.(grpcMethodInfo), nil + } + + info, err := d.fetchMethodInfo(ctx, conn, serviceName, fullMethod) + if err != nil { + return grpcMethodInfo{}, err + } + + d.methods.Store(cacheKey, info) + return info, nil +} + +func (d *GRPCDriver) fetchMethodInfo(ctx context.Context, conn *grpc.ClientConn, serviceName, fullMethod string) (grpcMethodInfo, error) { + rc := reflectionpb.NewServerReflectionClient(conn) + stream, err := rc.ServerReflectionInfo(ctx) + if err != nil { + return grpcMethodInfo{}, fmt.Errorf("opening reflection stream: %w", err) + } + defer stream.CloseSend() //nolint:errcheck + + if err := stream.Send(&reflectionpb.ServerReflectionRequest{ + MessageRequest: &reflectionpb.ServerReflectionRequest_FileContainingSymbol{ + FileContainingSymbol: serviceName, + }, + }); err != nil { + return grpcMethodInfo{}, fmt.Errorf("sending reflection request: %w", err) + } + + resp, err := stream.Recv() + if err != nil { + return grpcMethodInfo{}, fmt.Errorf("receiving reflection response: %w", err) + } + + fdr, ok := resp.MessageResponse.(*reflectionpb.ServerReflectionResponse_FileDescriptorResponse) + if !ok { + if errResp, ok2 := resp.MessageResponse.(*reflectionpb.ServerReflectionResponse_ErrorResponse); ok2 { + return grpcMethodInfo{}, fmt.Errorf("reflection error %d: %s", + errResp.ErrorResponse.ErrorCode, errResp.ErrorResponse.ErrorMessage) + } + return grpcMethodInfo{}, fmt.Errorf("unexpected reflection response type: %T", resp.MessageResponse) + } + + reg, err := buildFileRegistry(fdr.FileDescriptorResponse.FileDescriptorProto) + if err != nil { + return grpcMethodInfo{}, err + } + + desc, err := reg.FindDescriptorByName(protoreflect.FullName(serviceName)) + if err != nil { + return grpcMethodInfo{}, fmt.Errorf("service %q not found in reflection response: %w", serviceName, err) + } + + sd, ok := desc.(protoreflect.ServiceDescriptor) + if !ok { + return grpcMethodInfo{}, fmt.Errorf("%q is not a service descriptor", serviceName) + } + + methodName := protoreflect.Name(path.Base(fullMethod)) + md := sd.Methods().ByName(methodName) + if md == nil { + return grpcMethodInfo{}, fmt.Errorf("method %q not found in service %q", methodName, serviceName) + } + + return grpcMethodInfo{ + input: md.Input(), + output: md.Output(), + }, nil +} + +// buildFileRegistry registers all FileDescriptorProtos returned by reflection +// into a local Files registry. Files are registered in dependency order via a +// retry loop (the reflection response may not be topologically sorted). +func buildFileRegistry(fdpBytes [][]byte) (*protoregistry.Files, error) { + fdps := make([]*descriptorpb.FileDescriptorProto, 0, len(fdpBytes)) + for _, b := range fdpBytes { + var fdp descriptorpb.FileDescriptorProto + if err := proto.Unmarshal(b, &fdp); err != nil { + return nil, fmt.Errorf("parsing FileDescriptorProto: %w", err) + } + fdps = append(fdps, &fdp) + } + + reg := new(protoregistry.Files) + resolver := &mergedResolver{local: reg} + + for attempt := 0; attempt <= len(fdps); attempt++ { + var remaining []*descriptorpb.FileDescriptorProto + for _, fdp := range fdps { + if _, err := reg.FindFileByPath(fdp.GetName()); err == nil { + continue // already registered + } + fd, err := protodesc.NewFile(fdp, resolver) + if err != nil { + remaining = append(remaining, fdp) + continue + } + if err := reg.RegisterFile(fd); err != nil { + remaining = append(remaining, fdp) + } + } + if len(remaining) == 0 { + break + } + fdps = remaining + } + + return reg, nil +} + +// mergedResolver resolves file descriptors from the local registry first, +// then falls back to the global registry (which contains well-known types). +type mergedResolver struct { + local *protoregistry.Files +} + +func (r *mergedResolver) FindFileByPath(p string) (protoreflect.FileDescriptor, error) { + if fd, err := r.local.FindFileByPath(p); err == nil { + return fd, nil + } + return protoregistry.GlobalFiles.FindFileByPath(p) +} + +func (r *mergedResolver) FindDescriptorByName(name protoreflect.FullName) (protoreflect.Descriptor, error) { + if d, err := r.local.FindDescriptorByName(name); err == nil { + return d, nil + } + return protoregistry.GlobalFiles.FindDescriptorByName(name) +} diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 1e4e7bb..3a7f0ee 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -61,6 +61,7 @@ func New(cfg *config.Config, m *metrics.Metrics) (*Engine, error) { "browser": driver.NewBrowserDriver(), "dns": driver.NewDNSDriver(), "websocket": driver.NewWebSocketDriver(), + "grpc": driver.NewGRPCDriver(), }, } From 35dec35e0452f33cb3a6312cc9960fc502a6eaed Mon Sep 17 00:00:00 2001 From: Aaron Lewter <77890109+lewta@users.noreply.github.com> Date: Fri, 27 Mar 2026 08:14:07 -0400 Subject: [PATCH 2/2] fix(lint): switch reflection client from v1alpha to v1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit grpc_reflection_v1alpha is deprecated at the proto-file level; staticcheck flags any use of it. The v1 API is identical from the client's perspective. Also drops ErrorCode from the error message — v1 ErrorResponse uses google.rpc.Code, not int32. Co-Authored-By: Claude Sonnet 4.6 --- internal/driver/grpc.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/internal/driver/grpc.go b/internal/driver/grpc.go index e7ef9bf..d71c3bd 100644 --- a/internal/driver/grpc.go +++ b/internal/driver/grpc.go @@ -14,7 +14,7 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/credentials" "google.golang.org/grpc/credentials/insecure" - reflectionpb "google.golang.org/grpc/reflection/grpc_reflection_v1alpha" + reflectionpb "google.golang.org/grpc/reflection/grpc_reflection_v1" "google.golang.org/grpc/status" "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/proto" @@ -240,8 +240,7 @@ func (d *GRPCDriver) fetchMethodInfo(ctx context.Context, conn *grpc.ClientConn, fdr, ok := resp.MessageResponse.(*reflectionpb.ServerReflectionResponse_FileDescriptorResponse) if !ok { if errResp, ok2 := resp.MessageResponse.(*reflectionpb.ServerReflectionResponse_ErrorResponse); ok2 { - return grpcMethodInfo{}, fmt.Errorf("reflection error %d: %s", - errResp.ErrorResponse.ErrorCode, errResp.ErrorResponse.ErrorMessage) + return grpcMethodInfo{}, fmt.Errorf("reflection error: %s", errResp.ErrorResponse.ErrorMessage) } return grpcMethodInfo{}, fmt.Errorf("unexpected reflection response type: %T", resp.MessageResponse) }