├── .travis.yml ├── LICENSE ├── README.md ├── backoff ├── backoff.go └── backoff_test.go ├── circuitbreaker ├── circuitbreaker.go └── circuitbreaker_test.go ├── client.go ├── client_test.go ├── config.go ├── doc.go ├── endpoint.go ├── errors.go ├── errors_test.go ├── example_test.go ├── go.mod ├── go.sum ├── gorilla ├── mux.go └── mux_test.go ├── health.go ├── http.go ├── http_test.go ├── log.go ├── middleware.go ├── middleware_test.go ├── options.go ├── router.go ├── server.go ├── server_test.go ├── shutdown.go └── transport.go /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | go: 4 | - 1.13.x 5 | - 1.14.x 6 | - 1.15.x 7 | - 1.16.x 8 | 9 | before_install: 10 | - go get github.com/mattn/goveralls 11 | after_success: 12 | - $GOPATH/bin/goveralls -service=travis-ci 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2018 Objenious 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so, 8 | subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 15 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 16 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # kitty 2 | [![Travis-CI](https://travis-ci.org/objenious/kitty.svg?branch=master)](https://travis-ci.org/objenious/kitty) [![GoDoc](https://godoc.org/github.com/objenious/kitty?status.svg)](http://godoc.org/github.com/objenious/kitty) 3 | [![GoReportCard](https://goreportcard.com/badge/github.com/objenious/kitty)](https://goreportcard.com/report/github.com/objenious/kitty) 4 | [![Coverage Status](https://coveralls.io/repos/github/objenious/kitty/badge.svg?branch=master)](https://coveralls.io/github/objenious/kitty?branch=master) 5 | 6 | `go get github.com/objenious/kitty` 7 | 8 | Kitty is a slightly opinionated framework based on go-kit. 9 | It's goal is to ease development of microservices deployed on Kubernetes (or any similar orchestration platform). 10 | 11 | Kitty has an opinion on: 12 | * transports: HTTP only (additional transports can be added as long as they implement kitty.Transport, a Google Pub/Sub transport is available as a separate package), 13 | * errors: an error may be Retryable (e.g. 5XX status codes) or not (e.g. 4XX status codes), 14 | * status codes: unless specified, request decoding errors will generate 400 HTTP status codes. 15 | 16 | Kitty has no opinion on: 17 | * logging: no logs are generated by default, you can plug your logger and it will get additional context, 18 | * packages: kitty only imports go-kit and the standard library, 19 | * routers: you can use any router (a Gorilla Mux implementation is available in a sub-package, other routers can easily be plugged), 20 | * encoding: use whatever encoding you want (JSON, messagepack, protobuf, ...), 21 | * monitoring, metrics and tracing: use Istio, a sidecar process or a middleware. 22 | 23 | Kitty includes 2 sub-packages: 24 | * backoff: Retryable-aware exponential backoff (only Retryable errors trigger retries), 25 | * circuitbreaker: Retryable-aware circuit breaker (only Retryable errors trigger the circuit breaker). 26 | 27 | ## Example 28 | 29 | Server-side 30 | ``` 31 | t := kitty.NewHTTPTransport(kitty.Config{HTTPPort: 8081}). 32 | Router(gorilla.Router()). 33 | Endpoint("POST", "/foo", Foo, kitty.Decoder(decodeFooRequest)). 34 | Endpoint("GET", "/bar", Bar) 35 | 36 | kitty.NewServer(t).Run(ctx) 37 | 38 | // Foo is a go-kit Endpoint 39 | func Foo(ctx context.Context, request interface{}) (interface{}, error) { 40 | fr := request.(fooRequest) 41 | return fooResponse{Message: fmt.Sprintf("Good morning %s !", fr.Name)}, nil 42 | } 43 | 44 | // decodeFooRequest 45 | func decodeFooRequest(ctx context.Context, r *http.Request) (interface{}, error) { 46 | var request fooRequest 47 | if err := json.NewDecoder(r.Body).Decode(&request); err != nil { 48 | return nil, err 49 | } 50 | return request, nil 51 | } 52 | ``` 53 | 54 | Client-side (with circuit breaker & exponential backoff) 55 | ``` 56 | u, err := url.Parse("http://example.com/foo") 57 | e := kitty.NewClient( 58 | "POST", 59 | u, 60 | kithttp.EncodeJSONRequest, 61 | decodeFooResponse 62 | ).Endpoint() 63 | cb := gobreaker.NewCircuitBreaker(gobreaker.Settings{Name: "foo"}) 64 | e = kittycircuitbreaker.NewCircuitBreaker(cb)(e) 65 | bo := backoff.NewExponentialBackOff() 66 | e = kittybackoff.NewBackoff(bo)(e) 67 | ``` 68 | 69 | ## How-to 70 | 71 | ### Log requests 72 | 73 | ``` 74 | kitty.NewServer(t). 75 | // Log as JSON 76 | Logger(log.NewJSONLogger(log.NewSyncWriter(os.Stdout))). 77 | // Add path and method to all log lines 78 | LogContext("http-path", "http-method"). 79 | // Log request only if an error occurred 80 | Middlewares(kitty.LogEndpoint(kitty.LogErrors)) 81 | ``` 82 | 83 | ### Integrate with Istio 84 | 85 | TBD 86 | 87 | ### Integrate liveness/readiness checks 88 | 89 | Using github.com/heptiolabs/healthcheck: 90 | ``` 91 | health := healthcheck.NewHandler() 92 | health.AddLivenessCheck("goroutine-threshold", healthcheck.GoroutineCountCheck(100)) 93 | health.AddReadinessCheck("database", healthcheck.DatabasePingCheck(db, 1*time.Second)) 94 | 95 | t := kitty.NewTransport(kitty.Config{}).Liveness(health.LiveEndpoint).Readiness(health.ReadyEndpoint) 96 | ``` 97 | 98 | ### Use Google Pub/Sub as a transport 99 | 100 | https://github.com/objenious/kitty-gcp adds a Google Pub/Sub transport to kitty: 101 | ``` 102 | import "github.com/objenious/kitty-gcp/pubsub" 103 | 104 | tr := pubsub.NewTransport(ctx, "project-id"). 105 | Endpoint(subscriptionName, endpoint, Decoder(decodeFunc)) 106 | err := kitty.NewServer(tr).Run(ctx) 107 | ``` 108 | 109 | ## Requirements 110 | 111 | Go > 1.11 112 | 113 | ## Contribution guidelines 114 | 115 | Contributions are welcome, as long as : 116 | * unit tests & comments are included, 117 | * no external package is added to the top-level package (implementations can be added as sub-packages). 118 | 119 | ## Thanks 120 | 121 | kitty is heavily inspired by gizmo/kit (https://godoc.org/github.com/NYTimes/gizmo/server/kit), 122 | with a different approach to server setup and without the gRPC clutter. 123 | 124 | ## License 125 | 126 | MIT - See LICENSE file 127 | -------------------------------------------------------------------------------- /backoff/backoff.go: -------------------------------------------------------------------------------- 1 | package backoff 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/cenkalti/backoff" 7 | "github.com/go-kit/kit/endpoint" 8 | "github.com/objenious/kitty" 9 | ) 10 | 11 | // NewBackoff creates an exponential backoff middleware, based on github.com/cenkalti/backoff. 12 | // Retries will be attempted if the returned error implements is retryable (see kitty.IsRetryable). 13 | func NewBackoff(bo backoff.BackOff) endpoint.Middleware { 14 | return func(next endpoint.Endpoint) endpoint.Endpoint { 15 | return func(ctx context.Context, request interface{}) (response interface{}, finalerr error) { 16 | err := backoff.Retry(func() error { 17 | response, finalerr = next(ctx, request) 18 | if kitty.IsRetryable(finalerr) { 19 | return finalerr 20 | } 21 | return nil 22 | }, backoff.NewExponentialBackOff()) 23 | 24 | if err != nil { 25 | finalerr = err 26 | } 27 | return 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /backoff/backoff_test.go: -------------------------------------------------------------------------------- 1 | package backoff 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "testing" 7 | 8 | "github.com/cenkalti/backoff" 9 | "github.com/go-kit/kit/endpoint" 10 | "github.com/objenious/kitty" 11 | ) 12 | 13 | type retryableError struct{} 14 | 15 | func (*retryableError) Error() string { return "error" } 16 | func (*retryableError) Retryable() bool { return true } 17 | func (*retryableError) Cause() error { return errors.New("error") } 18 | 19 | var _ kitty.Retryabler = &retryableError{} 20 | 21 | type nonRetryableError struct{} 22 | 23 | func (*nonRetryableError) Error() string { return "error" } 24 | func (*nonRetryableError) Retryable() bool { return false } 25 | 26 | func TestBackoff(t *testing.T) { 27 | bo := backoff.NewExponentialBackOff() 28 | { 29 | e := NewBackoff(bo)(mkFailingEndpoint(&retryableError{}, &retryableError{})) 30 | res, err := e(context.TODO(), nil) 31 | if err != nil { 32 | t.Error("With a retryable error, backoff should not return an error") 33 | } 34 | if res != "OK" { 35 | t.Error("With a retryable error, backoff should have returned the right result") 36 | } 37 | } 38 | { 39 | e := NewBackoff(bo)(mkFailingEndpoint(&nonRetryableError{})) 40 | res, err := e(context.TODO(), nil) 41 | if err == nil { 42 | t.Error("With a non retryable error, backoff should return an error") 43 | } 44 | if res != nil { 45 | t.Error("With a non retryable error, backoff should have returned no result") 46 | } 47 | } 48 | 49 | } 50 | 51 | func mkFailingEndpoint(errors ...error) endpoint.Endpoint { 52 | return func(_ context.Context, _ interface{}) (interface{}, error) { 53 | if len(errors) == 0 { 54 | return "OK", nil 55 | } 56 | err := errors[0] 57 | errors = errors[1:] 58 | return nil, err 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /circuitbreaker/circuitbreaker.go: -------------------------------------------------------------------------------- 1 | package circuitbreaker 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/go-kit/kit/endpoint" 7 | "github.com/objenious/kitty" 8 | "github.com/sony/gobreaker" 9 | ) 10 | 11 | type cbResponse struct { 12 | err error 13 | res interface{} 14 | } 15 | 16 | // NewCircuitBreaker creates a circuit breaker middleware, based on github.com/sony/gobreaker. 17 | // CircuitBreaker will only trigger on retryable errors (see kitty.IsRetryable). 18 | func NewCircuitBreaker(cb *gobreaker.CircuitBreaker) endpoint.Middleware { 19 | return func(next endpoint.Endpoint) endpoint.Endpoint { 20 | return func(ctx context.Context, request interface{}) (interface{}, error) { 21 | res, err := cb.Execute(func() (interface{}, error) { 22 | res, err := next(ctx, request) 23 | if kitty.IsRetryable(err) { 24 | return res, err 25 | } 26 | return cbResponse{res: res, err: err}, nil 27 | }) 28 | if err == gobreaker.ErrOpenState || err == gobreaker.ErrTooManyRequests { 29 | return nil, kitty.Retryable(err) 30 | } 31 | if cbres, ok := res.(cbResponse); ok { 32 | return cbres.res, cbres.err 33 | } 34 | return res, err 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /circuitbreaker/circuitbreaker_test.go: -------------------------------------------------------------------------------- 1 | package circuitbreaker 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "testing" 7 | 8 | "github.com/go-kit/kit/endpoint" 9 | "github.com/objenious/kitty" 10 | "github.com/sony/gobreaker" 11 | ) 12 | 13 | type retryableError struct{} 14 | 15 | func (*retryableError) Error() string { return "error" } 16 | func (*retryableError) Retryable() bool { return true } 17 | func (*retryableError) Cause() error { return errors.New("error") } 18 | 19 | var _ kitty.Retryabler = &retryableError{} 20 | 21 | type nonRetryableError struct{} 22 | 23 | func (*nonRetryableError) Error() string { return "error" } 24 | func (*nonRetryableError) Retryable() bool { return false } 25 | 26 | func TestCircuitBreaker(t *testing.T) { 27 | { 28 | cb := gobreaker.NewCircuitBreaker(gobreaker.Settings{ReadyToTrip: func(_ gobreaker.Counts) bool { return true }}) 29 | called := 0 30 | e := NewCircuitBreaker(cb)(mkFailingEndpoint(&called, &retryableError{})) 31 | _, err := e(context.TODO(), nil) 32 | if err == nil { 33 | t.Error("the circuit breaker should return an error") 34 | } 35 | _, err = e(context.TODO(), nil) 36 | if err.Error() != gobreaker.ErrOpenState.Error() { 37 | t.Error("the circuit breaker should trigger") 38 | } 39 | if !kitty.IsRetryable(err) { 40 | t.Error("circuit breaker errors should be retryable") 41 | } 42 | if called > 1 { 43 | t.Error("retryable errors should trigger the circuit breaker") 44 | } 45 | } 46 | { 47 | cb := gobreaker.NewCircuitBreaker(gobreaker.Settings{ReadyToTrip: func(_ gobreaker.Counts) bool { return true }}) 48 | called := 0 49 | e := NewCircuitBreaker(cb)(mkFailingEndpoint(&called, &nonRetryableError{})) 50 | _, err := e(context.TODO(), nil) 51 | if err == nil { 52 | t.Error("the circuit breaker should return an error") 53 | } 54 | _, err = e(context.TODO(), nil) 55 | if err == nil { 56 | t.Error("the circuit breaker should return an error") 57 | } 58 | if called <= 1 { 59 | t.Error("non retryable errors should not trigger the circuit breaker") 60 | } 61 | } 62 | } 63 | 64 | func mkFailingEndpoint(count *int, err error) endpoint.Endpoint { 65 | return func(_ context.Context, _ interface{}) (interface{}, error) { 66 | *count++ 67 | return nil, err 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /client.go: -------------------------------------------------------------------------------- 1 | package kitty 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/url" 7 | 8 | kithttp "github.com/go-kit/kit/transport/http" 9 | ) 10 | 11 | // Client is a wrapper above the go-kit http client. 12 | // It maps HTTP errors to Go errors. 13 | // As the mapped error implements StatusCode, the returned status code will also be used 14 | // as the status code returned by a go-kit HTTP endpoint. 15 | // When using the backoff middleware, only 429 & 5XX errors trigger a retry. 16 | type Client struct { 17 | *kithttp.Client 18 | } 19 | 20 | // NewClient creates a kitty client. 21 | func NewClient( 22 | method string, 23 | tgt *url.URL, 24 | enc kithttp.EncodeRequestFunc, 25 | dec kithttp.DecodeResponseFunc, 26 | options ...kithttp.ClientOption, 27 | ) *Client { 28 | return &Client{Client: kithttp.NewClient(method, tgt, enc, makeDecodeResponseFunc(dec), options...)} 29 | } 30 | 31 | // NewClientWithError creates a kitty client that doesn't deal with HTTP errors. 32 | // and let you do it while you decode. 33 | func NewClientWithError( 34 | method string, 35 | tgt *url.URL, 36 | enc kithttp.EncodeRequestFunc, 37 | dec kithttp.DecodeResponseFunc, 38 | options ...kithttp.ClientOption, 39 | ) *Client { 40 | return &Client{Client: kithttp.NewClient(method, tgt, enc, dec, options...)} 41 | } 42 | 43 | // makeDecodeResponseFunc maps HTTP errors to Go errors. 44 | func makeDecodeResponseFunc(fn kithttp.DecodeResponseFunc) kithttp.DecodeResponseFunc { 45 | return func(ctx context.Context, resp *http.Response) (interface{}, error) { 46 | if err := HTTPError(resp); err != nil { 47 | return nil, err 48 | } 49 | return fn(ctx, resp) 50 | } 51 | } -------------------------------------------------------------------------------- /client_test.go: -------------------------------------------------------------------------------- 1 | package kitty 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "net/http" 7 | "net/http/httptest" 8 | "net/url" 9 | "reflect" 10 | "testing" 11 | 12 | kithttp "github.com/go-kit/kit/transport/http" 13 | ) 14 | 15 | func TestClient(t *testing.T) { 16 | h := testHandler{} 17 | ts := httptest.NewServer(&h) 18 | defer ts.Close() 19 | 20 | client := ts.Client() 21 | u, _ := url.Parse(ts.URL) 22 | e := NewClient("GET", u, kithttp.EncodeJSONRequest, decodeTestResponse, kithttp.SetClient(client)).Endpoint() 23 | h.statuses = []int{http.StatusServiceUnavailable} 24 | _, err := e(context.TODO(), nil) 25 | if err == nil { 26 | t.Error("When calling a failed server, the client should return an error") 27 | } 28 | if !IsRetryable(err) { 29 | t.Error("The returned error for http.StatusServiceUnavailable should be retryable") 30 | } 31 | h.statuses = []int{http.StatusBadRequest} 32 | _, err = e(context.TODO(), nil) 33 | if err == nil { 34 | t.Error("When calling a failed server, the client should return an error") 35 | } 36 | if IsRetryable(err) { 37 | t.Error("The returned error for http.StatusBadRequest should not be retryable") 38 | } 39 | h.statuses = []int{} 40 | res, err := e(context.TODO(), nil) 41 | if err != nil { 42 | t.Errorf("When calling a working server, the client should not return an error, got %s", err) 43 | } else if !reflect.DeepEqual(res, testData) { 44 | t.Errorf("The endpoint returned invalid data : %+v", res) 45 | } 46 | } 47 | 48 | func TestClientWithError(t *testing.T) { 49 | h := testHandler{} 50 | ts := httptest.NewServer(&h) 51 | defer ts.Close() 52 | 53 | client := ts.Client() 54 | u, _ := url.Parse(ts.URL) 55 | e := NewClientWithError("GET", u, kithttp.EncodeJSONRequest, decodeTestResponse, kithttp.SetClient(client)).Endpoint() 56 | 57 | h.statuses = []int{http.StatusServiceUnavailable} 58 | resp, err := e(context.TODO(), nil) 59 | if err != nil { 60 | t.Error("When calling a failed server, the client with error should NOT return an error") 61 | } 62 | v, ok := resp.(testStruct) 63 | if ok == false { 64 | t.Errorf("The returned response must be decoded, got %#v", resp) 65 | } 66 | if v.err == nil { 67 | t.Error("The response has not been correctly decoded") 68 | } 69 | 70 | if !IsRetryable(v.err) { 71 | t.Error("The returned error for http.StatusServiceUnavailable should be retryable") 72 | } 73 | 74 | h.statuses = []int{http.StatusBadRequest} 75 | resp, err = e(context.TODO(), nil) 76 | if err != nil { 77 | t.Error("When calling a failed server, the client with error should NOT return an error") 78 | } 79 | v, ok = resp.(testStruct) 80 | if ok == false { 81 | t.Errorf("The returned response must be decoded, got %#v", resp) 82 | } 83 | if v.err == nil { 84 | t.Error("The response has not been correctly decoded") 85 | } 86 | if IsRetryable(v.err) { 87 | t.Error("The returned error for http.StatusBadRequest should not be retryable") 88 | } 89 | 90 | 91 | h.statuses = []int{} 92 | res, err := e(context.TODO(), nil) 93 | if err != nil { 94 | t.Errorf("When calling a working server, the client should not return an error, got %s", err) 95 | } else if !reflect.DeepEqual(res, testData) { 96 | t.Errorf("The endpoint returned invalid data : %+v", res) 97 | } 98 | } 99 | 100 | var testData = testStruct{Foo: "bar"} 101 | 102 | type testHandler struct { 103 | statuses []int 104 | } 105 | 106 | func (h *testHandler) ServeHTTP(w http.ResponseWriter, _ *http.Request) { 107 | if len(h.statuses) > 0 { 108 | w.WriteHeader(h.statuses[0]) 109 | h.statuses = h.statuses[1:] 110 | } else { 111 | w.WriteHeader(http.StatusOK) 112 | } 113 | json.NewEncoder(w).Encode(testData) 114 | } 115 | 116 | func decodeTestResponse(ctx context.Context, resp *http.Response) (interface{}, error) { 117 | response := testStruct{} 118 | if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { 119 | return nil, err 120 | } 121 | 122 | response.err = HTTPError(resp) 123 | 124 | return response, nil 125 | } 126 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | package kitty 2 | 3 | import kithttp "github.com/go-kit/kit/transport/http" 4 | 5 | // Config holds configuration info for kitty.HTTPTransport. 6 | type Config struct { 7 | // LivenessCheckPath is the path of the health handler (default: "/alivez"). 8 | LivenessCheckPath string 9 | // ReadinessCheckPath is the path of the readiness handler (default: "/readyz"). 10 | ReadinessCheckPath string 11 | // HTTPPort is the port the server will listen on (default: 8080). 12 | HTTPPort int 13 | // EnablePProf enables pprof urls (default: false). 14 | EnablePProf bool 15 | // EncodeResponse defines the default response encoder for all endpoints (by default: EncodeJSONResponse). It can be overriden for a specific endpoint. 16 | EncodeResponse kithttp.EncodeResponseFunc 17 | } 18 | 19 | // DefaultConfig defines the default config of kitty.HTTPTransport. 20 | var DefaultConfig = Config{ 21 | HTTPPort: 8080, 22 | LivenessCheckPath: "/alivez", 23 | ReadinessCheckPath: "/readyz", 24 | EnablePProf: false, 25 | EncodeResponse: kithttp.EncodeJSONResponse, 26 | } 27 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | // Package kitty is a slightly opinionated framework based on go-kit. 2 | // It's goal is to ease development of services deployed on Kubernetes (or any similar orchestration platform). 3 | // 4 | // Kitty has an opinion on: 5 | // 6 | // * transports: HTTP only (additional transports can be added as long as they implement kitty.Transport), 7 | // * errors: an error may be Retryable (e.g. 5XX status codes) or not (e.g. 4XX status codes). 8 | // 9 | // Kitty has no opinion on: 10 | // 11 | // * logging: no logs are generated by default, you can plug your logger and it will get additional context, 12 | // 13 | // * packages: kitty only imports go-kit and the standard library, 14 | // 15 | // * routers: you can use any router (Gorilla Mux works out of the box, other routers can easily be plugged), 16 | // 17 | // * encoding: use whatever encoding you want (JSON, messagepack, protobuf, ...), 18 | // 19 | // * monitoring, metrics and tracing: use Istio or a sidecar process. 20 | package kitty 21 | -------------------------------------------------------------------------------- /endpoint.go: -------------------------------------------------------------------------------- 1 | package kitty 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | "github.com/go-kit/kit/endpoint" 8 | kithttp "github.com/go-kit/kit/transport/http" 9 | ) 10 | 11 | // httpendpoint encapsulates everything required to build 12 | // an endpoint hosted on a kit server. 13 | type httpendpoint struct { 14 | method, path string 15 | endpoint endpoint.Endpoint 16 | decoder kithttp.DecodeRequestFunc 17 | encoder kithttp.EncodeResponseFunc 18 | options []kithttp.ServerOption 19 | } 20 | 21 | // HTTPEndpointOption is an option for an HTTP endpoint 22 | type HTTPEndpointOption func(*httpendpoint) *httpendpoint 23 | 24 | // Endpoint registers an endpoint to a kitty.HTTPTransport. 25 | // Unless specified, NopRequestDecoder will decode the request (and do nothing), 26 | // and EncodeJSONResponse will encode the response. 27 | func (t *HTTPTransport) Endpoint(method, path string, ep endpoint.Endpoint, opts ...HTTPEndpointOption) *HTTPTransport { 28 | e := &httpendpoint{ 29 | method: method, 30 | path: path, 31 | endpoint: ep, 32 | decoder: kithttp.NopRequestDecoder, 33 | } 34 | for _, opt := range opts { 35 | e = opt(e) 36 | } 37 | t.endpoints = append(t.endpoints, e) 38 | return t 39 | } 40 | 41 | type decoderError struct { 42 | error 43 | } 44 | 45 | func (e decoderError) StatusCode() int { 46 | if err, ok := e.error.(kithttp.StatusCoder); ok { 47 | return err.StatusCode() 48 | } 49 | return http.StatusBadRequest 50 | } 51 | 52 | // Decoder defines the request decoder for a HTTP endpoint. 53 | // If none is provided, NopRequestDecoder is used. 54 | func Decoder(dec kithttp.DecodeRequestFunc) HTTPEndpointOption { 55 | return func(e *httpendpoint) *httpendpoint { 56 | e.decoder = func(ctx context.Context, r *http.Request) (interface{}, error) { 57 | request, err := dec(ctx, r) 58 | if err != nil { 59 | return nil, decoderError{error: err} 60 | } 61 | return request, nil 62 | } 63 | return e 64 | } 65 | } 66 | 67 | // Encoder defines the response encoder for a HTTP endpoint. 68 | // If none is provided, EncodeJSONResponse is used. 69 | func Encoder(enc kithttp.EncodeResponseFunc) HTTPEndpointOption { 70 | return func(e *httpendpoint) *httpendpoint { 71 | e.encoder = enc 72 | return e 73 | } 74 | } 75 | 76 | // ServerOptions defines a liste of go-kit ServerOption to be used by a HTTP endpoint. 77 | func ServerOptions(opts ...kithttp.ServerOption) HTTPEndpointOption { 78 | return func(e *httpendpoint) *httpendpoint { 79 | e.options = opts 80 | return e 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /errors.go: -------------------------------------------------------------------------------- 1 | package kitty 2 | 3 | import ( 4 | "net/http" 5 | "errors" 6 | ) 7 | 8 | // Retryabler defines an error that may be temporary. A function returning a retryable error may be executed again. 9 | type Retryabler interface { 10 | Retryable() bool 11 | Cause() error 12 | } 13 | 14 | // IsRetryable checks if an error is retryable (i.e. implements Retryabler and Retryable returns true). 15 | // Retryable errors may be wrapped using github.com/pkg/errors. 16 | // If the error is nil or does not implement Retryabler, false is returned. 17 | func IsRetryable(err error) bool { 18 | type causer interface { 19 | Cause() error 20 | } 21 | 22 | for err != nil { 23 | if retry, ok := err.(Retryabler); ok { 24 | return retry.Retryable() 25 | } 26 | cause, ok := err.(causer) 27 | if !ok { 28 | break 29 | } 30 | err = cause.Cause() 31 | } 32 | return false 33 | } 34 | 35 | type retryableError struct { 36 | error 37 | } 38 | 39 | func (e retryableError) Cause() error { 40 | return e.error 41 | } 42 | 43 | func (retryableError) Retryable() bool { 44 | return true 45 | } 46 | 47 | var _ error = retryableError{} 48 | var _ Retryabler = retryableError{} 49 | 50 | // Retryable defines an error as retryable. 51 | func Retryable(err error) error { 52 | return retryableError{error: err} 53 | } 54 | 55 | // HTTPError builds an error based on a http.Response. If status code is < 300 or 304, nil is returned. 56 | // 429, 5XX errors are Retryable. 57 | func HTTPError(resp *http.Response) error { 58 | if resp.StatusCode < 300 || resp.StatusCode == http.StatusNotModified { 59 | return nil 60 | } 61 | return httpError(resp.StatusCode) 62 | } 63 | 64 | type httpError int 65 | 66 | func (err httpError) Error() string { 67 | switch err { 68 | case 429: 69 | return "Too Many Requests" 70 | default: 71 | return http.StatusText(int(err)) 72 | } 73 | } 74 | 75 | func (err httpError) StatusCode() int { 76 | return int(err) 77 | } 78 | 79 | func (err httpError) Retryable() bool { 80 | switch int(err) { 81 | case http.StatusBadGateway, http.StatusGatewayTimeout, http.StatusServiceUnavailable, http.StatusInternalServerError: 82 | return true 83 | case 429: 84 | return true 85 | default: 86 | return false 87 | } 88 | } 89 | 90 | func (err httpError) Cause() error { 91 | return errors.New(err.Error()) 92 | } 93 | -------------------------------------------------------------------------------- /errors_test.go: -------------------------------------------------------------------------------- 1 | package kitty 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | "testing" 7 | ) 8 | 9 | func TestRetryable(t *testing.T) { 10 | { 11 | err := errors.New("foo") 12 | err2 := Retryable(err) 13 | if err.Error() != err2.Error() { 14 | t.Error("Retryable should not change the error text") 15 | } 16 | if !IsRetryable(err2) { 17 | t.Error("Retryable should generate a retryable error") 18 | } 19 | } 20 | { 21 | err := httpError(http.StatusBadRequest) 22 | err2 := Retryable(err) 23 | if !IsRetryable(err2) { 24 | t.Error("Retryable should transform a non retryable error into a retryable error") 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /example_test.go: -------------------------------------------------------------------------------- 1 | package kitty_test 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | 9 | "github.com/objenious/kitty" 10 | "github.com/objenious/kitty/gorilla" 11 | ) 12 | 13 | func ExampleServer() { 14 | type fooRequest struct{ Name string } 15 | type fooResponse struct{ Message string } 16 | 17 | foo := func(ctx context.Context, request interface{}) (interface{}, error) { 18 | fr := request.(fooRequest) 19 | return fooResponse{Message: fmt.Sprintf("Good morning %s !", fr.Name)}, nil 20 | } 21 | 22 | decodeFooRequest := func(ctx context.Context, r *http.Request) (interface{}, error) { 23 | var request fooRequest 24 | if err := json.NewDecoder(r.Body).Decode(&request); err != nil { 25 | return nil, err 26 | } 27 | return request, nil 28 | } 29 | t := kitty.NewHTTPTransport(kitty.Config{HTTPPort: 8081}). 30 | Router(gorilla.Router()). 31 | Endpoint("POST", "/foo", foo, kitty.Decoder(decodeFooRequest)) 32 | kitty.NewServer(t).Run(context.Background()) 33 | } 34 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/objenious/kitty 2 | 3 | go 1.13 4 | 5 | require ( 6 | github.com/cenkalti/backoff v2.2.1+incompatible 7 | github.com/go-kit/kit v0.9.0 8 | github.com/go-logfmt/logfmt v0.4.0 // indirect 9 | github.com/go-stack/stack v1.8.0 // indirect 10 | github.com/gorilla/mux v1.7.3 11 | github.com/sony/gobreaker v0.4.1 12 | ) 13 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4= 2 | github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= 3 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 4 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/go-kit/kit v0.9.0 h1:wDJmvq38kDhkVxi50ni9ykkdUr1PKgqKOoi01fa0Mdk= 6 | github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= 7 | github.com/go-logfmt/logfmt v0.4.0 h1:MP4Eh7ZCb31lleYCFuwm0oe4/YGak+5l1vA2NOE80nA= 8 | github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= 9 | github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk= 10 | github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= 11 | github.com/gorilla/mux v1.7.3 h1:gnP5JzjVOuiZD07fKKToCAOjS0yOpj/qPETTXCCS6hw= 12 | github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= 13 | github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515 h1:T+h1c/A9Gawja4Y9mFVWj2vyii2bbUNDw3kt9VxK2EY= 14 | github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= 15 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 16 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 17 | github.com/sony/gobreaker v0.4.1 h1:oMnRNZXX5j85zso6xCPRNPtmAycat+WcoKbklScLDgQ= 18 | github.com/sony/gobreaker v0.4.1/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= 19 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 20 | github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= 21 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 22 | -------------------------------------------------------------------------------- /gorilla/mux.go: -------------------------------------------------------------------------------- 1 | package gorilla 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gorilla/mux" 7 | "github.com/objenious/kitty" 8 | ) 9 | 10 | // router is a Router implementation for the gorilla/mux router. 11 | type router struct { 12 | mux *mux.Router 13 | } 14 | 15 | var _ kitty.Router = &router{} 16 | 17 | func Router() kitty.Router { 18 | return &router{mux.NewRouter()} 19 | } 20 | 21 | // Handle registers a handler to the router. 22 | func (g *router) Handle(method, path string, h http.Handler) { 23 | g.mux.Handle(path, h).Methods(method) 24 | } 25 | 26 | // SetNotFoundHandler will sets the NotFound handler. 27 | func (g *router) SetNotFoundHandler(h http.Handler) { 28 | g.mux.NotFoundHandler = h 29 | } 30 | 31 | // ServeHTTP dispatches the handler registered in the matched route. 32 | func (g *router) ServeHTTP(w http.ResponseWriter, r *http.Request) { 33 | g.mux.ServeHTTP(w, r) 34 | } 35 | -------------------------------------------------------------------------------- /gorilla/mux_test.go: -------------------------------------------------------------------------------- 1 | package gorilla 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "io" 8 | "net/http" 9 | "reflect" 10 | "testing" 11 | "time" 12 | 13 | "github.com/objenious/kitty" 14 | ) 15 | 16 | func TestRouter(t *testing.T) { 17 | notfoundcalled := false 18 | ctx, cancel := context.WithCancel(context.TODO()) 19 | tr := kitty.NewHTTPTransport(kitty.DefaultConfig). 20 | Endpoint("POST", "/foo", testEP, kitty.Decoder(goodDecoder)). 21 | Router(Router(), kitty.NotFoundHandler(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { 22 | notfoundcalled = true 23 | w.WriteHeader(http.StatusNotFound) 24 | _, _ = io.WriteString(w, "OK") 25 | }))) 26 | srv := kitty.NewServer(tr) 27 | go func() { 28 | srv.Run(ctx) 29 | }() 30 | 31 | start := time.Now() 32 | for { 33 | resp, err := http.Get("http://localhost:8080/alivez") 34 | if err == nil && resp.StatusCode == http.StatusOK { 35 | break 36 | } 37 | if time.Since(start) > 500*time.Millisecond { 38 | t.Fatal("server did not start within 500msec or liveness returned an error") 39 | } 40 | time.Sleep(50 * time.Millisecond) 41 | } 42 | { 43 | resp, err := http.Post("http://localhost:8080/foo", "application/json", bytes.NewBufferString(`{"foo":"bar"}`)) 44 | if err != nil { 45 | t.Errorf("http.Get returned an error : %s", err) 46 | } else { 47 | if resp.StatusCode != 200 { 48 | t.Errorf("receive a %d status instead of 200", resp.StatusCode) 49 | } 50 | resData := testStruct{} 51 | err := json.NewDecoder(resp.Body).Decode(&resData) 52 | resp.Body.Close() 53 | if err != nil { 54 | t.Errorf("json.Decode returned an error : %s", err) 55 | } else if !reflect.DeepEqual(resData, testStruct{Foo: "bar"}) { 56 | t.Errorf("http.Get returned invalid data : %+v", resData) 57 | } 58 | } 59 | } 60 | { 61 | resp, err := http.Get("http://localhost:8080/does_not_exist") 62 | if err != nil { 63 | t.Errorf("http.Get returned an error : %s", err) 64 | } else { 65 | resp.Body.Close() 66 | if resp.StatusCode != http.StatusNotFound { 67 | t.Errorf("A call to an unknown url should return a StatusNotFound status, not %d", resp.StatusCode) 68 | } 69 | if !notfoundcalled { 70 | t.Error("the not found handler was not called") 71 | } 72 | } 73 | } 74 | 75 | cancel() 76 | } 77 | 78 | type testStruct struct { 79 | Foo string `json:"foo"` 80 | } 81 | 82 | func testEP(_ context.Context, req interface{}) (interface{}, error) { 83 | return req, nil 84 | } 85 | 86 | func goodDecoder(_ context.Context, r *http.Request) (interface{}, error) { 87 | request := &testStruct{} 88 | err := json.NewDecoder(r.Body).Decode(request) 89 | return request, err 90 | } 91 | -------------------------------------------------------------------------------- /health.go: -------------------------------------------------------------------------------- 1 | package kitty 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | ) 7 | 8 | // Liveness defines the liveness handler. 9 | func (t *HTTPTransport) Liveness(h http.HandlerFunc) *HTTPTransport { 10 | t.liveness = h 11 | return t 12 | } 13 | 14 | // Readiness defines the readiness handler. 15 | func (t *HTTPTransport) Readiness(h http.HandlerFunc) *HTTPTransport { 16 | t.readiness = h 17 | return t 18 | } 19 | 20 | // defaultHealthcheck is a default health handler that returns a 200 status and an "OK" body 21 | func defaultHealthcheck(w http.ResponseWriter, _ *http.Request) { 22 | w.WriteHeader(http.StatusOK) 23 | _, _ = io.WriteString(w, "OK") 24 | } 25 | -------------------------------------------------------------------------------- /http.go: -------------------------------------------------------------------------------- 1 | package kitty 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "net/http/pprof" 8 | 9 | "github.com/go-kit/kit/endpoint" 10 | kithttp "github.com/go-kit/kit/transport/http" 11 | ) 12 | 13 | // HTTPTransport defines a HTTP transport for a kitty Server. 14 | type HTTPTransport struct { 15 | cfg Config 16 | 17 | opts []kithttp.ServerOption 18 | 19 | endpoints []*httpendpoint 20 | 21 | httpmiddleware func(http.Handler) http.Handler 22 | mux Router 23 | svr *http.Server 24 | 25 | liveness http.HandlerFunc 26 | readiness http.HandlerFunc 27 | } 28 | 29 | var _ Transport = &HTTPTransport{} 30 | 31 | // nopHTTPMiddleWare is the default HTTP middleware, and does nothing. 32 | func nopHTTPMiddleWare(h http.Handler) http.Handler { 33 | return h 34 | } 35 | 36 | // HTTPMiddlewares defines the list of HTTP middlewares to be added to all HTTP handlers. 37 | func (t *HTTPTransport) HTTPMiddlewares(m ...func(http.Handler) http.Handler) *HTTPTransport { 38 | t.httpmiddleware = func(next http.Handler) http.Handler { 39 | for i := len(m) - 1; i >= 0; i-- { 40 | next = m[i](next) 41 | } 42 | return next 43 | } 44 | return t 45 | } 46 | 47 | // NewHTTPTransport creates a new HTTP transport, based on the specified config. 48 | func NewHTTPTransport(cfg Config) *HTTPTransport { 49 | t := &HTTPTransport{ 50 | cfg: DefaultConfig, 51 | httpmiddleware: nopHTTPMiddleWare, 52 | mux: StdlibRouter(), 53 | liveness: defaultHealthcheck, 54 | readiness: defaultHealthcheck, 55 | } 56 | if cfg.HTTPPort > 0 { 57 | t.cfg.HTTPPort = cfg.HTTPPort 58 | } 59 | if cfg.LivenessCheckPath != "" { 60 | t.cfg.LivenessCheckPath = cfg.LivenessCheckPath 61 | } 62 | if cfg.ReadinessCheckPath != "" { 63 | t.cfg.ReadinessCheckPath = cfg.ReadinessCheckPath 64 | } 65 | t.cfg.EnablePProf = cfg.EnablePProf 66 | if cfg.EncodeResponse != nil { 67 | t.cfg.EncodeResponse = cfg.EncodeResponse 68 | } 69 | return t 70 | } 71 | 72 | // ServeHTTP implements http.Handler. 73 | func (t *HTTPTransport) ServeHTTP(w http.ResponseWriter, r *http.Request) { 74 | t.httpmiddleware(t.mux).ServeHTTP(w, r) 75 | } 76 | 77 | // RegisterEndpoints registers all configured endpoints, wraps them with the m middleware. 78 | func (t *HTTPTransport) RegisterEndpoints(m endpoint.Middleware) error { 79 | opts := []kithttp.ServerOption{ 80 | kithttp.ServerBefore(kithttp.PopulateRequestContext), 81 | } 82 | opts = append(opts, t.opts...) 83 | 84 | // register endpoints 85 | for _, ep := range t.endpoints { 86 | encoder := t.cfg.EncodeResponse 87 | if ep.encoder != nil { 88 | encoder = ep.encoder 89 | } 90 | t.mux.Handle(ep.method, ep.path, 91 | kithttp.NewServer( 92 | m(ep.endpoint), 93 | ep.decoder, 94 | encoder, 95 | append(opts, ep.options...)...)) 96 | } 97 | 98 | // register health handlers 99 | t.mux.Handle("GET", t.cfg.LivenessCheckPath, t.liveness) 100 | t.mux.Handle("GET", t.cfg.ReadinessCheckPath, t.readiness) 101 | 102 | // register pprof handlers 103 | registerPProf(t.cfg, t.mux) 104 | return nil 105 | } 106 | 107 | var httpLogkeys = map[string]interface{}{ 108 | "http-method": kithttp.ContextKeyRequestMethod, 109 | "http-uri": kithttp.ContextKeyRequestURI, 110 | "http-path": kithttp.ContextKeyRequestPath, 111 | "http-proto": kithttp.ContextKeyRequestProto, 112 | "http-requesthost": kithttp.ContextKeyRequestHost, 113 | "http-remote-addr": kithttp.ContextKeyRequestRemoteAddr, 114 | "http-x-forwarded-for": kithttp.ContextKeyRequestXForwardedFor, 115 | "http-x-forwarded-proto": kithttp.ContextKeyRequestXForwardedProto, 116 | "http-user-agent": kithttp.ContextKeyRequestUserAgent, 117 | "http-x-request-id": kithttp.ContextKeyRequestXRequestID, 118 | } 119 | 120 | // LogKeys returns the list of name key to context key mappings 121 | func (t *HTTPTransport) LogKeys() map[string]interface{} { 122 | return httpLogkeys 123 | } 124 | 125 | // Start starts the HTTP server. 126 | func (t *HTTPTransport) Start(ctx context.Context) error { 127 | t.svr = &http.Server{ 128 | Handler: t, 129 | Addr: fmt.Sprintf(":%d", t.cfg.HTTPPort), 130 | } 131 | _ = LogMessage(ctx, fmt.Sprintf("Listening on port: %d", t.cfg.HTTPPort)) 132 | err := t.svr.ListenAndServe() 133 | if err != nil && err != http.ErrServerClosed { 134 | return err 135 | } 136 | return nil 137 | } 138 | 139 | // Shutdown shutdowns the HTTP server 140 | func (t *HTTPTransport) Shutdown(ctx context.Context) error { 141 | return t.svr.Shutdown(ctx) 142 | } 143 | 144 | func registerPProf(cfg Config, mux Router) { 145 | if !cfg.EnablePProf { 146 | return 147 | } 148 | mux.Handle("GET", "/debug/pprof/", http.HandlerFunc(pprof.Index)) 149 | mux.Handle("GET", "/debug/pprof/cmdline", http.HandlerFunc(pprof.Cmdline)) 150 | mux.Handle("GET", "/debug/pprof/profile", http.HandlerFunc(pprof.Profile)) 151 | mux.Handle("GET", "/debug/pprof/symbol", http.HandlerFunc(pprof.Symbol)) 152 | mux.Handle("GET", "/debug/pprof/trace", http.HandlerFunc(pprof.Trace)) 153 | mux.Handle("GET", "/debug/pprof/goroutine", pprof.Handler("goroutine")) 154 | mux.Handle("GET", "/debug/pprof/heap", pprof.Handler("heap")) 155 | mux.Handle("GET", "/debug/pprof/threadcreate", pprof.Handler("threadcreate")) 156 | mux.Handle("GET", "/debug/pprof/block", pprof.Handler("block")) 157 | } 158 | -------------------------------------------------------------------------------- /http_test.go: -------------------------------------------------------------------------------- 1 | package kitty 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "net/http" 7 | "net/http/httptest" 8 | "net/url" 9 | "strings" 10 | "testing" 11 | 12 | "github.com/go-kit/kit/endpoint" 13 | ) 14 | 15 | func TestEndpointResponseEncode(t *testing.T) { 16 | cfg := Config{} 17 | HTTPTransport := NewHTTPTransport(cfg) 18 | HTTPTransport.Endpoint("GET", "/test/default", func(ctx context.Context, r interface{}) (interface{}, error) { 19 | return "default response", nil 20 | }).Endpoint("GET", "/test/override", func(ctx context.Context, r interface{}) (interface{}, error) { 21 | return "override response", nil 22 | }, Encoder(func(ctx context.Context, w http.ResponseWriter, r interface{}) error { 23 | w.WriteHeader(501) 24 | return nil 25 | })) 26 | HTTPTransport.RegisterEndpoints(func(e endpoint.Endpoint) endpoint.Endpoint { 27 | return e 28 | }) 29 | { 30 | rec := httptest.NewRecorder() 31 | HTTPTransport.ServeHTTP(rec, &http.Request{ 32 | Method: "GET", 33 | RequestURI: "/test/default", 34 | URL: &url.URL{ 35 | Path: "/test/default", 36 | }, 37 | }) 38 | if rec.Code != 200 { 39 | t.Errorf("default HTTP response status expected: %d", rec.Code) 40 | } 41 | body := rec.Body.String() 42 | if strings.TrimSpace(body) != `"default response"` { 43 | t.Errorf("different body expected: %s", body) 44 | } 45 | } 46 | { 47 | rec := httptest.NewRecorder() 48 | HTTPTransport.ServeHTTP(rec, &http.Request{ 49 | Method: "GET", 50 | RequestURI: "/test/override", 51 | URL: &url.URL{ 52 | Path: "/test/override", 53 | }, 54 | }) 55 | if rec.Code != 501 { 56 | t.Errorf("override HTTP response status expected: %d", rec.Code) 57 | } 58 | body := rec.Body.String() 59 | if body != "" { 60 | t.Errorf("different body expected: %s", body) 61 | } 62 | } 63 | } 64 | 65 | func TestDefaultResponseEncode(t *testing.T) { 66 | cfg := Config{ 67 | EncodeResponse: func(ctx context.Context, w http.ResponseWriter, r interface{}) error { 68 | w.WriteHeader(501) 69 | w.Write([]byte("response:")) 70 | return json.NewEncoder(w).Encode(r) 71 | }, 72 | } 73 | HTTPTransport := NewHTTPTransport(cfg). 74 | Endpoint("GET", "/test", func(ctx context.Context, r interface{}) (interface{}, error) { 75 | return "default response", nil 76 | }) 77 | err := HTTPTransport.RegisterEndpoints(func(e endpoint.Endpoint) endpoint.Endpoint { 78 | return e 79 | }) 80 | if err != nil { 81 | t.Errorf("error occurred: %+v", err) 82 | } 83 | rec := httptest.NewRecorder() 84 | HTTPTransport.ServeHTTP(rec, &http.Request{ 85 | Method: "GET", 86 | RequestURI: "/test", 87 | URL: &url.URL{ 88 | Path: "/test", 89 | }, 90 | }) 91 | if rec.Code != 501 { 92 | t.Errorf("default HTTP response status expected: %d", rec.Code) 93 | } 94 | body := rec.Body.String() 95 | if strings.TrimSpace(body) != `response:"default response"` { 96 | t.Errorf("different body expected: %s", body) 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /log.go: -------------------------------------------------------------------------------- 1 | package kitty 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/go-kit/kit/endpoint" 7 | "github.com/go-kit/kit/log" 8 | ) 9 | 10 | // nopLogger is the default logger and does nothing. 11 | type nopLogger struct{} 12 | 13 | func (l *nopLogger) Log(keyvals ...interface{}) error { return nil } 14 | 15 | // Logger sets the logger. 16 | func (s *Server) Logger(l log.Logger) *Server { 17 | s.logger = l 18 | return s 19 | } 20 | 21 | // LogContext defines the list of keys to add to all log lines. 22 | // Keys may vary depending on transport. 23 | // Available keys for the http transport are : http-method, http-uri, http-path, http-proto, http-requesthost, 24 | // http-remote-addr, http-x-forwarded-for, http-x-forwarded-proto, http-user-agent and http-x-request-id. 25 | func (s *Server) LogContext(keys ...string) *Server { 26 | s.logkeys = keys 27 | return s 28 | } 29 | 30 | func (s *Server) addLoggerToContext(ctx context.Context, keys map[string]interface{}) context.Context { 31 | l := s.logger 32 | if keys != nil { 33 | for _, k := range s.logkeys { 34 | if val, ok := ctx.Value(keys[k]).(string); ok && val != "" { 35 | l = log.With(l, k, val) 36 | } 37 | } 38 | } 39 | return context.WithValue(ctx, logKey, l) 40 | } 41 | 42 | func (s *Server) addLoggerToContextMiddleware(m endpoint.Middleware, t Transport) endpoint.Middleware { 43 | return func(e endpoint.Endpoint) endpoint.Endpoint { 44 | e = m(e) 45 | return func(ctx context.Context, request interface{}) (response interface{}, err error) { 46 | return e(s.addLoggerToContext(ctx, t.LogKeys()), request) 47 | } 48 | } 49 | } 50 | 51 | // Logger will return the logger that has been injected into the context by the kitty 52 | // server. This function can only be called from an endpoint. 53 | func Logger(ctx context.Context) log.Logger { 54 | return ctx.Value(logKey).(log.Logger) 55 | } 56 | 57 | // LogMessage will log a message. 58 | // This function can only be called from an endpoint. 59 | func LogMessage(ctx context.Context, msg string, keyvals ...interface{}) error { 60 | l := Logger(ctx) 61 | keyvals = append(keyvals, "msg", msg) 62 | return l.Log(keyvals...) 63 | } 64 | -------------------------------------------------------------------------------- /middleware.go: -------------------------------------------------------------------------------- 1 | package kitty 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/go-kit/kit/endpoint" 10 | kithttp "github.com/go-kit/kit/transport/http" 11 | ) 12 | 13 | // nopMiddleware is the default middleware, and does nothing. 14 | func nopMiddleware(e endpoint.Endpoint) endpoint.Endpoint { 15 | return e 16 | } 17 | 18 | // Middlewares defines the list of endpoint middlewares to be added to all endpoints. 19 | func (s *Server) Middlewares(m ...endpoint.Middleware) *Server { 20 | s.middleware = func(next endpoint.Endpoint) endpoint.Endpoint { 21 | for i := len(m) - 1; i >= 0; i-- { 22 | next = m[i](next) 23 | } 24 | return next 25 | } 26 | return s 27 | } 28 | 29 | // LogOption is a LogEndpoint middleware option. 30 | type LogOption int 31 | 32 | const ( 33 | // LogRequest logs the request. 34 | LogRequest LogOption = iota 35 | // LogResponse logs the response. 36 | LogResponse 37 | // LogErrors logs the request in case of an error. 38 | LogErrors 39 | ) 40 | 41 | // LogEndpoint creates a middleware that logs Endpoint calls. 42 | // If LogRequest is specified, the endpoint request will be logged before the endpoint is called. 43 | // If LogResponse is specified, the endpoint response will be logged after. 44 | // If LogErrors is specified, the endpoint request will be logged if the endpoint returns an error. 45 | // With LogResponse and LogErrors, the endpoint duration and result HTTP status code will be added to logs. 46 | func LogEndpoint(fields ...LogOption) endpoint.Middleware { 47 | opts := map[LogOption]bool{} 48 | for _, f := range fields { 49 | opts[f] = true 50 | } 51 | return func(e endpoint.Endpoint) endpoint.Endpoint { 52 | return func(ctx context.Context, request interface{}) (response interface{}, err error) { 53 | if opts[LogRequest] { 54 | _ = LogMessage(ctx, fmt.Sprintf("request: %+v", request)) 55 | } 56 | start := time.Now() 57 | response, err = e(ctx, request) 58 | code := http.StatusOK 59 | if err != nil { 60 | code = http.StatusInternalServerError 61 | if sc, ok := err.(kithttp.StatusCoder); ok { 62 | code = sc.StatusCode() 63 | } 64 | } 65 | switch { 66 | case opts[LogResponse]: 67 | _ = LogMessage(ctx, fmt.Sprintf("response: %+v", response), "status", code, "duration", time.Since(start)) 68 | case opts[LogErrors] && err != nil: 69 | _ = LogMessage(ctx, fmt.Sprintf("request: %+v", request), "error", err, "status", code, "duration", time.Since(start)) 70 | default: 71 | return 72 | } 73 | return 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /middleware_test.go: -------------------------------------------------------------------------------- 1 | package kitty 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "strings" 10 | "testing" 11 | 12 | "github.com/go-kit/kit/log" 13 | ) 14 | 15 | func newTestLogger(w io.Writer) log.Logger { 16 | return &testLogger{w} 17 | } 18 | 19 | type testLogger struct{ w io.Writer } 20 | 21 | func (l *testLogger) Log(keyvals ...interface{}) error { 22 | for i := 0; i < len(keyvals); i++ { 23 | if keyvals[i] == "duration" { 24 | i++ 25 | continue 26 | } 27 | io.WriteString(l.w, fmt.Sprintf("%v,", keyvals[i])) 28 | } 29 | return nil 30 | } 31 | 32 | func TestLogEndpoint(t *testing.T) { 33 | tcs := []struct { 34 | opt LogOption 35 | response interface{} 36 | err error 37 | log string 38 | }{ 39 | { 40 | opt: LogRequest, 41 | response: "foo", 42 | log: `msg,request: bar,`, 43 | }, 44 | { 45 | opt: LogResponse, 46 | response: "foo", 47 | log: `status,200,msg,response: foo,`, 48 | }, 49 | { 50 | opt: LogErrors, 51 | response: "foo", 52 | log: ``, 53 | }, 54 | { 55 | opt: LogErrors, 56 | err: errors.New("bar"), 57 | log: `error,bar,status,500,msg,request: bar,`, 58 | }, 59 | } 60 | for _, tc := range tcs { 61 | buf := bytes.NewBuffer([]byte{}) 62 | ctx := context.WithValue(context.TODO(), logKey, newTestLogger(buf)) 63 | e := func(_ context.Context, _ interface{}) (interface{}, error) { 64 | return tc.response, tc.err 65 | } 66 | LogEndpoint(tc.opt)(e)(ctx, "bar") 67 | logged := strings.TrimSpace(buf.String()) 68 | if logged != tc.log { 69 | t.Errorf("Invalid log `%s` should have been `%s`", logged, tc.log) 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /options.go: -------------------------------------------------------------------------------- 1 | package kitty 2 | 3 | import kithttp "github.com/go-kit/kit/transport/http" 4 | 5 | // Options defines the list of go-kit http.ServerOption to be added to all endpoints. 6 | func (t *HTTPTransport) Options(opts ...kithttp.ServerOption) *HTTPTransport { 7 | t.opts = opts 8 | return t 9 | } 10 | -------------------------------------------------------------------------------- /router.go: -------------------------------------------------------------------------------- 1 | package kitty 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | // Router is an interface for router implementations. 8 | type Router interface { 9 | // Handle registers a handler to the router. 10 | Handle(method string, path string, handler http.Handler) 11 | // SetNotFoundHandler will sets the NotFound handler. 12 | SetNotFoundHandler(handler http.Handler) 13 | // ServeHTTP implements http.Handler. 14 | ServeHTTP(w http.ResponseWriter, r *http.Request) 15 | } 16 | 17 | // RouterOption sets optional Router options. 18 | type RouterOption func(Router) Router 19 | 20 | // Router defines the router to use in a server. 21 | func (t *HTTPTransport) Router(r Router, opts ...RouterOption) *HTTPTransport { 22 | for _, opt := range opts { 23 | r = opt(r) 24 | } 25 | t.mux = r 26 | return t 27 | } 28 | 29 | // StdlibRouter returns a Router based on the stdlib http package. 30 | func StdlibRouter() Router { 31 | return &stdlibRouter{mux: http.NewServeMux()} 32 | } 33 | 34 | // NotFoundHandler will set the not found handler of the router. 35 | func NotFoundHandler(h http.Handler) RouterOption { 36 | return func(r Router) Router { 37 | r.SetNotFoundHandler(h) 38 | return r 39 | } 40 | } 41 | 42 | var _ Router = &stdlibRouter{} 43 | 44 | // StdlibRouter is a Router implementation based on the stdlib http package. 45 | type stdlibRouter struct { 46 | mux *http.ServeMux 47 | } 48 | 49 | // Handle registers a handler to the router. 50 | func (g *stdlibRouter) Handle(method, path string, h http.Handler) { 51 | g.mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { 52 | if r.Method == method { 53 | h.ServeHTTP(w, r) 54 | return 55 | } 56 | http.NotFound(w, r) 57 | }) 58 | } 59 | 60 | // SetNotFoundHandler will do nothing as we cannot override the Not Found handler from the stdlib. 61 | func (g *stdlibRouter) SetNotFoundHandler(h http.Handler) { 62 | } 63 | 64 | // ServeHTTP dispatches the handler registered in the matched route. 65 | func (g *stdlibRouter) ServeHTTP(w http.ResponseWriter, r *http.Request) { 66 | g.mux.ServeHTTP(w, r) 67 | } 68 | -------------------------------------------------------------------------------- /server.go: -------------------------------------------------------------------------------- 1 | package kitty 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "os/signal" 8 | "syscall" 9 | 10 | "github.com/go-kit/kit/endpoint" 11 | "github.com/go-kit/kit/log" 12 | ) 13 | 14 | // Server defines a kitty server. 15 | type Server struct { 16 | middleware endpoint.Middleware 17 | shutdown []func() 18 | 19 | logkeys []string 20 | logger log.Logger 21 | 22 | transports []Transport 23 | } 24 | 25 | type contextKey int 26 | 27 | const ( 28 | // context key for logger 29 | logKey contextKey = iota 30 | ) 31 | 32 | // NewServer creates a kitty server. 33 | func NewServer(t ...Transport) *Server { 34 | return &Server{ 35 | transports: t, 36 | logger: &nopLogger{}, 37 | middleware: nopMiddleware, 38 | } 39 | } 40 | 41 | // Run starts the server. 42 | func (s *Server) Run(ctx context.Context) error { 43 | ctx, cancel := context.WithCancel(ctx) 44 | defer cancel() 45 | stop := make(chan error) 46 | exit := make(chan error) 47 | ctx = s.addLoggerToContext(ctx, nil) 48 | for _, t := range s.transports { 49 | m := s.addLoggerToContextMiddleware(s.middleware, t) 50 | if err := t.RegisterEndpoints(m); err != nil { 51 | return err 52 | } 53 | } 54 | for _, t := range s.transports { 55 | go func(t Transport) { 56 | if err := t.Start(ctx); err != nil { 57 | _ = s.logger.Log("msg", fmt.Sprintf("Shutting down due to server error: %s", err)) 58 | stop <- err 59 | } 60 | }(t) 61 | } 62 | 63 | go func() { 64 | err := <-stop 65 | for _, fn := range s.shutdown { 66 | fn() 67 | } 68 | for _, t := range s.transports { 69 | if eerr := t.Shutdown(ctx); eerr != nil && err == nil { 70 | err = eerr 71 | } 72 | } 73 | exit <- err 74 | }() 75 | 76 | ch := make(chan os.Signal, 1) 77 | signal.Notify(ch, syscall.SIGTERM, syscall.SIGINT) 78 | select { 79 | case sig := <-ch: 80 | _ = s.logger.Log("msg", "received signal", "signal", sig) 81 | case <-ctx.Done(): 82 | _ = s.logger.Log("msg", "canceled context") 83 | case err := <-exit: 84 | return err 85 | } 86 | stop <- nil 87 | return <-exit 88 | } 89 | -------------------------------------------------------------------------------- /server_test.go: -------------------------------------------------------------------------------- 1 | package kitty 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "errors" 8 | "net/http" 9 | "reflect" 10 | "testing" 11 | "time" 12 | 13 | "github.com/go-kit/kit/endpoint" 14 | ) 15 | 16 | func TestServer(t *testing.T) { 17 | shutdownCalled := false 18 | ctx, cancel := context.WithCancel(context.TODO()) 19 | exitError := make(chan error) 20 | tr := NewHTTPTransport(DefaultConfig). 21 | Endpoint("POST", "/foo", testEP, Decoder(goodDecoder)). 22 | Endpoint("GET", "/decoding_error", testEP, Decoder(badDecoder)) 23 | srv := NewServer(tr).Shutdown(func() { 24 | shutdownCalled = true 25 | }) 26 | go func() { 27 | exitError <- srv.Run(ctx) 28 | }() 29 | 30 | start := time.Now() 31 | for { 32 | resp, err := http.Get("http://localhost:8080/alivez") 33 | if err == nil && resp.StatusCode == http.StatusOK { 34 | break 35 | } 36 | if time.Since(start) > 500*time.Millisecond { 37 | t.Fatal("server did not start within 500msec or liveness returned an error") 38 | } 39 | time.Sleep(50 * time.Millisecond) 40 | } 41 | 42 | { 43 | resp, err := http.Get("http://localhost:8080/readyz") 44 | if err != nil || resp.StatusCode != http.StatusOK { 45 | t.Error("readyness returned an error") 46 | } 47 | } 48 | 49 | { 50 | resp, err := http.Post("http://localhost:8080/foo", "application/json", bytes.NewBufferString(`{"foo":"bar"}`)) 51 | if err != nil { 52 | t.Errorf("http.Get returned an error : %s", err) 53 | } else { 54 | if resp.StatusCode != 200 { 55 | t.Errorf("receive a %d status instead of 200", resp.StatusCode) 56 | } 57 | resData := testStruct{} 58 | err := json.NewDecoder(resp.Body).Decode(&resData) 59 | resp.Body.Close() 60 | if err != nil { 61 | t.Errorf("json.Decode returned an error : %s", err) 62 | } else if !reflect.DeepEqual(resData, testStruct{Foo: "bar"}) { 63 | t.Errorf("http.Get returned invalid data : %+v", resData) 64 | } 65 | } 66 | } 67 | 68 | { 69 | resp, err := http.Post("http://localhost:8080/foo", "application/json", bytes.NewBufferString(`{"status":404}`)) 70 | if err != nil { 71 | t.Errorf("http.Get returned an error : %s", err) 72 | } else { 73 | if resp.StatusCode != 404 { 74 | t.Errorf("receive a %d status instead of 404", resp.StatusCode) 75 | } 76 | } 77 | } 78 | 79 | { 80 | resp, err := http.Get("http://localhost:8080/decoding_error") 81 | if err != nil { 82 | t.Errorf("http.Get returned an error : %s", err) 83 | } else { 84 | resp.Body.Close() 85 | if resp.StatusCode != http.StatusBadRequest { 86 | t.Errorf("A decoding error should return a BadRequest status, not %d", resp.StatusCode) 87 | } 88 | } 89 | } 90 | 91 | cancel() 92 | select { 93 | case <-time.After(time.Second): 94 | t.Error("Server.Run has not stopped after 1sec") 95 | case err := <-exitError: 96 | if err != nil && err != context.Canceled { 97 | t.Errorf("Server.Run returned an error : %s", err) 98 | } 99 | } 100 | if !shutdownCalled { 101 | t.Error("Shutdown functions are not called") 102 | } 103 | } 104 | 105 | type testStruct struct { 106 | Foo string `json:"foo"` 107 | Status int `json:"status"` 108 | 109 | err error 110 | } 111 | 112 | func testEP(_ context.Context, req interface{}) (interface{}, error) { 113 | if r, ok := req.(*testStruct); ok && r.Status != 0 { 114 | return nil, httpError(r.Status) 115 | } 116 | return req, nil 117 | } 118 | 119 | func goodDecoder(_ context.Context, r *http.Request) (interface{}, error) { 120 | request := &testStruct{} 121 | err := json.NewDecoder(r.Body).Decode(request) 122 | return request, err 123 | } 124 | 125 | func badDecoder(_ context.Context, _ *http.Request) (interface{}, error) { 126 | return nil, errors.New("decoding error") 127 | } 128 | 129 | type failingTransport struct { 130 | Transport 131 | } 132 | 133 | func (*failingTransport) RegisterEndpoints(m endpoint.Middleware) error { return nil } 134 | func (*failingTransport) Start(ctx context.Context) error { return errors.New("unable to start") } 135 | func (*failingTransport) Shutdown(ctx context.Context) error { return nil } 136 | 137 | type workingTransport struct { 138 | running chan struct{} 139 | Transport 140 | } 141 | 142 | func (*workingTransport) RegisterEndpoints(m endpoint.Middleware) error { return nil } 143 | func (t *workingTransport) Start(ctx context.Context) error { 144 | defer func() { close(t.running) }() 145 | <-ctx.Done() 146 | return nil 147 | } 148 | func (*workingTransport) Shutdown(ctx context.Context) error { return nil } 149 | 150 | func TestStartError(t *testing.T) { 151 | ctx, cancel := context.WithTimeout(context.TODO(), 5*time.Second) 152 | defer cancel() 153 | exitError := make(chan error) 154 | wt := &workingTransport{running: make(chan struct{})} 155 | srv := NewServer(&failingTransport{}, wt) 156 | go func() { 157 | exitError <- srv.Run(ctx) 158 | }() 159 | 160 | select { 161 | case <-ctx.Done(): 162 | t.Error("Server.Run has not stopped after 5sec") 163 | case err := <-exitError: 164 | if err == nil || err.Error() != "unable to start" { 165 | t.Errorf("Server.Run returned an invalid error : %v", err) 166 | } 167 | } 168 | select { 169 | case <-ctx.Done(): 170 | t.Error("Alternate transport has not stopped after 5sec") 171 | case <-wt.running: 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /shutdown.go: -------------------------------------------------------------------------------- 1 | package kitty 2 | 3 | // Shutdown registers functions to be called when the server is stopped. 4 | func (s *Server) Shutdown(fns ...func()) *Server { 5 | s.shutdown = fns 6 | return s 7 | } 8 | -------------------------------------------------------------------------------- /transport.go: -------------------------------------------------------------------------------- 1 | package kitty 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/go-kit/kit/endpoint" 7 | ) 8 | 9 | // Transport is the interface all transports must implement. 10 | type Transport interface { 11 | // RegisterEndpoints registers all endpoints. Endpoints needs to be wrapped with the specified middleware. 12 | RegisterEndpoints(m endpoint.Middleware) error 13 | // LogKeys returns the list of name key (as configured in Server.LogContext) to context key (as set in context.WithValue) mappings 14 | // for logging. Transports are responsible for injecting the corresponding values in the context. 15 | LogKeys() map[string]interface{} 16 | // Start starts the transport. 17 | Start(ctx context.Context) error 18 | // Shutdown shutdowns the transport. 19 | Shutdown(ctx context.Context) error 20 | } 21 | --------------------------------------------------------------------------------