-
Notifications
You must be signed in to change notification settings - Fork 347
Add support for URL completions in buf curl
#4402
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 8 commits
ce4c55c
e1274ba
679f2ef
20a16ea
37e2fd2
dd83d77
6a9bcc0
6e02082
c48c0a6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -22,6 +22,7 @@ import ( | |
| "errors" | ||
| "fmt" | ||
| "io" | ||
| "log/slog" | ||
| "net" | ||
| "net/http" | ||
| "net/url" | ||
|
|
@@ -43,6 +44,7 @@ import ( | |
| "github.com/bufbuild/buf/private/pkg/verbose" | ||
| "github.com/quic-go/quic-go" | ||
| "github.com/quic-go/quic-go/http3" | ||
| "github.com/spf13/cobra" | ||
| "github.com/spf13/pflag" | ||
| "google.golang.org/protobuf/reflect/protoreflect" | ||
| ) | ||
|
|
@@ -150,26 +152,26 @@ Examples: | |
| Issue a unary RPC to a plain-text (i.e. "h2c") gRPC server, where the schema for the service is | ||
| in a Buf module in the current directory, using an empty request message: | ||
|
|
||
| $ buf curl --schema . --protocol grpc --http2-prior-knowledge \ | ||
| $ buf curl --schema . --protocol grpc --http2-prior-knowledge \ | ||
| http://localhost:20202/foo.bar.v1.FooService/DoSomething | ||
|
|
||
| Issue an RPC to a Connect server, where the schema comes from the Buf Schema Registry, using | ||
| a request that is defined as a command-line argument: | ||
|
|
||
| $ buf curl --schema buf.build/connectrpc/eliza \ | ||
| --data '{"name": "Bob Loblaw"}' \ | ||
| $ buf curl --schema buf.build/connectrpc/eliza \ | ||
| --data '{"name": "Bob Loblaw"}' \ | ||
| https://demo.connectrpc.com/connectrpc.eliza.v1.ElizaService/Introduce | ||
|
|
||
| Issue a unary RPC to a server that supports reflection, with verbose output: | ||
|
|
||
| $ buf curl --data '{"sentence": "I am not feeling well."}' -v \ | ||
| https://demo.connectrpc.com/connectrpc.eliza.v1.ElizaService/Say | ||
| $ buf curl --data '{"sentence": "I am not feeling well."}' -v \ | ||
| https://demo.connectrpc.com/connectrpc.eliza.v1.ElizaService/Say | ||
|
|
||
| Issue a client-streaming RPC to a gRPC-web server that supports reflection, where custom | ||
| headers and request data are both in a heredoc: | ||
|
|
||
| $ buf curl --data @- --header @- --protocol grpcweb \ | ||
| https://demo.connectrpc.com/connectrpc.eliza.v1.ElizaService/Converse \ | ||
| $ buf curl --data @- --header @- --protocol grpcweb \ | ||
| https://demo.connectrpc.com/connectrpc.eliza.v1.ElizaService/Converse \ | ||
| <<EOM | ||
| Custom-Header-1: foo-bar-baz | ||
| Authorization: token jas8374hgnkvje9wpkerebncjqol4 | ||
|
|
@@ -194,7 +196,8 @@ exit code that is the gRPC code, shifted three bits to the left. | |
| return run(ctx, container, flags) | ||
| }, | ||
| ), | ||
| BindFlags: flags.Bind, | ||
| BindFlags: flags.Bind, | ||
| ModifyCobra: completeCurlCommand, | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -1193,3 +1196,306 @@ func makeHTTP3RoundTripper(f *flags, authority string, printer verbose.Printer) | |
| func secondsToDuration(secs float64) time.Duration { | ||
| return time.Duration(float64(time.Second) * secs) | ||
| } | ||
|
|
||
| // completeCurlCommand wires up shell completions for buf curl. | ||
| func completeCurlCommand(cmd *cobra.Command) error { | ||
| if err := cmd.RegisterFlagCompletionFunc( | ||
| protocolFlagName, | ||
| func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { | ||
| return []string{connect.ProtocolConnect, connect.ProtocolGRPC, connect.ProtocolGRPCWeb}, cobra.ShellCompDirectiveNoFileComp | ||
| }, | ||
| ); err != nil { | ||
| return err | ||
| } | ||
| if err := cmd.RegisterFlagCompletionFunc( | ||
| reflectProtocolFlagName, | ||
| func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { | ||
| return bufcurl.AllKnownReflectProtocolStrings, cobra.ShellCompDirectiveNoFileComp | ||
| }, | ||
| ); err != nil { | ||
| return err | ||
| } | ||
| cmd.ValidArgsFunction = completeURL | ||
| return nil | ||
| } | ||
|
|
||
| // completeURL provides shell completions for the <url> positional argument. | ||
| // It completes service and method name path components using the first source | ||
| // that succeeds, tried in order: | ||
| // | ||
| // 1. --schema flag (explicit schemas provided by the user) | ||
| // 2. Live server reflection against the URL being completed | ||
| // 3. The buf module found by walking up from the current working directory | ||
| // | ||
| // Sources 2 and 3 are tried in order so that a running server takes precedence, | ||
| // but local development against a service without a live server still works. | ||
| func completeURL(cmd *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { | ||
| if toComplete == "" { | ||
| return nil, cobra.ShellCompDirectiveNoFileComp | ||
| } | ||
| parsed, err := url.Parse(toComplete) | ||
| if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") { | ||
| return nil, cobra.ShellCompDirectiveNoFileComp | ||
| } | ||
| baseURL := parsed.Scheme + "://" + parsed.Host | ||
| // rawPath is everything after the leading slash, e.g.: | ||
| // "" → completing package prefix | ||
| // "acme." → completing next package segment | ||
| // "acme.foo.v1.FooService" → completing up to trailing slash | ||
| // "acme.foo.v1.FooService/" → completing method name | ||
| // "acme.foo.v1.FooService/Get" → completing method name (partial) | ||
| rawPath := strings.TrimPrefix(parsed.Path, "/") | ||
|
|
||
| ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | ||
| defer cancel() | ||
|
|
||
| // 1. Explicit --schema flag. | ||
| schemas, _ := cmd.Flags().GetStringSlice(schemaFlagName) | ||
| if len(schemas) > 0 { | ||
| return completeURLFromSchema(ctx, schemas, baseURL, rawPath, "--schema") | ||
| } | ||
|
|
||
| // 2. Live server reflection. | ||
| isSecure := parsed.Scheme == "https" | ||
| if httpClient, ok := makeCompletionHTTPClient(cmd, isSecure); ok { | ||
|
||
| if completions, directive, ok := completeURLFromReflection(ctx, httpClient, baseURL, rawPath); ok { | ||
| return completions, directive | ||
| } | ||
| } | ||
|
|
||
| // 3. Buf module in the current working directory (walks up to find buf.yaml / | ||
| // buf.work.yaml). This covers the common local-dev case: the user is working | ||
| // inside a buf workspace and hasn't started the server yet, or the server | ||
| // doesn't expose reflection. | ||
| return completeURLFromSchema(ctx, []string{"."}, baseURL, rawPath, "local module") | ||
| } | ||
|
|
||
| // completeURLFromReflection attempts server reflection against baseURL and | ||
| // returns completions if the server is reachable and supports reflection. | ||
| // The third return value is false if reflection was unavailable (connection | ||
| // refused, server unreachable, reflection not implemented), in which case the | ||
| // caller should try an alternative source. It is true even if the service list | ||
| // was empty or no completions matched — that is a valid reflection result. | ||
| // | ||
| // Reflection for completion always uses gRPC regardless of --protocol or | ||
| // --reflect-protocol, and does not forward --reflect-header, --cert, --key, | ||
| // --cacert, or --servername. Servers that require auth on the reflection | ||
| // endpoint will silently produce no completions. | ||
| func completeURLFromReflection(ctx context.Context, httpClient connect.HTTPClient, baseURL, rawPath string) ([]string, cobra.ShellCompDirective, bool) { | ||
| reflectionResolver, closeResolver := bufcurl.NewServerReflectionResolver( | ||
| ctx, | ||
| httpClient, | ||
| []connect.ClientOption{connect.WithGRPC()}, | ||
| baseURL, | ||
| bufcurl.ReflectProtocolUnknown, | ||
| http.Header{}, | ||
| verbose.NopPrinter, | ||
| ) | ||
| defer closeResolver() | ||
|
|
||
| serviceNames, err := reflectionResolver.ListServices() | ||
| if err != nil { | ||
| // Reflection unavailable or server unreachable; let the caller try another source. | ||
| return nil, cobra.ShellCompDirectiveNoFileComp, false | ||
| } | ||
| completions, directive := completePathFromServices( | ||
| baseURL, | ||
| serviceNames, | ||
| rawPath, | ||
| func(svcName string) (protoreflect.ServiceDescriptor, error) { | ||
| return bufcurl.ResolveServiceDescriptor(reflectionResolver, svcName) | ||
| }, | ||
| "reflection", | ||
| ) | ||
| return completions, directive, true | ||
| } | ||
|
|
||
| // completeURLFromSchema builds a resolver from the given schemas using the full | ||
| // schema-loading stack (BSR auth, caching, etc.) and uses it to complete | ||
| // service and method name path components. | ||
| func completeURLFromSchema(ctx context.Context, schemas []string, baseURL, rawPath, source string) ([]string, cobra.ShellCompDirective) { | ||
| // Only surface errors when the user explicitly provided a source (e.g. | ||
| // --schema). The CWD fallback ("local module") is silent because there may | ||
| // simply be no buf.yaml in the current directory. | ||
| reportError := func(format string, args ...any) { | ||
| if source != "local module" { | ||
| cobra.CompErrorln(fmt.Sprintf("buf curl completion: "+format, args...)) | ||
| } | ||
| } | ||
|
|
||
| baseContainer, err := app.NewContainerForOS() | ||
| if err != nil { | ||
| reportError("%v", err) | ||
| return nil, cobra.ShellCompDirectiveNoFileComp | ||
| } | ||
| nameContainer, err := appext.NewNameContainer(baseContainer, "buf") | ||
| if err != nil { | ||
| reportError("%v", err) | ||
| return nil, cobra.ShellCompDirectiveNoFileComp | ||
| } | ||
| // Discard log output during shell completion. | ||
| container := appext.NewContainer(nameContainer, slog.New(slog.NewTextHandler(io.Discard, nil))) | ||
| controller, err := bufcli.NewController(container) | ||
| if err != nil { | ||
| reportError("%v", err) | ||
| return nil, cobra.ShellCompDirectiveNoFileComp | ||
| } | ||
|
|
||
| resolvers := make([]bufcurl.Resolver, 0, len(schemas)) | ||
| for _, schema := range schemas { | ||
| image, err := controller.GetImage(ctx, schema) | ||
| if err != nil { | ||
| reportError("failed to load schema %q: %v", schema, err) | ||
| return nil, cobra.ShellCompDirectiveNoFileComp | ||
| } | ||
| resolvers = append(resolvers, bufcurl.ResolverForImage(image)) | ||
| } | ||
| resolver := bufcurl.CombineResolvers(resolvers...) | ||
|
|
||
| serviceNames, err := resolver.ListServices() | ||
| if err != nil { | ||
| reportError("%v", err) | ||
| return nil, cobra.ShellCompDirectiveNoFileComp | ||
| } | ||
| return completePathFromServices( | ||
| baseURL, | ||
| serviceNames, | ||
| rawPath, | ||
| func(svcName string) (protoreflect.ServiceDescriptor, error) { | ||
| return bufcurl.ResolveServiceDescriptor(resolver, svcName) | ||
| }, | ||
| source, | ||
| ) | ||
| } | ||
|
|
||
| // completePathFromServices computes URL completions given a list of known | ||
| // service names and the raw path typed so far (everything after the host). | ||
| // | ||
| // Service-level completions advance through unambiguous dot-segments automatically | ||
| // so a single tab press reaches the first real fork in the name hierarchy. For | ||
| // example, given services | ||
| // | ||
| // acme.foo.v1.FooService | ||
| // acme.bar.v1.BarService | ||
| // | ||
| // the progression is: | ||
| // | ||
| // "" → "https://host/acme.foo." and "https://host/acme.bar." (skips unambiguous "acme.") | ||
| // "acme.foo." → "https://host/acme.foo.v1.FooService/" (skips unambiguous "v1.") | ||
| // "acme.foo.v1.FooService/" → method names | ||
| func completePathFromServices( | ||
| baseURL string, | ||
| serviceNames []protoreflect.FullName, | ||
| rawPath string, | ||
| getServiceDescriptor func(serviceName string) (protoreflect.ServiceDescriptor, error), | ||
| source string, | ||
| ) ([]string, cobra.ShellCompDirective) { | ||
| serviceName, methodPrefix, hasSlash := strings.Cut(rawPath, "/") | ||
| if hasSlash { | ||
| // Method completion. | ||
| desc, err := getServiceDescriptor(serviceName) | ||
| if err != nil { | ||
| // The service name was already listed, so failing to fetch its | ||
| // descriptor is unexpected — surface it regardless of source. | ||
| if source != "" { | ||
| cobra.CompErrorln(fmt.Sprintf("buf curl completion: failed to resolve service %q: %v", serviceName, err)) | ||
| } | ||
| return nil, cobra.ShellCompDirectiveNoFileComp | ||
| } | ||
| methods := desc.Methods() | ||
| completions := make([]string, 0, methods.Len()) | ||
| for i := range methods.Len() { | ||
| name := string(methods.Get(i).Name()) | ||
| if strings.HasPrefix(name, methodPrefix) { | ||
| item := baseURL + "/" + serviceName + "/" + name | ||
| if source != "" { | ||
| item += "\t" + source | ||
| } | ||
| completions = append(completions, item) | ||
| } | ||
| } | ||
| slices.Sort(completions) | ||
| return completions, cobra.ShellCompDirectiveNoFileComp | ||
| } | ||
|
|
||
| // Service/package name completion. We loop, advancing the prefix through | ||
| // dot-segments until we reach a fork (multiple candidates) or a terminal | ||
| // service name (ends with "/"). This means a single tab press skips over | ||
| // any unambiguous prefix segments. | ||
| prefix := rawPath | ||
| for { | ||
| seen := make(map[string]struct{}) | ||
| for _, svc := range serviceNames { | ||
| svcStr := string(svc) | ||
| if !strings.HasPrefix(svcStr, prefix) { | ||
| continue | ||
| } | ||
| remainder := svcStr[len(prefix):] | ||
| if idx := strings.Index(remainder, "."); idx >= 0 { | ||
| // More package components remain: offer only the next segment. | ||
| seen[prefix+remainder[:idx+1]] = struct{}{} | ||
| } else { | ||
| // No more dots: full service name; add trailing slash. | ||
| seen[svcStr+"/"] = struct{}{} | ||
| } | ||
| } | ||
| // If there is exactly one candidate and it is not yet a terminal service | ||
| // name (i.e. it ends with "." not "/"), advance and loop so the next | ||
| // segment is also consumed without requiring another tab press. | ||
| if len(seen) == 1 { | ||
| var only string | ||
| for k := range seen { | ||
| only = k | ||
| } | ||
| if !strings.HasSuffix(only, "/") { | ||
| prefix = only | ||
| continue | ||
| } | ||
| } | ||
| completions := make([]string, 0, len(seen)) | ||
| for p := range seen { | ||
| item := baseURL + "/" + p | ||
| // Add source description only to terminal service names (ending with "/"), | ||
| // not to intermediate package segments (ending with "."). | ||
| if source != "" && strings.HasSuffix(p, "/") { | ||
| item += "\t" + source | ||
| } | ||
| completions = append(completions, item) | ||
| } | ||
| slices.Sort(completions) | ||
| // NoSpace so the shell does not insert a space after a trailing dot or | ||
| // slash, letting the user continue typing the next segment immediately. | ||
| return completions, cobra.ShellCompDirectiveNoSpace | cobra.ShellCompDirectiveNoFileComp | ||
| } | ||
| } | ||
|
|
||
| // makeCompletionHTTPClient builds a minimal HTTP client for use during shell | ||
| // completion. Returns (client, true) on success, or (nil, false) when | ||
| // reflection is not possible (e.g. plain HTTP without HTTP/2 prior knowledge). | ||
| func makeCompletionHTTPClient(cmd *cobra.Command, isSecure bool) (connect.HTTPClient, bool) { | ||
| protocols := new(http.Protocols) | ||
| if isSecure { | ||
| insecure, _ := cmd.Flags().GetBool(insecureFlagName) | ||
| protocols.SetHTTP1(true) | ||
| protocols.SetHTTP2(true) | ||
| return &http.Client{ | ||
| Transport: &http.Transport{ | ||
| TLSClientConfig: &tls.Config{InsecureSkipVerify: insecure}, | ||
| ForceAttemptHTTP2: true, | ||
| Protocols: protocols, | ||
| }, | ||
| }, true | ||
| } | ||
| // Plain HTTP: server reflection requires HTTP/2, which needs prior knowledge | ||
| // over a cleartext connection. Skip completion if the flag is not set. | ||
| http2PriorKnowledge, _ := cmd.Flags().GetBool(http2PriorKnowledgeFlagName) | ||
| if !http2PriorKnowledge { | ||
| return nil, false | ||
| } | ||
| protocols.SetUnencryptedHTTP2(true) | ||
| return &http.Client{ | ||
| Transport: &http.Transport{ | ||
| Protocols: protocols, | ||
| }, | ||
| }, true | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So this pattern makes sense to me, and mirrors the way
buf curlworks in general, but I wonder if we can leverage and refactor the existing code paths to support this.Basically, the command does some validation upfront, sets up the URL, and then sets up the client + resolvers for the schema. I wonder if we can refactor a little bit so that we set up the resolvers for the schema after the validation, and then use that for completions for the URL.
That way, we have a single, consistent code path, and also uses the same
container, client, transport, etc.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
added in c48c0a6 if I'm understanding properly!