Skip to content

Proposal: Request/Response interceptors #265

@amckinney

Description

@amckinney

In gRPC, interceptors are able to access the request and response objects from
the client and/or server.

Twirp supports similar middleware functionality via its ClientHooks and
ServerHooks constructs, but it's not currently possible to inspect the
request or response objects like gRPC.

This proposes a new Interceptors option for both the ClientHooks and
ServerHooks types.

For ClientHooks the user-provided interceptors would be able to take action

  • After the request structure is marshaled
  • Before the request is sent over the wire
  • After the response is returned

Borrowing from Twirp's generated code, in doProtobufRequest,
this would look something along the lines of the following:

func doProtoRequest(...) (context.Context, error) {
 req, err := newRequest(ctx, url, reqBody, "application/protobuf")
 if err != nil {
   return ctx, wrapInternal(err, "could not build request")
 }
 ctx, err = callClientRequestPrepared(ctx, hooks, req)
 if err != nil {
   return ctx, err
 }

 req = req.WithContext(ctx)
 method := twirp.ChainInterceptors(hooks.Interceptors...)(doProtobufMethod(out))
 if _, err := method(ctx, req); err != nil {
   return ctx, wrapInternal(err, "oops")
 }

 return ctx, nil
}

// doProtobufMethod returns an twirp.Method that performs the client call.
// This method should always occur last in the interceptor chain.
func doProtobufMethod(out proto.Message) twirp.Method {
 return func(ctx context.Context, req interface{}) (interface{}, error) {
   resp, err := client.Do(req)
   if err != nil {
     return ctx, wrapInternal(err, "failed to do request")
   }

   defer func() {
     cerr := resp.Body.Close()
     if err == nil && cerr != nil {
       err = wrapInternal(cerr, "failed to close response body")
     }
   }()

   if err = ctx.Err(); err != nil {
     return ctx, wrapInternal(err, "aborted because context was done")
   }

   if resp.StatusCode != 200 {
     return ctx, errorFromResponse(resp)
   }

   respBodyBytes, err := ioutil.ReadAll(resp.Body)
   if err != nil {
     return ctx, wrapInternal(err, "failed to read response body")
   }
   if err = ctx.Err(); err != nil {
     return ctx, wrapInternal(err, "aborted because context was done")
   }

   if err = proto.Unmarshal(respBodyBytes, out); err != nil {
     return ctx, wrapInternal(err, "failed to unmarshal proto response")
   }

   return out, nil
 }
}

Then in the core twirp package, there would be something along the
lines of the following:

type Method func(ctx context.Context, request interface{}) (interface{}, error)

type Interceptor func(Method) Method

func ChainInterceptors(interceptors ...Interceptor) Interceptor {
  // Compose the chain of interceptors into a single one.
  // ...
}

And we could write interceptors like the following:

// Logger returns a new twirp.Interceptor for logging.
func Logger(l *zap.Logger) twirp.Interceptor {
  return func(next twirp.Method) twirp.Method {
    return func(ctx context.Context, req interface{}) (interface{}, error) {
      // Log the request ...
      resp, err := next(ctx, req) // Calls the next method in the chain, e.g. doProtobufMethod above.
      if err != nil {
        // Log the error ...
        return nil, err
      }
      // Log the response ...
      return resp, nil
    }
  }
}

Finally, the twirp.ClientHooks would need to add something like the following.

type ClientHooks struct {
  // Interceptors are called after the request structure is serialized according to the associated encoding.
  Interceptors []Interceptor
}

Note that this is example code meant to demonstrate the proposal and is not meant
to be the recommended way of implementation. There might be unforeseen bugs and issues
with this approach, for example.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions