From e0291558ffb369c6f695bcc73182d75565ead156 Mon Sep 17 00:00:00 2001 From: Ryan Fowler Date: Thu, 19 Mar 2026 10:00:08 -0700 Subject: [PATCH 1/6] Add gRPC reflection and discovery without grpc module dependency --- AGENTS.md | 12 +- README.md | 2 +- docs/cli-reference.md | 26 +- docs/grpc.md | 59 +- integration/integration_test.go | 357 +++++++++++ internal/cli/app.go | 24 +- internal/cli/cli.go | 46 ++ internal/cli/cli_test.go | 56 ++ internal/client/client.go | 30 +- internal/fetch/fetch.go | 60 +- internal/fetch/grpc_reflection.go | 795 +++++++++++++++++++++++++ internal/fetch/grpc_reflection_test.go | 141 +++++ internal/fetch/proto.go | 23 +- main.go | 21 +- 14 files changed, 1584 insertions(+), 68 deletions(-) create mode 100644 internal/fetch/grpc_reflection.go create mode 100644 internal/fetch/grpc_reflection_test.go diff --git a/AGENTS.md b/AGENTS.md index 544261c..ad41afb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ This file provides guidance to AI agents when working with code in this reposito ## Project Overview -`fetch` is a modern HTTP(S) client CLI written in Go. It features automatic response formatting (JSON, XML, YAML, HTML, CSS, CSV, protobuf, msgpack), image rendering in terminals, gRPC support with JSON-to-protobuf conversion, and authentication (Basic, Bearer, AWS SigV4). +`fetch` is a modern HTTP(S) client CLI written in Go. It features automatic response formatting (JSON, XML, YAML, HTML, CSS, CSV, protobuf, msgpack), image rendering in terminals, gRPC support with reflection/discovery and JSON-to-protobuf conversion, and authentication (Basic, Bearer, AWS SigV4). The repository currently targets Go 1.26.1 in `go.mod` and GitHub Actions. @@ -52,7 +52,7 @@ prettier -w . - **internal/config** - INI-format config file parsing with host-specific overrides. - **internal/core** - Shared types (`Printer`, `Color`, `Format`, `HTTPVersion`) and utilities. - **internal/curl** - Curl command parser for `--from-curl` flag. Tokenizes and parses curl command strings into an intermediate `Result` struct. -- **internal/fetch** - Core HTTP request execution. `fetch.go:Fetch()` is the main entry point that builds requests, handles gRPC framing, and routes to formatters. +- **internal/fetch** - Core HTTP request execution. `fetch.go:Fetch()` is the main entry point that builds requests, handles gRPC framing/reflection/discovery, and routes to formatters. - **internal/fileutil** - Shared file helpers, including cross-platform atomic replacement for temp-file write flows. - **internal/format** - Response body formatters (JSON, XML, YAML, HTML, CSS, CSV, msgpack, protobuf, SSE, NDJSON). Each formatter writes colored output to a `Printer`. - **internal/grpc** - gRPC framing, headers, and status code handling. @@ -68,7 +68,13 @@ prettier -w . 1. CLI args parsed (`cli.Parse`) → `App` struct 2. Config file merged (`config.GetFile`) 3. `fetch.Request` built from merged config -4. If gRPC: load proto schema, setup descriptors, convert JSON→protobuf, frame message +4. If gRPC: load local proto schema or resolve it via reflection, setup descriptors, convert JSON→protobuf, frame message + +## Recent Notes + +- `--grpc-list` and `--grpc-describe` provide grpcurl-style discovery using reflection or local descriptor files. +- `--grpc` now automatically tries gRPC reflection when no local schema is supplied. +- Plaintext loopback gRPC servers are supported via `h2c` for both calls and discovery. 5. HTTP client executes request 6. Response formatted based on Content-Type and output to stdout (optionally via pager) diff --git a/README.md b/README.md index 281cdf3..d841515 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ A modern HTTP(S) client for the command line. - **Response formatting** - Automatic formatting and syntax highlighting for JSON, XML, YAML, HTML, CSS, CSV, Markdown, MessagePack, Protocol Buffers, and more - **Image rendering** - Display images directly in your terminal - **WebSocket support** - Bidirectional WebSocket connections with automatic JSON formatting -- **gRPC support** - Make gRPC calls with automatic JSON-to-protobuf conversion +- **gRPC support** - Make gRPC calls with automatic reflection, discovery, and JSON-to-protobuf conversion - **Authentication** - Built-in support for Basic Auth, Bearer Token, AWS Signature V4, and mTLS - **Compression** - Automatic gzip and zstd response body decompression - **TLS inspection** - Inspect TLS certificate chains, expiry, SANs, and OCSP status diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 1b3e83c..a39f3dc 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -489,15 +489,33 @@ See [WebSocket documentation](websocket.md) for details. ### `--grpc` -Enable gRPC mode. Automatically sets HTTP/2, POST method, and gRPC headers. +Enable gRPC mode. Automatically sets HTTP/2, POST method, and gRPC headers. When no local proto schema is provided, `fetch` automatically tries gRPC reflection before falling back to generic protobuf handling. ```sh fetch --grpc https://localhost:50051/package.Service/Method ``` +### `--grpc-list` + +List available gRPC services. Uses reflection when a URL is provided, or runs offline when `--proto-file` / `--proto-desc` is provided. + +```sh +fetch --grpc-list https://localhost:50051 +fetch --grpc-list --proto-desc service.pb +``` + +### `--grpc-describe NAME` + +Describe a gRPC service, method, or message. Accepts `package.Service`, `package.Service/Method`, `package.Service.Method`, and full message names. + +```sh +fetch --grpc-describe grpc.health.v1.Health https://localhost:50051 +fetch --grpc-describe grpc.health.v1.Health/Check --proto-desc service.pb +``` + ### `--proto-file PATH` -Compile `.proto` file(s) for JSON-to-protobuf conversion. Requires `protoc`. Can specify multiple comma-separated paths. +Compile `.proto` file(s) for gRPC requests or offline discovery. Requires `protoc`. Can specify multiple comma-separated paths. ```sh fetch --grpc --proto-file service.proto -j '{"field": "value"}' localhost:50051/pkg.Svc/Method @@ -505,7 +523,7 @@ fetch --grpc --proto-file service.proto -j '{"field": "value"}' localhost:50051/ ### `--proto-desc PATH` -Use pre-compiled descriptor set file instead of `--proto-file`. +Use a pre-compiled descriptor set file instead of `--proto-file`. ```sh # Generate descriptor: @@ -523,6 +541,8 @@ Add import paths for proto compilation. Use with `--proto-file`. fetch --grpc --proto-file service.proto --proto-import ./proto localhost:50051/pkg.Svc/Method ``` +Plaintext local gRPC servers are supported via `h2c`, so loopback URLs like `http://127.0.0.1:50051` work for both `--grpc` and reflection-based discovery. + ## Configuration ### `-c, --config PATH` diff --git a/docs/grpc.md b/docs/grpc.md index 3f14c40..06143ee 100644 --- a/docs/grpc.md +++ b/docs/grpc.md @@ -1,6 +1,6 @@ # gRPC Support -`fetch` supports making gRPC calls with automatic protocol handling, JSON-to-protobuf conversion, and formatted responses. +`fetch` supports making gRPC calls with automatic protocol handling, reflection-backed schema discovery, JSON-to-protobuf conversion, and formatted responses. ## Overview @@ -22,6 +22,41 @@ Enable gRPC mode. This flag: fetch --grpc https://localhost:50051/package.Service/Method ``` +When `--proto-file` or `--proto-desc` is not provided, `fetch` automatically tries gRPC reflection for schema-aware request conversion and response formatting. + +## Reflection and Discovery + +### `--grpc-list` + +List available services from a reflection-enabled server: + +```sh +fetch --grpc-list https://localhost:50051 +``` + +Or run the same lookup offline with a local descriptor set: + +```sh +fetch --grpc-list --proto-desc service.pb +``` + +### `--grpc-describe NAME` + +Describe a service, method, or message: + +```sh +fetch --grpc-describe grpc.health.v1.Health https://localhost:50051 +fetch --grpc-describe grpc.health.v1.Health/Check https://localhost:50051 +fetch --grpc-describe grpc.health.v1.HealthCheckRequest --proto-desc service.pb +``` + +`NAME` accepts: + +- `package.Service` +- `package.Service/Method` +- `package.Service.Method` +- full message names + ### URL Format The service and method are specified in the URL path: @@ -39,7 +74,7 @@ fetch --grpc https://localhost:50051/echo.EchoService/Echo ## Proto Schema Options -To enable JSON-to-protobuf conversion and rich response formatting, provide a proto schema. +To enable offline discovery, guaranteed JSON-to-protobuf conversion, or to bypass reflection entirely, provide a local proto schema. ### `--proto-file PATH` @@ -106,11 +141,14 @@ fetch --grpc \ ### Without Proto Schema -When no schema is provided: +When no local schema is provided: -- Request bodies must be raw protobuf (not JSON) -- Responses are formatted using generic protobuf parsing -- Field numbers are shown instead of names +- `fetch` first tries gRPC reflection +- If reflection is available, request/response descriptors are resolved automatically +- If reflection is unavailable, schema-less requests still work for raw/empty protobuf bodies and responses fall back to generic protobuf formatting +- JSON request bodies fail with an actionable error because descriptors are required for conversion + +If reflection is unavailable and you need schema-aware behavior, pass `--proto-file` or `--proto-desc`. ## Request Bodies @@ -217,6 +255,15 @@ fetch --grpc --insecure \ https://localhost:50051/pkg.Service/Method ``` +### Plaintext Local Servers (`h2c`) + +For local development servers that use plaintext HTTP/2, use an `http://` URL: + +```sh +fetch --grpc -j '{"service":""}' http://127.0.0.1:50051/grpc.health.v1.Health/Check +fetch --grpc-list http://127.0.0.1:50051 +``` + ### Custom CA Certificate ```sh diff --git a/integration/integration_test.go b/integration/integration_test.go index 75aea95..9e48f5b 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -37,6 +37,8 @@ import ( "github.com/coder/websocket" "github.com/klauspost/compress/gzip" "github.com/klauspost/compress/zstd" + "golang.org/x/net/http2" + "golang.org/x/net/http2/h2c" "google.golang.org/protobuf/encoding/protowire" protoMarshal "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/descriptorpb" @@ -1250,6 +1252,98 @@ func TestMain(t *testing.T) { assertBufContains(t, res.stderr, "does not exist") }) + t.Run("grpc reflection discovery and calls", func(t *testing.T) { + t.Parallel() + + t.Run("tls reflection supports list describe and json call", func(t *testing.T) { + t.Parallel() + server := startReflectionGRPCServer(t, true, true) + t.Cleanup(server.cleanup) + + res := runFetch(t, fetchPath, "--grpc-list", "--ca-cert", server.caCertPath, server.url) + assertExitCode(t, 0, res) + assertBufContains(t, res.stdout, "grpc.health.v1.Health") + + res = runFetch(t, fetchPath, "--grpc-describe", "grpc.health.v1.Health/Check", "--ca-cert", server.caCertPath, server.url) + assertExitCode(t, 0, res) + assertBufContains(t, res.stdout, "method grpc.health.v1.Health/Check") + assertBufContains(t, res.stdout, "rpc: unary") + assertBufContains(t, res.stdout, "request: grpc.health.v1.HealthCheckRequest") + + res = runFetch(t, fetchPath, + server.url+"/grpc.health.v1.Health/Check", + "--grpc", "--ca-cert", server.caCertPath, + "-j", `{"service":""}`, + "--format", "on", + ) + assertExitCode(t, 0, res) + assertBufContains(t, res.stdout, `"status": "SERVING"`) + }) + + t.Run("plaintext h2c reflection works for local servers", func(t *testing.T) { + t.Parallel() + server := startReflectionGRPCServer(t, false, true) + t.Cleanup(server.cleanup) + + res := runFetch(t, fetchPath, "--grpc-list", server.url) + assertExitCode(t, 0, res) + assertBufContains(t, res.stdout, "grpc.health.v1.Health") + + res = runFetch(t, fetchPath, + server.url+"/grpc.health.v1.Health/Check", + "--grpc", + "-j", `{"service":""}`, + "--format", "on", + ) + assertExitCode(t, 0, res) + assertBufContains(t, res.stdout, `"status": "SERVING"`) + }) + + t.Run("reflection unavailable errors are actionable", func(t *testing.T) { + t.Parallel() + server := startReflectionGRPCServer(t, false, false) + t.Cleanup(server.cleanup) + + res := runFetch(t, fetchPath, "--grpc-list", server.url) + assertExitCode(t, 1, res) + assertBufContains(t, res.stderr, "gRPC reflection is unavailable") + assertBufContains(t, res.stderr, "--proto-file") + + res = runFetch(t, fetchPath, + server.url+"/grpc.health.v1.Health/Check", + "--grpc", + "-j", `{"service":""}`, + ) + assertExitCode(t, 1, res) + assertBufContains(t, res.stderr, "gRPC reflection is unavailable") + assertBufContains(t, res.stderr, "--proto-desc") + + res = runFetch(t, fetchPath, + server.url+"/grpc.health.v1.Health/Check", + "--grpc", "--format", "on", + ) + assertExitCode(t, 0, res) + assertBufNotEmpty(t, res.stdout) + }) + + t.Run("local schema discovery runs offline and wins over reflection", func(t *testing.T) { + t.Parallel() + descFile := writeHealthDescriptorSet(t) + + res := runFetch(t, fetchPath, "--grpc-list", "--proto-desc", descFile) + assertExitCode(t, 0, res) + assertBufContains(t, res.stdout, "grpc.health.v1.Health") + + res = runFetch(t, fetchPath, + "--grpc-describe", "grpc.health.v1.Health", + "--proto-desc", descFile, + "http://127.0.0.1:1", + ) + assertExitCode(t, 0, res) + assertBufContains(t, res.stdout, "service grpc.health.v1.Health") + }) + }) + t.Run("update", func(t *testing.T) { t.Parallel() // Copy the binary to a test-specific directory so the update @@ -2965,6 +3059,265 @@ func startServer(h http.HandlerFunc) *httptest.Server { return httptest.NewServer(h) } +type reflectionGRPCServer struct { + url string + caCertPath string + cleanup func() +} + +func startReflectionGRPCServer(t *testing.T, useTLS, enableReflection bool) reflectionGRPCServer { + t.Helper() + + handler := newReflectionGRPCHandler(t, enableReflection) + server := httptest.NewUnstartedServer(handler) + + var caCertPath string + var scheme string + if useTLS { + dir := t.TempDir() + caCert, caKey := generateCACert(t) + serverCert, serverKey := generateCert(t, caCert, caKey, "grpc-reflection") + caCertPath = writeTempPEM(t, dir, "grpc-reflection-ca.crt", "CERTIFICATE", caCert.Raw) + serverCertPath := writeTempPEM(t, dir, "grpc-reflection-server.crt", "CERTIFICATE", serverCert.Raw) + serverKeyPath := writeTempPEM(t, dir, "grpc-reflection-server.key", "RSA PRIVATE KEY", x509.MarshalPKCS1PrivateKey(serverKey)) + tlsCert, err := tls.LoadX509KeyPair(serverCertPath, serverKeyPath) + if err != nil { + t.Fatalf("LoadX509KeyPair: %v", err) + } + server.EnableHTTP2 = true + server.TLS = &tls.Config{Certificates: []tls.Certificate{tlsCert}} + server.StartTLS() + scheme = "https" + } else { + server.Config.Handler = h2c.NewHandler(handler, &http2.Server{}) + server.Start() + scheme = "http" + } + + return reflectionGRPCServer{ + url: scheme + "://" + server.Listener.Addr().String(), + caCertPath: caCertPath, + cleanup: func() { + server.Close() + }, + } +} + +func writeHealthDescriptorSet(t *testing.T) string { + t.Helper() + + fds := buildHealthDescriptorSet() + data, err := protoMarshal.Marshal(fds) + if err != nil { + t.Fatalf("marshal descriptor set: %v", err) + } + + path := filepath.Join(t.TempDir(), "grpc-health.pb") + if err := os.WriteFile(path, data, 0o644); err != nil { + t.Fatalf("write descriptor set: %v", err) + } + return path +} + +func newReflectionGRPCHandler(t *testing.T, enableReflection bool) http.Handler { + t.Helper() + + descriptorSet := buildHealthDescriptorSet() + descriptorData, err := protoMarshal.Marshal(descriptorSet.File[0]) + if err != nil { + t.Fatalf("marshal descriptor: %v", err) + } + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/grpc+proto") + + switch r.URL.Path { + case "/grpc.health.v1.Health/Check": + writeGRPCFrameResponse(w, buildHealthCheckResponse()) + case "/grpc.reflection.v1.ServerReflection/ServerReflectionInfo", "/grpc.reflection.v1alpha.ServerReflection/ServerReflectionInfo": + if !enableReflection { + writeGRPCErrorResponse(w, "12", "reflection disabled") + return + } + payload, ok := readSingleGRPCFrame(t, r.Body) + if !ok { + writeGRPCErrorResponse(w, "3", "invalid reflection request") + return + } + resp, err := buildReflectionResponse(payload, descriptorData) + if err != nil { + writeGRPCErrorResponse(w, "5", err.Error()) + return + } + writeGRPCFrameResponse(w, resp) + default: + writeGRPCErrorResponse(w, "12", "unimplemented") + } + }) +} + +func buildHealthDescriptorSet() *descriptorpb.FileDescriptorSet { + strType := descriptorpb.FieldDescriptorProto_TYPE_STRING + enumType := descriptorpb.FieldDescriptorProto_TYPE_ENUM + return &descriptorpb.FileDescriptorSet{ + File: []*descriptorpb.FileDescriptorProto{ + { + Name: ptr("grpc/health/v1/health.proto"), + Package: ptr("grpc.health.v1"), + Syntax: ptr("proto3"), + MessageType: []*descriptorpb.DescriptorProto{ + { + Name: ptr("HealthCheckRequest"), + Field: []*descriptorpb.FieldDescriptorProto{ + { + Name: ptr("service"), + Number: ptr(int32(1)), + Type: &strType, + }, + }, + }, + { + Name: ptr("HealthCheckResponse"), + Field: []*descriptorpb.FieldDescriptorProto{ + { + Name: ptr("status"), + Number: ptr(int32(1)), + Type: &enumType, + TypeName: ptr(".grpc.health.v1.HealthCheckResponse.ServingStatus"), + }, + }, + EnumType: []*descriptorpb.EnumDescriptorProto{ + { + Name: ptr("ServingStatus"), + Value: []*descriptorpb.EnumValueDescriptorProto{ + {Name: ptr("UNKNOWN"), Number: ptr(int32(0))}, + {Name: ptr("SERVING"), Number: ptr(int32(1))}, + {Name: ptr("NOT_SERVING"), Number: ptr(int32(2))}, + {Name: ptr("SERVICE_UNKNOWN"), Number: ptr(int32(3))}, + }, + }, + }, + }, + }, + Service: []*descriptorpb.ServiceDescriptorProto{ + { + Name: ptr("Health"), + Method: []*descriptorpb.MethodDescriptorProto{ + { + Name: ptr("Check"), + InputType: ptr(".grpc.health.v1.HealthCheckRequest"), + OutputType: ptr(".grpc.health.v1.HealthCheckResponse"), + }, + }, + }, + }, + }, + }, + } +} + +func readSingleGRPCFrame(t *testing.T, body io.Reader) ([]byte, bool) { + t.Helper() + + var header [5]byte + if _, err := io.ReadFull(body, header[:]); err != nil { + return nil, false + } + length := binary.BigEndian.Uint32(header[1:5]) + payload := make([]byte, length) + if _, err := io.ReadFull(body, payload); err != nil { + return nil, false + } + return payload, true +} + +func buildHealthCheckResponse() []byte { + var data []byte + data = protowire.AppendTag(data, 1, protowire.VarintType) + data = protowire.AppendVarint(data, 1) + return data +} + +func buildReflectionResponse(req []byte, descriptor []byte) ([]byte, error) { + for len(req) > 0 { + num, typ, n := protowire.ConsumeTag(req) + if n < 0 { + return nil, protowire.ParseError(n) + } + req = req[n:] + switch { + case num == 7 && typ == protowire.BytesType: + _, m := protowire.ConsumeString(req) + if m < 0 { + return nil, protowire.ParseError(m) + } + return buildReflectionListResponse("grpc.health.v1.Health"), nil + case num == 4 && typ == protowire.BytesType: + symbol, m := protowire.ConsumeString(req) + if m < 0 { + return nil, protowire.ParseError(m) + } + switch symbol { + case "grpc.health.v1.Health", + "grpc.health.v1.Health.Check", + "grpc.health.v1.HealthCheckRequest", + "grpc.health.v1.HealthCheckResponse": + return buildReflectionDescriptorResponse(descriptor), nil + default: + return nil, fmt.Errorf("symbol not found: %s", symbol) + } + default: + m := protowire.ConsumeFieldValue(num, typ, req) + if m < 0 { + return nil, protowire.ParseError(m) + } + req = req[m:] + } + } + return nil, errors.New("unsupported reflection request") +} + +func buildReflectionListResponse(names ...string) []byte { + var list []byte + for _, name := range names { + var service []byte + service = protowire.AppendTag(service, 1, protowire.BytesType) + service = protowire.AppendString(service, name) + list = protowire.AppendTag(list, 1, protowire.BytesType) + list = protowire.AppendBytes(list, service) + } + + var resp []byte + resp = protowire.AppendTag(resp, 6, protowire.BytesType) + resp = protowire.AppendBytes(resp, list) + return resp +} + +func buildReflectionDescriptorResponse(descriptor []byte) []byte { + var fdResp []byte + fdResp = protowire.AppendTag(fdResp, 1, protowire.BytesType) + fdResp = protowire.AppendBytes(fdResp, descriptor) + + var resp []byte + resp = protowire.AppendTag(resp, 4, protowire.BytesType) + resp = protowire.AppendBytes(resp, fdResp) + return resp +} + +func writeGRPCFrameResponse(w http.ResponseWriter, payload []byte) { + frame := make([]byte, 5+len(payload)) + binary.BigEndian.PutUint32(frame[1:5], uint32(len(payload))) + copy(frame[5:], payload) + w.WriteHeader(http.StatusOK) + w.Write(frame) +} + +func writeGRPCErrorResponse(w http.ResponseWriter, status, message string) { + w.Header().Set("Grpc-Status", status) + w.Header().Set("Grpc-Message", message) + w.WriteHeader(http.StatusOK) +} + func startUnixServer(path string, h http.HandlerFunc) (*httptest.Server, error) { server := httptest.NewUnstartedServer(h) l, err := net.Listen("unix", path) @@ -3015,6 +3368,10 @@ func getOptionalModTime(t *testing.T, path string) *time.Time { return &mt } +func ptr[T any](v T) *T { + return &v +} + func assertExitCode(t *testing.T, exp int, res runResult) { t.Helper() diff --git a/internal/cli/app.go b/internal/cli/app.go index d8857f2..955c834 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -35,6 +35,8 @@ type App struct { Form []core.KeyVal[string] FromCurl string GRPC bool + GRPCDescribe string + GRPCList bool Help bool InspectTLS bool WS bool // set when URL scheme is ws:// or wss:// @@ -60,6 +62,18 @@ func (a *App) PrintHelp(p *core.Printer) { printHelp(a.CLI(), p) } +func (a *App) HasGRPCDiscovery() bool { + return a.GRPCList || a.GRPCDescribe != "" +} + +func (a *App) HasGRPCMode() bool { + return a.GRPC || a.HasGRPCDiscovery() +} + +func (a *App) HasProtoSchema() bool { + return len(a.ProtoFiles) > 0 || a.ProtoDesc != "" +} + func (a *App) CLI() *CLI { var extraArgs bool return &CLI{ @@ -101,14 +115,12 @@ func (a *App) CLI() *CLI { }, RequiredFlags: []core.KeyVal[[]string]{ {Key: "key", Val: []string{"cert"}}, - {Key: "proto-desc", Val: []string{"grpc"}}, - {Key: "proto-file", Val: []string{"grpc"}}, {Key: "proto-import", Val: []string{"proto-file"}}, {Key: "remote-header-name", Val: []string{"remote-name"}}, }, SchemeExclusiveFlags: map[string][]string{ - "ws": {"discard", "grpc", "form", "multipart", "xml", "edit"}, - "wss": {"discard", "grpc", "form", "multipart", "xml", "edit"}, + "ws": {"discard", "grpc", "grpc-describe", "grpc-list", "form", "multipart", "xml", "edit"}, + "wss": {"discard", "grpc", "grpc-describe", "grpc-list", "form", "multipart", "xml", "edit"}, }, FromCurlExclusiveFlags: []string{ "method", "header", "data", "json", "xml", @@ -117,7 +129,7 @@ func (a *App) CLI() *CLI { "range", "unix", "timeout", "connect-timeout", "redirects", "proxy", "insecure", "tls", "http", "cert", "key", "ca-cert", "dns-server", - "retry", "retry-delay", "grpc", "query", + "retry", "retry-delay", "grpc", "grpc-describe", "grpc-list", "query", }, Flags: []Flag{ // cfgFlag: delegates to config parser @@ -222,6 +234,8 @@ func (a *App) CLI() *CLI { stringFlag(&a.FromCurl, "from-curl", "", "COMMAND", "Execute a curl command using fetch"), boolFlag(&a.GRPC, "grpc", "", "Enable gRPC mode"), + stringFlag(&a.GRPCDescribe, "grpc-describe", "", "NAME", "Describe a gRPC service, method, or message"), + boolFlag(&a.GRPCList, "grpc-list", "", "List available gRPC services"), cfgFlag("header", "H", "NAME:VALUE", "Set headers for the request", func() bool { return len(a.Cfg.Headers) > 0 }, a.Cfg.ParseHeader), diff --git a/internal/cli/cli.go b/internal/cli/cli.go index a5747f3..9b324f7 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -284,6 +284,9 @@ func Parse(args []string) (*App, error) { if err := validateSchemeExclusives(&app, cli, long); err != nil { return &app, err } + if err := validateGRPCModes(&app, long); err != nil { + return &app, err + } return &app, nil } @@ -311,6 +314,49 @@ func validateSchemeExclusives(app *App, cli *CLI, long map[string]Flag) error { return nil } +func validateGRPCModes(app *App, long map[string]Flag) error { + if !app.HasProtoSchema() && !app.HasGRPCMode() { + return nil + } + + if !app.HasGRPCMode() { + name := "proto-file" + if app.ProtoDesc != "" { + name = "proto-desc" + } + return newRequiredFlagError(name, []string{"grpc", "grpc-list", "grpc-describe"}) + } + + if app.GRPC && app.GRPCDescribe != "" { + return newExclusiveFlagsError("grpc", "grpc-describe") + } + if app.GRPC && app.GRPCList { + return newExclusiveFlagsError("grpc", "grpc-list") + } + if app.GRPCDescribe != "" && app.GRPCList { + return newExclusiveFlagsError("grpc-describe", "grpc-list") + } + + if !app.HasGRPCDiscovery() { + return nil + } + + for _, name := range []string{ + "data", "json", "xml", "form", "multipart", + "edit", "output", "remote-name", "remote-header-name", + "discard", "method", + } { + if flag, ok := long[name]; ok && flag.IsSet() { + if app.GRPCDescribe != "" { + return newExclusiveFlagsError("grpc-describe", name) + } + return newExclusiveFlagsError("grpc-list", name) + } + } + + return nil +} + func printHelp(cli *CLI, p *core.Printer) { p.WriteString(cli.Description) p.WriteString("\n\n") diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 6cd15ed..70aa6ee 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -133,3 +133,59 @@ func TestCLI(t *testing.T) { } } } + +func TestGRPCDiscoveryFlags(t *testing.T) { + t.Run("grpc list parses", func(t *testing.T) { + app, err := Parse([]string{"--grpc-list", "localhost:50051"}) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + if !app.GRPCList { + t.Fatal("expected GRPCList to be set") + } + if app.URL == nil { + t.Fatal("expected URL to be parsed") + } + }) + + t.Run("proto desc accepts grpc describe without url", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "service.pb") + os.WriteFile(path, []byte("placeholder"), 0o644) + + app, err := Parse([]string{"--grpc-describe", "pkg.Service", "--proto-desc", path}) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + if app.GRPCDescribe != "pkg.Service" { + t.Fatalf("GRPCDescribe = %q, want %q", app.GRPCDescribe, "pkg.Service") + } + if app.URL != nil { + t.Fatal("expected URL to be optional for offline discovery") + } + }) + + t.Run("proto desc requires grpc mode", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "service.pb") + os.WriteFile(path, []byte("placeholder"), 0o644) + + _, err := Parse([]string{"--proto-desc", path}) + if err == nil { + t.Fatal("expected error for proto-desc without grpc mode") + } + if !strings.Contains(err.Error(), "requires one of '--grpc', '--grpc-list', '--grpc-describe'") { + t.Fatalf("unexpected error: %v", err) + } + }) + + t.Run("grpc discovery rejects request body flags", func(t *testing.T) { + _, err := Parse([]string{"--grpc-list", "--data", "hello", "localhost:50051"}) + if err == nil { + t.Fatal("expected error for grpc-list with data") + } + if !strings.Contains(err.Error(), "cannot be used together") { + t.Fatalf("unexpected error: %v", err) + } + }) +} diff --git a/internal/client/client.go b/internal/client/client.go index 6f1da24..9ca6872 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -56,6 +56,7 @@ type ClientConfig struct { ClientCert *tls.Certificate ConnectTimeout time.Duration DNSServer *url.URL + H2C bool HTTP core.HTTPVersion Insecure bool Proxy *url.URL @@ -97,7 +98,11 @@ func NewClient(cfg ClientConfig) *Client { var transport http.RoundTripper switch cfg.HTTP { case core.HTTP2: - transport = getHTTP2Transport(baseDial, tlsConfig, cfg.ConnectTimeout) + if cfg.H2C { + transport = getH2CTransport(baseDial, cfg.ConnectTimeout) + } else { + transport = getHTTP2Transport(baseDial, tlsConfig, cfg.ConnectTimeout) + } case core.HTTP3: transport = getHTTP3Transport(cfg.DNSServer, tlsConfig, cfg.ConnectTimeout) default: @@ -212,7 +217,7 @@ func newDialTLSWithConnectTimeout(baseDial func(context.Context, string, string) func getHTTP2Transport(baseDial func(context.Context, string, string) (net.Conn, error), tlsConfig *tls.Config, connectTimeout time.Duration) http.RoundTripper { return &http2.Transport{ - AllowHTTP: false, // Disable h2c, for now. + AllowHTTP: false, DialTLSContext: func(ctx context.Context, network string, addr string, cfg *tls.Config) (net.Conn, error) { if connectTimeout > 0 { var cancel context.CancelFunc @@ -254,6 +259,27 @@ func getHTTP2Transport(baseDial func(context.Context, string, string) (net.Conn, } } +func getH2CTransport(baseDial func(context.Context, string, string) (net.Conn, error), connectTimeout time.Duration) http.RoundTripper { + return &http2.Transport{ + AllowHTTP: true, + DialTLSContext: func(ctx context.Context, network, addr string, _ *tls.Config) (net.Conn, error) { + if connectTimeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, connectTimeout) + defer cancel() + } + + dial := baseDial + if dial == nil { + var dialer net.Dialer + dial = dialer.DialContext + } + return dial(ctx, network, addr) + }, + DisableCompression: true, + } +} + func getHTTP3Transport(dnsServer *url.URL, tlsConfig *tls.Config, connectTimeout time.Duration) http.RoundTripper { rt := &http3.Transport{ DisableCompression: true, diff --git a/internal/fetch/fetch.go b/internal/fetch/fetch.go index f86c52f..b5d966f 100644 --- a/internal/fetch/fetch.go +++ b/internal/fetch/fetch.go @@ -22,7 +22,7 @@ import ( "github.com/ryanfowler/fetch/internal/format" "github.com/ryanfowler/fetch/internal/image" "github.com/ryanfowler/fetch/internal/multipart" - "github.com/ryanfowler/fetch/internal/proto" + iproto "github.com/ryanfowler/fetch/internal/proto" "github.com/ryanfowler/fetch/internal/session" "google.golang.org/protobuf/reflect/protoreflect" @@ -58,6 +58,8 @@ type Request struct { Form []core.KeyVal[string] Format core.Format GRPC bool + GRPCDescribe string + GRPCList bool Headers []core.KeyVal[string] HTTP core.HTTPVersion IgnoreStatus bool @@ -93,6 +95,18 @@ type Request struct { responseDescriptor protoreflect.MessageDescriptor } +func (r *Request) HasGRPCDiscovery() bool { + return r.GRPCList || r.GRPCDescribe != "" +} + +func (r *Request) HasGRPCMode() bool { + return r.GRPC || r.HasGRPCDiscovery() +} + +func (r *Request) HasLocalProtoSchema() bool { + return len(r.ProtoFiles) > 0 || r.ProtoDesc != "" +} + func Fetch(ctx context.Context, r *Request) int { code, err := fetch(ctx, r) if err == nil { @@ -112,43 +126,31 @@ func Fetch(ctx context.Context, r *Request) int { } func fetch(ctx context.Context, r *Request) (int, error) { - // 1. Load proto schema if configured. - var schema *proto.Schema - if len(r.ProtoFiles) > 0 || r.ProtoDesc != "" { - var err error - schema, err = loadProtoSchema(r) - if err != nil { - return 0, err - } + if r.GRPC { + applyGRPCDefaults(r) } - // 2. Setup gRPC (adds headers, sets HTTP version, finds descriptors). + // 1. Create the HTTP client. + c := newGRPCClient(r) + defer c.Close() + + // 2. Resolve any proto schema and configure gRPC descriptors. + var schema *iproto.Schema var requestDesc protoreflect.MessageDescriptor var isClientStreaming bool if r.GRPC { var err error + schema, err = resolveCallSchema(ctx, r, c) + if err != nil { + return 0, err + } requestDesc, r.responseDescriptor, isClientStreaming, err = setupGRPC(r, schema) if err != nil { return 0, err } } - // 3. Create HTTP client and request. - c := client.NewClient(client.ClientConfig{ - CACerts: r.CACerts, - ClientCert: r.ClientCert, - ConnectTimeout: r.ConnectTimeout, - DNSServer: r.DNSServer, - HTTP: r.HTTP, - Insecure: r.Insecure, - Proxy: r.Proxy, - Redirects: r.Redirects, - TLS: r.TLS, - UnixSocket: r.UnixSocket, - }) - defer c.Close() - - // Load session and set cookie jar, if configured. + // 3. Load session and set cookie jar, if configured. var sess *session.Session if r.Session != "" { var loadErr error @@ -165,6 +167,10 @@ func fetch(ctx context.Context, r *Request) (int, error) { c.SetJar(sess.Jar()) } + headers := r.Headers + if r.GRPC { + headers = grpcHeaders(r.Headers) + } req, err := c.NewRequest(ctx, client.RequestConfig{ AWSSigV4: r.AWSSigv4, Basic: r.Basic, @@ -172,7 +178,7 @@ func fetch(ctx context.Context, r *Request) (int, error) { ContentType: r.ContentType, Data: r.Data, Form: r.Form, - Headers: r.Headers, + Headers: headers, HTTP: r.HTTP, Method: r.Method, Multipart: r.Multipart, diff --git a/internal/fetch/grpc_reflection.go b/internal/fetch/grpc_reflection.go new file mode 100644 index 0000000..0572d37 --- /dev/null +++ b/internal/fetch/grpc_reflection.go @@ -0,0 +1,795 @@ +package fetch + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "mime" + "net/http" + "net/url" + "slices" + "sort" + "strings" + + "github.com/ryanfowler/fetch/internal/client" + "github.com/ryanfowler/fetch/internal/core" + fetchgrpc "github.com/ryanfowler/fetch/internal/grpc" + iproto "github.com/ryanfowler/fetch/internal/proto" + + "google.golang.org/protobuf/encoding/protowire" + gproto "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/types/descriptorpb" +) + +const ( + reflectionV1Path = "/grpc.reflection.v1.ServerReflection/ServerReflectionInfo" + reflectionV1AlphaPath = "/grpc.reflection.v1alpha.ServerReflection/ServerReflectionInfo" +) + +type reflectionUnavailableError struct { + err error +} + +func (e *reflectionUnavailableError) Error() string { + if e.err == nil { + return "gRPC reflection is unavailable; provide --proto-file or --proto-desc" + } + return fmt.Sprintf("gRPC reflection is unavailable: %s. Provide --proto-file or --proto-desc", e.err) +} + +func (e *reflectionUnavailableError) Unwrap() error { + return e.err +} + +type descriptorSetBuilder struct { + files map[string]*descriptorpb.FileDescriptorProto +} + +func newDescriptorSetBuilder() *descriptorSetBuilder { + return &descriptorSetBuilder{files: make(map[string]*descriptorpb.FileDescriptorProto)} +} + +func (b *descriptorSetBuilder) Add(encoded [][]byte) error { + for _, raw := range encoded { + fd := &descriptorpb.FileDescriptorProto{} + if err := gproto.Unmarshal(raw, fd); err != nil { + return fmt.Errorf("failed to decode reflected descriptor: %w", err) + } + name := fd.GetName() + if name == "" { + return errors.New("reflected descriptor is missing a file name") + } + if _, exists := b.files[name]; exists { + continue + } + b.files[name] = fd + } + return nil +} + +func (b *descriptorSetBuilder) Build() (*iproto.Schema, error) { + fds := &descriptorpb.FileDescriptorSet{ + File: make([]*descriptorpb.FileDescriptorProto, 0, len(b.files)), + } + names := make([]string, 0, len(b.files)) + for name := range b.files { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + fds.File = append(fds.File, b.files[name]) + } + return iproto.LoadFromDescriptorSet(fds) +} + +type reflectionProtocol struct { + name string + path string +} + +var reflectionProtocols = []reflectionProtocol{ + {name: "v1", path: reflectionV1Path}, + {name: "v1alpha", path: reflectionV1AlphaPath}, +} + +type reflectionInvoker func(ctx context.Context, path string, payload []byte) ([][]byte, error) + +type reflectionClient struct { + request *Request + client *client.Client + invoke reflectionInvoker +} + +func newReflectionClient(r *Request, c *client.Client) *reflectionClient { + rc := &reflectionClient{ + request: r, + client: c, + } + rc.invoke = rc.invokeHTTP + return rc +} + +func (rc *reflectionClient) ListServices(ctx context.Context) ([]string, error) { + var lastErr error + for i, protocol := range reflectionProtocols { + frames, err := rc.invoke(ctx, protocol.path, buildReflectionListRequest()) + if err != nil { + if i == 0 && isReflectionUnimplemented(err) { + lastErr = err + continue + } + return nil, &reflectionUnavailableError{err: err} + } + if len(frames) == 0 { + return nil, &reflectionUnavailableError{err: errors.New("empty reflection response")} + } + names, err := parseReflectionListResponse(frames[0]) + if err != nil { + return nil, &reflectionUnavailableError{err: err} + } + sort.Strings(names) + return names, nil + } + return nil, &reflectionUnavailableError{err: lastErr} +} + +func (rc *reflectionClient) SchemaForSymbol(ctx context.Context, symbol string) (*iproto.Schema, error) { + var lastErr error + for i, protocol := range reflectionProtocols { + frames, err := rc.invoke(ctx, protocol.path, buildReflectionSymbolRequest(symbol)) + if err != nil { + if i == 0 && isReflectionUnimplemented(err) { + lastErr = err + continue + } + return nil, &reflectionUnavailableError{err: err} + } + builder := newDescriptorSetBuilder() + for _, frame := range frames { + descs, err := parseReflectionFileDescriptorResponse(frame) + if err != nil { + return nil, &reflectionUnavailableError{err: err} + } + if err := builder.Add(descs); err != nil { + return nil, &reflectionUnavailableError{err: err} + } + } + schema, err := builder.Build() + if err != nil { + return nil, &reflectionUnavailableError{err: err} + } + return schema, nil + } + return nil, &reflectionUnavailableError{err: lastErr} +} + +func buildReflectionListRequest() []byte { + var data []byte + data = protowire.AppendTag(data, 7, protowire.BytesType) + data = protowire.AppendString(data, "*") + return data +} + +func buildReflectionSymbolRequest(symbol string) []byte { + var data []byte + data = protowire.AppendTag(data, 4, protowire.BytesType) + data = protowire.AppendString(data, symbol) + return data +} + +func parseReflectionListResponse(raw []byte) ([]string, error) { + var names []string + for len(raw) > 0 { + num, typ, n := protowire.ConsumeTag(raw) + if n < 0 { + return nil, protowire.ParseError(n) + } + raw = raw[n:] + switch { + case num == 6 && typ == protowire.BytesType: + listData, m := protowire.ConsumeBytes(raw) + if m < 0 { + return nil, protowire.ParseError(m) + } + raw = raw[m:] + var err error + names, err = parseReflectionServiceList(listData) + if err != nil { + return nil, err + } + case num == 7 && typ == protowire.BytesType: + errData, m := protowire.ConsumeBytes(raw) + if m < 0 { + return nil, protowire.ParseError(m) + } + return nil, parseReflectionError(errData) + default: + m := protowire.ConsumeFieldValue(num, typ, raw) + if m < 0 { + return nil, protowire.ParseError(m) + } + raw = raw[m:] + } + } + if names == nil { + return nil, errors.New("missing list services response") + } + return names, nil +} + +func parseReflectionServiceList(raw []byte) ([]string, error) { + var names []string + for len(raw) > 0 { + num, typ, n := protowire.ConsumeTag(raw) + if n < 0 { + return nil, protowire.ParseError(n) + } + raw = raw[n:] + if num != 1 || typ != protowire.BytesType { + m := protowire.ConsumeFieldValue(num, typ, raw) + if m < 0 { + return nil, protowire.ParseError(m) + } + raw = raw[m:] + continue + } + serviceData, m := protowire.ConsumeBytes(raw) + if m < 0 { + return nil, protowire.ParseError(m) + } + raw = raw[m:] + name, err := parseReflectionServiceName(serviceData) + if err != nil { + return nil, err + } + names = append(names, name) + } + return names, nil +} + +func parseReflectionServiceName(raw []byte) (string, error) { + for len(raw) > 0 { + num, typ, n := protowire.ConsumeTag(raw) + if n < 0 { + return "", protowire.ParseError(n) + } + raw = raw[n:] + if num == 1 && typ == protowire.BytesType { + name, m := protowire.ConsumeString(raw) + if m < 0 { + return "", protowire.ParseError(m) + } + return name, nil + } + m := protowire.ConsumeFieldValue(num, typ, raw) + if m < 0 { + return "", protowire.ParseError(m) + } + raw = raw[m:] + } + return "", errors.New("reflection service response missing service name") +} + +func parseReflectionFileDescriptorResponse(raw []byte) ([][]byte, error) { + var files [][]byte + for len(raw) > 0 { + num, typ, n := protowire.ConsumeTag(raw) + if n < 0 { + return nil, protowire.ParseError(n) + } + raw = raw[n:] + switch { + case num == 4 && typ == protowire.BytesType: + fdData, m := protowire.ConsumeBytes(raw) + if m < 0 { + return nil, protowire.ParseError(m) + } + raw = raw[m:] + var err error + files, err = parseReflectionDescriptorList(fdData) + if err != nil { + return nil, err + } + case num == 7 && typ == protowire.BytesType: + errData, m := protowire.ConsumeBytes(raw) + if m < 0 { + return nil, protowire.ParseError(m) + } + return nil, parseReflectionError(errData) + default: + m := protowire.ConsumeFieldValue(num, typ, raw) + if m < 0 { + return nil, protowire.ParseError(m) + } + raw = raw[m:] + } + } + if files == nil { + return nil, errors.New("missing file descriptor response") + } + return files, nil +} + +func parseReflectionDescriptorList(raw []byte) ([][]byte, error) { + var files [][]byte + for len(raw) > 0 { + num, typ, n := protowire.ConsumeTag(raw) + if n < 0 { + return nil, protowire.ParseError(n) + } + raw = raw[n:] + if num != 1 || typ != protowire.BytesType { + m := protowire.ConsumeFieldValue(num, typ, raw) + if m < 0 { + return nil, protowire.ParseError(m) + } + raw = raw[m:] + continue + } + fd, m := protowire.ConsumeBytes(raw) + if m < 0 { + return nil, protowire.ParseError(m) + } + files = append(files, fd) + raw = raw[m:] + } + return files, nil +} + +func parseReflectionError(raw []byte) error { + var msg string + for len(raw) > 0 { + num, typ, n := protowire.ConsumeTag(raw) + if n < 0 { + return protowire.ParseError(n) + } + raw = raw[n:] + if num == 2 && typ == protowire.BytesType { + val, m := protowire.ConsumeString(raw) + if m < 0 { + return protowire.ParseError(m) + } + msg = val + raw = raw[m:] + continue + } + m := protowire.ConsumeFieldValue(num, typ, raw) + if m < 0 { + return protowire.ParseError(m) + } + raw = raw[m:] + } + if msg == "" { + msg = "reflection request failed" + } + return errors.New(msg) +} + +func (rc *reflectionClient) invokeHTTP(ctx context.Context, path string, payload []byte) ([][]byte, error) { + if rc.client == nil { + return nil, errors.New("reflection client is not initialized") + } + u, err := reflectionURL(rc.request.URL, path) + if err != nil { + return nil, err + } + headers := grpcHeaders(rc.request.Headers) + req, err := rc.client.NewRequest(ctx, client.RequestConfig{ + AWSSigV4: rc.request.AWSSigv4, + Basic: rc.request.Basic, + Bearer: rc.request.Bearer, + ContentType: fetchgrpc.ContentType, + Data: bytes.NewReader(fetchgrpc.Frame(payload, false)), + Headers: headers, + HTTP: rc.request.HTTP, + Method: "POST", + NoEncode: true, + URL: u, + }) + if err != nil { + return nil, err + } + defer func() { + if req.Body != nil { + req.Body.Close() + } + }() + + resp, err := rc.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + frames, err := readGRPCFrames(resp.Body) + if err != nil { + return nil, err + } + if status := grpcStatusFromResponse(resp); status != nil { + return nil, status + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected HTTP status: %s", resp.Status) + } + return frames, nil +} + +func DiscoverGRPC(ctx context.Context, r *Request) int { + code, err := discoverGRPC(ctx, r) + if err == nil { + return code + } + + p := r.PrinterHandle.Stderr() + core.WriteErrorMsgNoFlush(p, err) + p.Flush() + return 1 +} + +func discoverGRPC(ctx context.Context, r *Request) (int, error) { + schema, offline, c, err := loadDiscoverySchema(ctx, r) + if err != nil { + return 0, err + } + if c != nil { + defer c.Close() + } + + p := r.PrinterHandle.Stdout() + if r.GRPCList { + var names []string + if offline { + names = schema.ListServices() + sort.Strings(names) + } else { + names, err = newReflectionClient(r, c).ListServices(ctx) + if err != nil { + return 0, err + } + } + for _, name := range names { + p.WriteString(name) + p.WriteString("\n") + } + return 0, p.Flush() + } + + target := normalizeReflectionSymbol(r.GRPCDescribe) + desc, err := lookupDescribeSymbol(schema, r.GRPCDescribe) + if err != nil && !offline { + schema, err = newReflectionClient(r, c).SchemaForSymbol(ctx, target) + if err != nil { + return 0, err + } + desc, err = lookupDescribeSymbol(schema, r.GRPCDescribe) + } + if err != nil { + return 0, err + } + renderDescribe(p, desc) + return 0, p.Flush() +} + +func loadDiscoverySchema(ctx context.Context, r *Request) (*iproto.Schema, bool, *client.Client, error) { + schema, err := loadProtoSchema(r) + if err != nil { + return nil, false, nil, err + } + if schema != nil { + return schema, true, nil, nil + } + if r.URL == nil { + return nil, false, nil, &reflectionUnavailableError{} + } + + applyGRPCDefaults(r) + c := newGRPCClient(r) + if r.GRPCDescribe == "" { + return nil, false, c, nil + } + schema, err = newReflectionClient(r, c).SchemaForSymbol(ctx, normalizeReflectionSymbol(r.GRPCDescribe)) + if err != nil { + c.Close() + return nil, false, nil, err + } + return schema, false, c, nil +} + +func resolveCallSchema(ctx context.Context, r *Request, c *client.Client) (*iproto.Schema, error) { + schema, err := loadProtoSchema(r) + if err != nil || schema != nil { + return schema, err + } + if r.URL == nil { + return nil, nil + } + serviceName, _, err := parseGRPCPath(r.URL.Path) + if err != nil { + return nil, err + } + schema, err = newReflectionClient(r, c).SchemaForSymbol(ctx, serviceName) + if err != nil { + if requiresGRPCSchema(r) { + return nil, err + } + return nil, nil + } + return schema, nil +} + +func requiresGRPCSchema(r *Request) bool { + if r.ContentType == "" { + return false + } + mediaType, _, err := mime.ParseMediaType(r.ContentType) + if err != nil { + mediaType = strings.TrimSpace(strings.ToLower(r.ContentType)) + } + return mediaType == "application/json" || strings.HasSuffix(mediaType, "+json") +} + +func applyGRPCDefaults(r *Request) { + if r.HTTP == core.HTTPDefault { + r.HTTP = core.HTTP2 + } + if r.Method == "" { + r.Method = "POST" + } +} + +func grpcHeaders(headers []core.KeyVal[string]) []core.KeyVal[string] { + out := slices.Clone(headers) + out = append(out, fetchgrpc.Headers()...) + out = append(out, fetchgrpc.AcceptHeader()) + return out +} + +func newGRPCClient(r *Request) *client.Client { + return client.NewClient(client.ClientConfig{ + CACerts: r.CACerts, + ClientCert: r.ClientCert, + ConnectTimeout: r.ConnectTimeout, + DNSServer: r.DNSServer, + H2C: shouldUseH2C(r), + HTTP: r.HTTP, + Insecure: r.Insecure, + Proxy: r.Proxy, + Redirects: r.Redirects, + TLS: r.TLS, + UnixSocket: r.UnixSocket, + }) +} + +func shouldUseH2C(r *Request) bool { + if r.URL == nil { + return false + } + httpVersion := r.HTTP + if httpVersion == core.HTTPDefault { + httpVersion = core.HTTP2 + } + if httpVersion != core.HTTP2 { + return false + } + return effectiveScheme(r.URL) == "http" +} + +func effectiveScheme(u *url.URL) string { + if u == nil { + return "" + } + if u.Scheme != "" { + return strings.ToLower(u.Scheme) + } + if client.IsLoopback(u.Hostname()) { + return "http" + } + return "https" +} + +func reflectionURL(base *url.URL, path string) (*url.URL, error) { + if base == nil { + return nil, errors.New("gRPC reflection requires a target URL") + } + u := *base + u.Path = path + u.RawPath = "" + u.RawQuery = "" + u.Fragment = "" + return &u, nil +} + +func readGRPCFrames(r io.Reader) ([][]byte, error) { + var frames [][]byte + for { + frame, compressed, err := fetchgrpc.ReadFrame(r) + if err == io.EOF { + return frames, nil + } + if err != nil { + return nil, err + } + if compressed { + return nil, errors.New("compressed gRPC messages are not supported") + } + frames = append(frames, frame) + } +} + +func grpcStatusFromResponse(resp *http.Response) *fetchgrpc.Status { + grpcStatus := resp.Trailer.Get("Grpc-Status") + grpcMessage := resp.Trailer.Get("Grpc-Message") + if grpcStatus == "" { + grpcStatus = resp.Header.Get("Grpc-Status") + grpcMessage = resp.Header.Get("Grpc-Message") + } + if grpcStatus == "" || grpcStatus == "0" { + return nil + } + return fetchgrpc.ParseStatus(grpcStatus, grpcMessage) +} + +func isReflectionUnimplemented(err error) bool { + var status *fetchgrpc.Status + if errors.As(err, &status) { + return status.Code == fetchgrpc.Unimplemented + } + return false +} + +type describeKind int + +const ( + describeService describeKind = iota + describeMethod + describeMessage +) + +type describeTarget struct { + kind describeKind + service protoreflect.ServiceDescriptor + method protoreflect.MethodDescriptor + message protoreflect.MessageDescriptor +} + +func lookupDescribeSymbol(schema *iproto.Schema, symbol string) (*describeTarget, error) { + if strings.Contains(symbol, "/") { + method, err := schema.FindMethod(symbol) + if err != nil { + return nil, fmt.Errorf("symbol not found: %s", symbol) + } + return &describeTarget{kind: describeMethod, method: method}, nil + } + + if svc, err := schema.FindService(symbol); err == nil { + return &describeTarget{kind: describeService, service: svc}, nil + } + if method, err := schema.FindMethod(symbol); err == nil { + return &describeTarget{kind: describeMethod, method: method}, nil + } + if msg, err := schema.FindMessage(symbol); err == nil { + return &describeTarget{kind: describeMessage, message: msg}, nil + } + return nil, fmt.Errorf("symbol not found: %s", symbol) +} + +func renderDescribe(p *core.Printer, target *describeTarget) { + switch target.kind { + case describeService: + renderServiceDescription(p, target.service) + case describeMethod: + renderMethodDescription(p, target.method) + case describeMessage: + renderMessageDescription(p, target.message) + } +} + +func renderServiceDescription(p *core.Printer, svc protoreflect.ServiceDescriptor) { + p.WriteString("service ") + p.WriteString(string(svc.FullName())) + p.WriteString("\n") + methods := svc.Methods() + for i := 0; i < methods.Len(); i++ { + method := methods.Get(i) + p.WriteString("\n") + p.WriteString(string(method.Name())) + p.WriteString("\n") + p.WriteString(" rpc: ") + p.WriteString(rpcType(method)) + p.WriteString("\n") + p.WriteString(" request: ") + p.WriteString(string(method.Input().FullName())) + p.WriteString("\n") + p.WriteString(" response: ") + p.WriteString(string(method.Output().FullName())) + p.WriteString("\n") + } +} + +func renderMethodDescription(p *core.Printer, method protoreflect.MethodDescriptor) { + p.WriteString("method ") + p.WriteString(string(method.Parent().FullName())) + p.WriteString("/") + p.WriteString(string(method.Name())) + p.WriteString("\n") + p.WriteString("rpc: ") + p.WriteString(rpcType(method)) + p.WriteString("\n") + p.WriteString("request: ") + p.WriteString(string(method.Input().FullName())) + p.WriteString("\n") + p.WriteString("response: ") + p.WriteString(string(method.Output().FullName())) + p.WriteString("\n") +} + +func renderMessageDescription(p *core.Printer, msg protoreflect.MessageDescriptor) { + p.WriteString("message ") + p.WriteString(string(msg.FullName())) + p.WriteString("\n") + fields := msg.Fields() + for i := 0; i < fields.Len(); i++ { + field := fields.Get(i) + p.WriteString("\n") + p.WriteString(fmt.Sprintf("%d %s %s %s", field.Number(), field.Name(), fieldLabel(field), fieldType(field))) + p.WriteString("\n") + } +} + +func rpcType(method protoreflect.MethodDescriptor) string { + switch { + case method.IsStreamingClient() && method.IsStreamingServer(): + return "bidi-stream" + case method.IsStreamingClient(): + return "client-stream" + case method.IsStreamingServer(): + return "server-stream" + default: + return "unary" + } +} + +func fieldLabel(field protoreflect.FieldDescriptor) string { + if field.IsList() { + return "repeated" + } + switch field.Cardinality() { + case protoreflect.Required: + return "required" + case protoreflect.Optional: + return "optional" + default: + return "singular" + } +} + +func fieldType(field protoreflect.FieldDescriptor) string { + if field.IsMap() { + key := field.MapKey() + value := field.MapValue() + return fmt.Sprintf("map<%s, %s>", scalarFieldType(key), scalarFieldType(value)) + } + return scalarFieldType(field) +} + +func scalarFieldType(field protoreflect.FieldDescriptor) string { + switch field.Kind() { + case protoreflect.MessageKind, protoreflect.GroupKind: + return string(field.Message().FullName()) + case protoreflect.EnumKind: + return string(field.Enum().FullName()) + default: + return strings.TrimSuffix(strings.ToLower(field.Kind().String()), "kind") + } +} + +func normalizeReflectionSymbol(symbol string) string { + symbol = strings.TrimPrefix(symbol, "/") + if idx := strings.LastIndex(symbol, "/"); idx >= 0 { + return symbol[:idx] + "." + symbol[idx+1:] + } + return symbol +} diff --git a/internal/fetch/grpc_reflection_test.go b/internal/fetch/grpc_reflection_test.go new file mode 100644 index 0000000..2b2faf4 --- /dev/null +++ b/internal/fetch/grpc_reflection_test.go @@ -0,0 +1,141 @@ +package fetch + +import ( + "context" + "strings" + "testing" + + "github.com/ryanfowler/fetch/internal/core" + fetchgrpc "github.com/ryanfowler/fetch/internal/grpc" + iproto "github.com/ryanfowler/fetch/internal/proto" + + "google.golang.org/protobuf/encoding/protowire" + gproto "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/descriptorpb" +) + +func TestReflectionClientFallsBackToV1Alpha(t *testing.T) { + payload := buildListResponse("zeta.Service", "alpha.Service") + + rc := &reflectionClient{ + invoke: func(_ context.Context, path string, _ []byte) ([][]byte, error) { + if path == reflectionV1Path { + return nil, &fetchgrpc.Status{Code: fetchgrpc.Unimplemented} + } + if path == reflectionV1AlphaPath { + return [][]byte{payload}, nil + } + t.Fatalf("unexpected reflection path: %s", path) + return nil, nil + }, + } + + names, err := rc.ListServices(context.Background()) + if err != nil { + t.Fatalf("ListServices() error = %v", err) + } + if got, want := strings.Join(names, ","), "alpha.Service,zeta.Service"; got != want { + t.Fatalf("ListServices() = %q, want %q", got, want) + } +} + +func buildListResponse(names ...string) []byte { + var list []byte + for _, name := range names { + var service []byte + service = protowire.AppendTag(service, 1, protowire.BytesType) + service = protowire.AppendString(service, name) + list = protowire.AppendTag(list, 1, protowire.BytesType) + list = protowire.AppendBytes(list, service) + } + + var resp []byte + resp = protowire.AppendTag(resp, 6, protowire.BytesType) + resp = protowire.AppendBytes(resp, list) + return resp +} + +func TestDescriptorSetBuilderDedupesFiles(t *testing.T) { + fd := createDescribeTestDescriptorSet().File[0] + raw, err := gproto.Marshal(fd) + if err != nil { + t.Fatalf("marshal descriptor: %v", err) + } + + builder := newDescriptorSetBuilder() + if err := builder.Add([][]byte{raw, raw}); err != nil { + t.Fatalf("Add() error = %v", err) + } + if len(builder.files) != 1 { + t.Fatalf("expected 1 file after dedupe, got %d", len(builder.files)) + } + if _, err := builder.Build(); err != nil { + t.Fatalf("Build() error = %v", err) + } +} + +func TestRenderDescribeMessage(t *testing.T) { + schema := createDescribeTestSchema(t) + target, err := lookupDescribeSymbol(schema, "testpkg.TestMessage") + if err != nil { + t.Fatalf("lookupDescribeSymbol() error = %v", err) + } + + p := core.TestPrinter(false) + renderDescribe(p, target) + got := string(p.Bytes()) + for _, want := range []string{ + "message testpkg.TestMessage", + "1 id optional int64", + "2 name optional string", + } { + if !strings.Contains(got, want) { + t.Fatalf("output missing %q:\n%s", want, got) + } + } +} + +func createDescribeTestSchema(t *testing.T) *iproto.Schema { + t.Helper() + + schema, err := iproto.LoadFromDescriptorSet(createDescribeTestDescriptorSet()) + if err != nil { + t.Fatalf("LoadFromDescriptorSet() error = %v", err) + } + return schema +} + +func createDescribeTestDescriptorSet() *descriptorpb.FileDescriptorSet { + strType := descriptorpb.FieldDescriptorProto_TYPE_STRING + int64Type := descriptorpb.FieldDescriptorProto_TYPE_INT64 + return &descriptorpb.FileDescriptorSet{ + File: []*descriptorpb.FileDescriptorProto{ + { + Name: ptr("describe.proto"), + Package: ptr("testpkg"), + Syntax: ptr("proto3"), + MessageType: []*descriptorpb.DescriptorProto{ + { + Name: ptr("TestMessage"), + Field: []*descriptorpb.FieldDescriptorProto{ + { + Name: ptr("id"), + Number: ptr(int32(1)), + Type: &int64Type, + }, + { + Name: ptr("name"), + Number: ptr(int32(2)), + Type: &strType, + }, + }, + }, + }, + }, + }, + } +} + +func ptr[T any](v T) *T { + return &v +} diff --git a/internal/fetch/proto.go b/internal/fetch/proto.go index 5db3d25..30d898c 100644 --- a/internal/fetch/proto.go +++ b/internal/fetch/proto.go @@ -18,20 +18,11 @@ import ( // trailers-only responses) and prints an error to stderr if the status // is non-OK. Returns the updated exit code. func checkGRPCStatus(r *Request, resp *http.Response, exitCode int) int { - grpcStatus := resp.Trailer.Get("Grpc-Status") - grpcMessage := resp.Trailer.Get("Grpc-Message") - - // Fall back to headers for trailers-only error responses. - if grpcStatus == "" { - grpcStatus = resp.Header.Get("Grpc-Status") - grpcMessage = resp.Header.Get("Grpc-Message") - } - - if grpcStatus == "" || grpcStatus == "0" { + status := grpcStatusFromResponse(resp) + if status == nil { return exitCode } - status := fetchgrpc.ParseStatus(grpcStatus, grpcMessage) p := r.PrinterHandle.Stderr() core.WriteErrorMsg(p, status) @@ -77,6 +68,7 @@ func parseGRPCPath(urlPath string) (serviceName, methodName string, err error) { func setupGRPC(r *Request, schema *proto.Schema) (protoreflect.MessageDescriptor, protoreflect.MessageDescriptor, bool, error) { var requestDesc, responseDesc protoreflect.MessageDescriptor var isClientStreaming bool + applyGRPCDefaults(r) if schema != nil && r.URL != nil { serviceName, methodName, err := parseGRPCPath(r.URL.Path) if err != nil { @@ -93,15 +85,6 @@ func setupGRPC(r *Request, schema *proto.Schema) (protoreflect.MessageDescriptor isClientStreaming = method.IsStreamingClient() } - if r.HTTP == core.HTTPDefault { - r.HTTP = core.HTTP2 - } - if r.Method == "" { - r.Method = "POST" - } - r.Headers = append(r.Headers, fetchgrpc.Headers()...) - r.Headers = append(r.Headers, fetchgrpc.AcceptHeader()) - return requestDesc, responseDesc, isClientStreaming, nil } diff --git a/main.go b/main.go index a848103..de7e423 100644 --- a/main.go +++ b/main.go @@ -108,8 +108,15 @@ func main() { os.Exit(status) } + // gRPC discovery can run offline when a local schema is provided. + if app.URL == nil && app.HasGRPCDiscovery() && !app.HasProtoSchema() { + p := handle.Stderr() + writeCLIErr(p, errors.New(" must be provided unless --proto-file or --proto-desc is set")) + os.Exit(1) + } + // Otherwise, a URL must be provided. - if app.URL == nil { + if app.URL == nil && !app.HasGRPCDiscovery() { p := handle.Stderr() writeCLIErr(p, errors.New(" must be provided")) os.Exit(1) @@ -169,6 +176,8 @@ func main() { Form: app.Form, Format: app.Cfg.Format, GRPC: app.GRPC, + GRPCDescribe: app.GRPCDescribe, + GRPCList: app.GRPCList, Headers: app.Cfg.Headers, HTTP: app.Cfg.HTTP, IgnoreStatus: getValue(app.Cfg.IgnoreStatus), @@ -200,6 +209,10 @@ func main() { Verbosity: verbosity, WS: app.WS, } + if app.HasGRPCDiscovery() { + status := fetch.DiscoverGRPC(ctx, &req) + os.Exit(status) + } status := fetch.Fetch(ctx, &req) os.Exit(status) } @@ -373,6 +386,12 @@ func inspectTLS(ctx context.Context, app *cli.App, handle *core.Handle) int { if app.GRPC { ignored = append(ignored, "--grpc") } + if app.GRPCDescribe != "" { + ignored = append(ignored, "--grpc-describe") + } + if app.GRPCList { + ignored = append(ignored, "--grpc-list") + } if app.Output != "" { ignored = append(ignored, "--output") } From cb304c3efa56f8917304486b78cc0bbb1091b215 Mon Sep 17 00:00:00 2001 From: Ryan Fowler Date: Fri, 20 Mar 2026 11:40:58 -0700 Subject: [PATCH 2/6] Remove redundant applyGRPCDefaults call in setupGRPC fetch() already calls applyGRPCDefaults before setupGRPC, making the call inside setupGRPC dead work. --- internal/fetch/proto.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/fetch/proto.go b/internal/fetch/proto.go index 30d898c..ed58fa1 100644 --- a/internal/fetch/proto.go +++ b/internal/fetch/proto.go @@ -68,7 +68,6 @@ func parseGRPCPath(urlPath string) (serviceName, methodName string, err error) { func setupGRPC(r *Request, schema *proto.Schema) (protoreflect.MessageDescriptor, protoreflect.MessageDescriptor, bool, error) { var requestDesc, responseDesc protoreflect.MessageDescriptor var isClientStreaming bool - applyGRPCDefaults(r) if schema != nil && r.URL != nil { serviceName, methodName, err := parseGRPCPath(r.URL.Path) if err != nil { From 3f58732607fdf8ca81e74a113af5c1e589f58a6d Mon Sep 17 00:00:00 2001 From: Ryan Fowler Date: Fri, 20 Mar 2026 11:43:36 -0700 Subject: [PATCH 3/6] Document h2c support --- docs/advanced-features.md | 1 + docs/cli-reference.md | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/advanced-features.md b/docs/advanced-features.md index 2d079a6..5533ffa 100644 --- a/docs/advanced-features.md +++ b/docs/advanced-features.md @@ -151,6 +151,7 @@ fetch --http 2 example.com - Multiplexed streams - Header compression (HPACK) - Required for gRPC +- Automatically uses h2c (HTTP/2 over cleartext) for `http://` URLs, enabling plaintext HTTP/2 connections to local development servers without TLS ### HTTP/3 (QUIC) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index a39f3dc..5f2e2a7 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -407,9 +407,12 @@ Force specific HTTP version. Values: `1`, `2`, `3`. - `2` - HTTP/2 (default preference) - `3` - HTTP/3 (QUIC) +When `--http 2` is used with an `http://` URL, `fetch` automatically uses h2c (HTTP/2 over cleartext) to connect without TLS. This applies to both gRPC and regular HTTP requests. + ```sh fetch --http 1 example.com fetch --http 3 example.com +fetch --http 2 http://localhost:8080 # uses h2c ``` ## Compression @@ -541,7 +544,7 @@ Add import paths for proto compilation. Use with `--proto-file`. fetch --grpc --proto-file service.proto --proto-import ./proto localhost:50051/pkg.Svc/Method ``` -Plaintext local gRPC servers are supported via `h2c`, so loopback URLs like `http://127.0.0.1:50051` work for both `--grpc` and reflection-based discovery. +Plaintext servers are supported via `h2c` (HTTP/2 over cleartext) when using an `http://` URL with HTTP/2. This works for `--grpc`, reflection-based discovery, and regular HTTP/2 requests alike. ## Configuration From 99a3df3235836afb3ce5abb89b4f001848a69756 Mon Sep 17 00:00:00 2001 From: Ryan Fowler Date: Fri, 20 Mar 2026 11:44:28 -0700 Subject: [PATCH 4/6] Check HTTP status before parsing gRPC frames in invokeHTTP --- internal/fetch/grpc_reflection.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/fetch/grpc_reflection.go b/internal/fetch/grpc_reflection.go index 0572d37..a0d0709 100644 --- a/internal/fetch/grpc_reflection.go +++ b/internal/fetch/grpc_reflection.go @@ -404,6 +404,9 @@ func (rc *reflectionClient) invokeHTTP(ctx context.Context, path string, payload } defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected HTTP status: %s", resp.Status) + } frames, err := readGRPCFrames(resp.Body) if err != nil { return nil, err @@ -411,9 +414,6 @@ func (rc *reflectionClient) invokeHTTP(ctx context.Context, path string, payload if status := grpcStatusFromResponse(resp); status != nil { return nil, status } - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("unexpected HTTP status: %s", resp.Status) - } return frames, nil } From e991ba958f789e034397afceb77d02cd7dce07bc Mon Sep 17 00:00:00 2001 From: Ryan Fowler Date: Fri, 20 Mar 2026 11:46:31 -0700 Subject: [PATCH 5/6] Remove redundant reflection fallback in discoverGRPC describe path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit loadDiscoverySchema already fetches the schema via SchemaForSymbol for the describe symbol. The fallback that retried the same call when lookupDescribeSymbol failed was dead code — it would return the same schema and fail identically. --- internal/fetch/grpc_reflection.go | 8 -------- 1 file changed, 8 deletions(-) diff --git a/internal/fetch/grpc_reflection.go b/internal/fetch/grpc_reflection.go index a0d0709..c03f22b 100644 --- a/internal/fetch/grpc_reflection.go +++ b/internal/fetch/grpc_reflection.go @@ -457,15 +457,7 @@ func discoverGRPC(ctx context.Context, r *Request) (int, error) { return 0, p.Flush() } - target := normalizeReflectionSymbol(r.GRPCDescribe) desc, err := lookupDescribeSymbol(schema, r.GRPCDescribe) - if err != nil && !offline { - schema, err = newReflectionClient(r, c).SchemaForSymbol(ctx, target) - if err != nil { - return 0, err - } - desc, err = lookupDescribeSymbol(schema, r.GRPCDescribe) - } if err != nil { return 0, err } From 323ef996f909e4e1f5c2977d44a3dc33dec0caa2 Mon Sep 17 00:00:00 2001 From: Ryan Fowler Date: Fri, 20 Mar 2026 11:57:11 -0700 Subject: [PATCH 6/6] Scope h2c transport to gRPC requests and rename newGRPCClient h2c was unconditionally evaluated for all requests, meaning non-gRPC `--http 2 http://` requests would silently use cleartext HTTP/2. Gate shouldUseH2C on HasGRPCMode() and remove the misleading HTTPDefault to HTTP2 mapping that didn't match the client transport switch. Rename newGRPCClient to newClient since it serves all request types. --- docs/advanced-features.md | 2 +- docs/cli-reference.md | 6 +++--- internal/fetch/fetch.go | 2 +- internal/fetch/grpc_reflection.go | 12 ++++-------- 4 files changed, 9 insertions(+), 13 deletions(-) diff --git a/docs/advanced-features.md b/docs/advanced-features.md index 5533ffa..d520e8d 100644 --- a/docs/advanced-features.md +++ b/docs/advanced-features.md @@ -151,7 +151,7 @@ fetch --http 2 example.com - Multiplexed streams - Header compression (HPACK) - Required for gRPC -- Automatically uses h2c (HTTP/2 over cleartext) for `http://` URLs, enabling plaintext HTTP/2 connections to local development servers without TLS +- Automatically uses h2c (HTTP/2 over cleartext) for gRPC requests with `http://` URLs, enabling plaintext HTTP/2 connections to local development servers without TLS ### HTTP/3 (QUIC) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 5f2e2a7..eecd30c 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -407,12 +407,12 @@ Force specific HTTP version. Values: `1`, `2`, `3`. - `2` - HTTP/2 (default preference) - `3` - HTTP/3 (QUIC) -When `--http 2` is used with an `http://` URL, `fetch` automatically uses h2c (HTTP/2 over cleartext) to connect without TLS. This applies to both gRPC and regular HTTP requests. +When `--http 2` is used with an `http://` URL for gRPC requests, `fetch` automatically uses h2c (HTTP/2 over cleartext) to connect without TLS. ```sh fetch --http 1 example.com fetch --http 3 example.com -fetch --http 2 http://localhost:8080 # uses h2c +fetch --grpc --http 2 http://localhost:50051/pkg.Svc/Method # uses h2c ``` ## Compression @@ -544,7 +544,7 @@ Add import paths for proto compilation. Use with `--proto-file`. fetch --grpc --proto-file service.proto --proto-import ./proto localhost:50051/pkg.Svc/Method ``` -Plaintext servers are supported via `h2c` (HTTP/2 over cleartext) when using an `http://` URL with HTTP/2. This works for `--grpc`, reflection-based discovery, and regular HTTP/2 requests alike. +Plaintext servers are supported via `h2c` (HTTP/2 over cleartext) when using an `http://` URL with HTTP/2. This works for `--grpc` and reflection-based discovery (`--grpc-list`, `--grpc-describe`). ## Configuration diff --git a/internal/fetch/fetch.go b/internal/fetch/fetch.go index b5d966f..8138ca6 100644 --- a/internal/fetch/fetch.go +++ b/internal/fetch/fetch.go @@ -131,7 +131,7 @@ func fetch(ctx context.Context, r *Request) (int, error) { } // 1. Create the HTTP client. - c := newGRPCClient(r) + c := newClient(r) defer c.Close() // 2. Resolve any proto schema and configure gRPC descriptors. diff --git a/internal/fetch/grpc_reflection.go b/internal/fetch/grpc_reflection.go index c03f22b..f768b3b 100644 --- a/internal/fetch/grpc_reflection.go +++ b/internal/fetch/grpc_reflection.go @@ -478,7 +478,7 @@ func loadDiscoverySchema(ctx context.Context, r *Request) (*iproto.Schema, bool, } applyGRPCDefaults(r) - c := newGRPCClient(r) + c := newClient(r) if r.GRPCDescribe == "" { return nil, false, c, nil } @@ -539,7 +539,7 @@ func grpcHeaders(headers []core.KeyVal[string]) []core.KeyVal[string] { return out } -func newGRPCClient(r *Request) *client.Client { +func newClient(r *Request) *client.Client { return client.NewClient(client.ClientConfig{ CACerts: r.CACerts, ClientCert: r.ClientCert, @@ -556,14 +556,10 @@ func newGRPCClient(r *Request) *client.Client { } func shouldUseH2C(r *Request) bool { - if r.URL == nil { + if r.URL == nil || !r.HasGRPCMode() { return false } - httpVersion := r.HTTP - if httpVersion == core.HTTPDefault { - httpVersion = core.HTTP2 - } - if httpVersion != core.HTTP2 { + if r.HTTP != core.HTTP2 { return false } return effectiveScheme(r.URL) == "http"