diff --git a/README.md b/README.md index 1735af1..801386c 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,42 @@ You can define this `--schema` option as many times as you want. That means you $ fauxrpc run --schema=https://demo.connectrpc.com --schema=./example.binpb ``` +## Multi-protocol Support +The multi-protocol support [is based on ConnectRPC](https://connectrpc.com/docs/multi-protocol/). So with FauxRPC, you get **gRPC, gRPC-Web and Connect** out of the box. However, FauxRPC does one thing more. It allows you to use [`google.api.http` annotations](https://grpc-ecosystem.github.io/grpc-gateway/docs/tutorials/adding_annotations/) to present a JSON/HTTP API, so you can gRPC and REST together! This is normally done with [an additional service](https://github.com/grpc-ecosystem/grpc-gateway) that runs in-between the outside world and your actual gRPC service but with FauxRPC you get the so-called transcoding from HTTP/JSON to gRPC all in the same package. Here's a concrete example: + +```protobuf +syntax = "proto3"; + +package http.service; + +import "google/api/annotations.proto"; + +service HTTPService { + rpc GetMessage(GetMessageRequest) returns (Message) { + option (google.api.http) = {get: "/v1/{name=messages/*}"}; + } +} +message GetMessageRequest { + string name = 1; // Mapped to URL path. +} +message Message { + string text = 1; // The resource content. +} +``` + +Again, we start the service by building the descriptors and using +``` +$ buf build ./httpservice.proto -o ./httpservice.binpb +$ fauxrpc run --schema=httpservice.binpb +``` + +Now that we have the server running we can test this with the "normal" curl: +```shell +$ curl http://127.0.0.1:6660/v1/messages/123456 +{"text":"Retro."}⏎ +``` +Sweet. You can now easily support REST alongside gRPC. If you are wondering how to do this with "real" services, look into [vangaurd-go](https://github.com/connectrpc/vanguard-go). This library is doing the real heavy lifting. + ## What does the fake data look like? You might be wondering what actual responses look like. FauxRPC's fake data generation is continually improving so these details might change as time goes on. It uses a library called [fakeit](https://github.com/brianvoe/gofakeit) to generate fake data. Because protobufs have pretty well-defined types, we can easily generate data that technically matches the types. This works well for most use cases, but FauxRPC tries to be a little bit better. If you annotate your protobuf files with [protovalidate](https://github.com/bufbuild/protovalidate) constraints, FauxRPC will try its best to generate data that matches these constraints. Let's look at some examples! @@ -152,5 +188,4 @@ This project is just starting out. I plan to add a lot of things that make this - Service for adding/updating/removing stub responses with a CLI to add/remove/replace these stubs - Configuration file - BSR Support (this is a 'maybe' because using `buf build` to emit descriptors works well enough IMO) -- Testing for REST translations. I have no idea if this actually works yet - Better streaming support. FauxRPC does work with streaming calls but it only returns a single response diff --git a/cmd/fauxrpc/cmd_run.go b/cmd/fauxrpc/cmd_run.go index e2ef531..513aa48 100644 --- a/cmd/fauxrpc/cmd_run.go +++ b/cmd/fauxrpc/cmd_run.go @@ -10,6 +10,8 @@ import ( "connectrpc.com/grpcreflect" "connectrpc.com/vanguard" "github.com/sudorandom/fauxrpc/private/protobuf" + "golang.org/x/net/http2" + "golang.org/x/net/http2/h2c" "google.golang.org/protobuf/reflect/protoreflect" ) @@ -53,13 +55,12 @@ func (c *RunCmd) Run(globals *Globals) error { reflector := grpcreflect.NewReflector(&staticNames{names: serviceNames}, grpcreflect.WithDescriptorResolver(registry.Files())) mux := http.NewServeMux() - mux.Handle("/", TraceHandler(transcoder)) + mux.Handle("/", transcoder) mux.Handle(grpcreflect.NewHandlerV1(reflector)) mux.Handle(grpcreflect.NewHandlerV1Alpha(reflector)) server := &http.Server{ - Addr: c.Addr, - // Handler: h2c.NewHandler(mux, &http2.Server{}), - Handler: mux, + Addr: c.Addr, + Handler: h2c.NewHandler(mux, &http2.Server{}), } slog.Info(fmt.Sprintf("Listening on http://%s", c.Addr)) diff --git a/cmd/fauxrpc/trace.go b/cmd/fauxrpc/trace.go deleted file mode 100644 index ba68033..0000000 --- a/cmd/fauxrpc/trace.go +++ /dev/null @@ -1,207 +0,0 @@ -package main - -import ( - "context" - "errors" - "fmt" - "io" - "net/http" - "strings" - "sync/atomic" -) - -var idSource atomic.Int64 - -func TraceHandler(handler http.Handler) http.Handler { - return http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) { - reqID := idSource.Add(1) - trc := &tracer{reqID: reqID} - if request.RemoteAddr != "" { - trc.traceReq("(from %s)", request.RemoteAddr) - } - traceRequest(trc, request) - request.Body = &traceReader{r: request.Body, trace: trc.traceReq} - request = request.WithContext(context.WithValue(request.Context(), traceRequestID{}, reqID)) - tw := &traceWriter{w: responseWriter, trace: trc.traceResp} - defer tw.traceTrailers() - handler.ServeHTTP(tw, request) - }) -} - -func TraceTransport(transport http.RoundTripper) http.RoundTripper { - return traceTransport{transport} -} - -type traceTransport struct { - transport http.RoundTripper -} - -type traceRequestID struct{} - -func (t traceTransport) RoundTrip(request *http.Request) (*http.Response, error) { - reqID, ok := request.Context().Value(traceRequestID{}).(int64) - if !ok { - reqID = idSource.Add(1) - } - trc := &tracer{reqID: reqID, prefix: " "} - traceRequest(trc, request) - request.Body = &traceReader{r: request.Body, trace: trc.traceReq} - resp, err := t.transport.RoundTrip(request) - if err != nil { - trc.traceResp("ERROR: %v", err) - return nil, err - } - trc.traceResp("%s", resp.Status) - traceHeaders(trc.traceResp, resp.Header) - resp.Body = &traceReader{ - r: resp.Body, - trace: trc.traceResp, - onEnd: func() { - traceTrailers(trc.traceResp, resp.Trailer) - }, - } - return resp, nil -} - -func traceRequest(trc *tracer, req *http.Request) { - var queryString string - if req.URL.RawQuery != "" { - queryString = "?" + req.URL.RawQuery - } - scheme := req.URL.Scheme - if scheme == "" { - scheme = "http" - } - trc.traceReq("%s %s://%s%s%s %s", req.Method, scheme, req.Host, req.URL.Path, queryString, req.Proto) - traceHeaders(trc.traceReq, req.Header) - trc.traceReq("") -} - -func traceHeaders(trace func(string, ...any), header http.Header) { - for k, v := range header { - for _, val := range v { - trace("%s: %s", k, val) - } - } -} - -func traceTrailers(trace func(string, ...any), trailer http.Header) { - if len(trailer) == 0 { - return - } - trace("") - traceHeaders(trace, trailer) -} - -type traceReader struct { - r io.ReadCloser - trace func(string, ...any) - done bool - onEnd func() -} - -func (t *traceReader) Read(p []byte) (n int, err error) { - n, err = t.r.Read(p) - if n > 0 { - t.trace("(%d bytes)", n) - } - if err != nil && !t.done { - t.done = true - if errors.Is(err, io.EOF) { - t.trace("(EOF)") - if t.onEnd != nil { - t.onEnd() - } - } else { - t.trace("(%v!)", err) - } - } - return n, err -} - -func (t *traceReader) Close() error { - return t.r.Close() -} - -type traceWriter struct { - w http.ResponseWriter - trace func(string, ...any) - wroteHeaders bool - trailersSnapshot []string - done bool -} - -func (t *traceWriter) Header() http.Header { - return t.w.Header() -} - -func (t *traceWriter) Write(bytes []byte) (n int, err error) { - if !t.wroteHeaders { - t.WriteHeader(http.StatusOK) - } - n, err = t.w.Write(bytes) - if n > 0 { - t.trace("(%d bytes)", n) - } - if err != nil && !t.done { - t.done = true - t.trace("(%v!)", err) - } - return n, err -} - -func (t *traceWriter) WriteHeader(statusCode int) { - if t.wroteHeaders { - return - } - t.wroteHeaders = true - trailers := t.Header().Values("Trailer") - t.trailersSnapshot = make([]string, 0, len(trailers)) - for _, trailer := range trailers { - for _, k := range strings.Split(trailer, ",") { - t.trailersSnapshot = append(t.trailersSnapshot, strings.TrimSpace(k)) - } - } - t.w.WriteHeader(statusCode) - t.trace("%d %s", statusCode, http.StatusText(statusCode)) - traceHeaders(t.trace, t.Header()) - t.trace("") -} - -func (t *traceWriter) traceTrailers() { - if t.done { - return - } - trailers := http.Header{} - for k, v := range t.Header() { - if strings.HasPrefix(k, http.TrailerPrefix) { - trailers[strings.TrimPrefix(k, http.TrailerPrefix)] = v - } - } - for _, k := range t.trailersSnapshot { - vals := t.Header().Values(k) - if len(vals) > 0 { - trailers[k] = vals - } - } - traceTrailers(t.trace, trailers) -} - -func (t *traceWriter) Flush() { - if flusher, ok := t.w.(http.Flusher); ok { - flusher.Flush() - } -} - -type tracer struct { - reqID int64 - prefix string -} - -func (trc *tracer) traceReq(msg string, args ...interface{}) { - fmt.Printf("%s#%04d>> %s\n", trc.prefix, trc.reqID, fmt.Sprintf(msg, args...)) -} - -func (trc *tracer) traceResp(msg string, args ...interface{}) { - fmt.Printf("%s#%04d<< %s\n", trc.prefix, trc.reqID, fmt.Sprintf(msg, args...)) -}