-
Notifications
You must be signed in to change notification settings - Fork 327
Description
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.