Since our service will be exposed through various transport protocols, we will need a way to model the requests and responses in and out of our service. We will do this by adding a struct
for each type of message our service will accept or return.
In order for somebody to call the Hash
method and then receive the hashed password as a response, we'll need to add the following two structures to service.go
:
type hashRequest struct { Password string `json:"password"` } type hashResponse struct { Hash string `json:"hash"` Err string `json:"err,omitempty"` }
The hashRequest
type contains a single field, the password, and the hashResponse
has the resulting hash and an Err
string field in case something goes wrong.
Before continuing, see whether you can model the same request/response pair for the Validate
method. Look at the signature in the Service
interface, examine the arguments it accepts, and think about what kind of responses it will need to make.
We are going to add a helper method (of type http.DecodeRequestFunc
from Go kit) that will be able to decode the JSON body of http.Request
to service.go
:
func decodeHashRequest(ctx context.Context, r *http.Request) (interface{}, error) { var req hashRequest err := json.NewDecoder(r.Body).Decode(&req) if err != nil { return nil, err } return req, nil }
The signature for decodeHashRequest
is dictated by Go kit because it will later use it to decode HTTP requests on our behalf. In this function, we just use json.Decoder
to unmarshal the JSON into our hashRequest
type.
Next, we will add the request and response structures as well as a decode helper function for the Validate
method:
type validateRequest struct { Password string `json:"password"` Hash string `json:"hash"` } type validateResponse struct { Valid bool `json:"valid"` Err string `json:"err,omitempty"` } func decodeValidateRequest(ctx context.Context, r *http.Request) (interface{}, error) { var req validateRequest err := json.NewDecoder(r.Body).Decode(&req) if err != nil { return nil, err } return req, nil }
Here, the validateRequest
struct takes both Password
and Hash
strings, since the signature has two input arguments and returns a response containing a bool
datatype called Valid
or Err
.
The final thing we need to do is encode the response. In this case, we can write a single method to encode both the hashResponse
and validateResponse
objects.
Add the following code to service.go
:
func encodeResponse(ctx context.Context, w http.ResponseWriter, response interface{}) error { return json.NewEncoder(w).Encode(response) }
Our encodeResponse
method just asks json.Encoder
to do the work for us. Note again that the signature is general since the response
type is interface{}
; this is because it's a Go kit mechanism for decoding to http.ResponseWriter
.
Endpoints are a special function type in Go kit that represent a single RPC method. The definition is inside the endpoint
package:
type Endpoint func(ctx context.Context, request interface{}) (response interface{}, err error)
An endpoint function takes context.Context
and request
, and it returns response
or error
. The request
and response
types are interface{}
, which tells us that it is up to the implementation code to deal with the actual types when building endpoints.
Endpoints are powerful because, like http.Handler
(and http.HandlerFunc
), you can wrap them with generalized middleware to solve a myriad of common issues that arise when building micro-services: logging, tracing, rate limiting, error handling, and more.
Go kit solves transporting over various protocols and uses endpoints as a general way to jump from their code to ours. For example, the gRPC server will listen on a port, and when it receives the appropriate message, it will call the corresponding Endpoint
function. Thanks to Go kit, this will all be transparent to us, as we only need to deal in Go code with our Service
interface.
In order to turn our service methods into endpoint.Endpoint
functions, we're going to write a function that handles the incoming hashRequest
, calls the Hash
service method, and depending on the response, builds and returns an appropriate hashResponse
object.
To service.go
, add the MakeHashEndpoint
function:
func MakeHashEndpoint(srv Service) endpoint.Endpoint { return func(ctx context.Context, request interface{}) (interface{}, error) { req := request.(hashRequest) v, err := srv.Hash(ctx, req.Password) if err != nil { return hashResponse{v, err.Error()}, nil } return hashResponse{v, ""}, nil } }
This function takes Service
as an argument, which means that we can generate an endpoint from any implementation of our Service
interface. We then use a type assertion to specify that the request argument should, in fact, be of type hashRequest
. We call the Hash
method, passing in the context and Password
, which we get from hashRequest
. If all is well, we build hashResponse
with the value we got back from the Hash
method and return it.
Let's do the same for the Validate
method:
func MakeValidateEndpoint(srv Service) endpoint.Endpoint { return func(ctx context.Context, request interface{}) (interface{}, error) { req := request.(validateRequest) v, err := srv.Validate(ctx, req.Password, req.Hash) if err != nil { return validateResponse{false, err.Error()}, nil } return validateResponse{v, ""}, nil } }
Here, we are doing the same: taking the request and using it to call the method before building a response. Note that we never return an error from the Endpoint
function.
There are two main types of errors in Go kit: transport errors (network failure, timeouts, dropped connection, and so on) and business logic errors (where the infrastructure of making the request and responding was successful, but something in the logic or data wasn't correct).
If the Hash
method returns an error, we are not going to return it as the second argument; instead, we are going to build hashResponse
, which contains the error string (accessible via the Error
method). This is because the error returned from an endpoint is intended to indicate a transport error, and perhaps Go kit will be configured to retry the call a few times by some middleware. If our service methods return an error, it is considered a business logic error and will probably always return the same error for the same input, so it's not worth retrying. This is why we wrap the error into the response and return it to the client so that they can deal with it.
Another very useful trick when dealing with endpoints in Go kit is to write an implementation of our vault.Service
interface, which just makes the necessary calls to the underlying endpoints.
To service.go
, add the following structure:
type Endpoints struct { HashEndpoint endpoint.Endpoint ValidateEndpoint endpoint.Endpoint }
In order to implement the vault.Service
interface, we are going to add the two methods to our Endpoints
structure, which will build a request object, make the request, and parse the resulting response object into the normal arguments to be returned.
Add the following Hash
method:
func (e Endpoints) Hash(ctx context.Context, password string) (string, error) { req := hashRequest{Password: password} resp, err := e.HashEndpoint(ctx, req) if err != nil { return "", err } hashResp := resp.(hashResponse) if hashResp.Err != "" { return "", errors.New(hashResp.Err) } return hashResp.Hash, nil }
We are calling HashEndpoint
with hashRequest
, which we create using the password argument before caching the general response to hashResponse
and returning the Hash value from it or an error.
We will do this for the Validate method:
func (e Endpoints) Validate(ctx context.Context, password, hash string) (bool, error) { req := validateRequest{Password: password, Hash: hash} resp, err := e.ValidateEndpoint(ctx, req) if err != nil { return false, err } validateResp := resp.(validateResponse) if validateResp.Err != "" { return false, errors.New(validateResp.Err) } return validateResp.Valid, nil }
These two methods will allow us to treat the endpoints we have created as though they are normal Go methods; very useful for when we actually consume our service later in this chapter.
18.119.133.160