Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Health middleware #23

Merged
merged 3 commits into from
Sep 5, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@ jobs:
runs-on: ubuntu-latest

steps:
- name: set up go 1.16
- name: set up go 1.17
uses: actions/setup-go@v2
with:
go-version: 1.16
go-version: 1.17
id: go

- name: checkout
Expand All @@ -32,7 +32,7 @@ jobs:

- name: install golangci-lint and goveralls
run: |
curl -sfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh| sh -s -- -b $GITHUB_WORKSPACE v1.39.0
curl -sfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh| sh -s -- -b $GITHUB_WORKSPACE v1.49.0
GO111MODULE=off go get -u -v github.com/mattn/goveralls

- name: run linters
Expand Down
5 changes: 1 addition & 4 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,17 +31,14 @@ linters:
- govet
- unconvert
- megacheck
- structcheck
- gas
- gocyclo
- dupl
- misspell
- unparam
- varcheck
- deadcode
- unused
- typecheck
- ineffassign
- varcheck
- stylecheck
- gochecknoinits
- exportloopref
Expand Down
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,45 @@ Org: Umputun
pong
```

### Health middleware

Responds with the status 200 if all health checks passed, 503 if any failed. Both health path and check functions passed by consumer.
For production usage this middleware should be used with throttler/limiter and, optionally, with some auth middlewares

Example of usage:

```go
check1 := func(ctx context.Context) (name string, err error) {
// do some check, for example check DB connection
return "check1", nil // all good, passed
}
check2 := func(ctx context.Context) (name string, err error) {
// do some other check, for example ping an external service
return "check2", errors.New("some error") // check failed
}

router := chi.NewRouter()
router.Use(rest.Health("/health", check1, check2))
```

example of the actual call and response:

```
> http GET https://example.com/health

HTTP/1.1 503 Service Unavailable
Date: Sun, 15 Jul 2018 19:40:31 GMT
Content-Type: application/json; charset=utf-8
Content-Length: 36

[
{"name":"check1","status":"ok"},
{"name":"check2","status":"failed","error":"some error"}
]
```

_this middleware is pretty basic, but can be used for simple health checks. For more complex cases, like async/cached health checks see [alexliesenfeld/health](https://github.com/alexliesenfeld/health)_

### Logger middleware

Logs request, request handling time and response. Log record fields in order of occurrence:
Expand Down
9 changes: 6 additions & 3 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
module github.com/go-pkgz/rest

go 1.16
go 1.17

require github.com/stretchr/testify v1.8.0

require (
github.com/pkg/errors v0.9.1
github.com/stretchr/testify v1.7.0
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
14 changes: 8 additions & 6 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
42 changes: 42 additions & 0 deletions middleware.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package rest

import (
"context"
"net/http"
"os"
"runtime/debug"
Expand Down Expand Up @@ -54,6 +55,47 @@ func Ping(next http.Handler) http.Handler {
return http.HandlerFunc(fn)
}

// Health middleware response with health info and status (200 if healthy). Stops chain if health request detected
// passed checkers implements custom health checks and returns error if health check failed. The check has to return name
// regardless to the error state.
// For production usage this middleware should be used with throttler and, optionally, with BasicAuth middlewares
func Health(path string, checkers ...func(ctx context.Context) (name string, err error)) func(http.Handler) http.Handler {

type hr struct {
Name string `json:"name"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
}

return func(h http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || !strings.EqualFold(r.URL.Path, path) {
h.ServeHTTP(w, r) // not the health check request, continue the chain
return
}
resp := []hr{}
var anyError bool
for _, check := range checkers {
name, err := check(r.Context())
hh := hr{Name: name, Status: "ok"}
if err != nil {
hh.Status = "failed"
hh.Error = err.Error()
anyError = true
}
resp = append(resp, hh)
}
if anyError {
w.WriteHeader(http.StatusServiceUnavailable)
} else {
w.WriteHeader(http.StatusOK)
}
RenderJSON(w, resp)
}
return http.HandlerFunc(fn)
}
}

// Recoverer is a middleware that recovers from panics, logs the panic and returns a HTTP 500 status if possible.
func Recoverer(l logger.Backend) func(http.Handler) http.Handler {
return func(h http.Handler) http.Handler {
Expand Down
63 changes: 62 additions & 1 deletion middleware_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package rest

import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
Expand All @@ -11,9 +13,10 @@ import (
"testing"
"time"

"github.com/go-pkgz/rest/realip"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/go-pkgz/rest/realip"
)

func TestMiddleware_AppInfo(t *testing.T) {
Expand Down Expand Up @@ -195,6 +198,64 @@ func TestRealIP(t *testing.T) {
require.NoError(t, err)
}

func TestHealthPassed(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, err := w.Write([]byte("blah blah"))
require.NoError(t, err)
})

check1 := func(ctx context.Context) (string, error) {
return "check1", nil
}
check2 := func(ctx context.Context) (string, error) {
return "check2", nil
}

ts := httptest.NewServer(Health("/health", check1, check2)(handler))
defer ts.Close()

resp, err := http.Get(ts.URL + "/health")
require.Nil(t, err)
assert.Equal(t, 200, resp.StatusCode)
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
assert.NoError(t, err)
assert.Equal(t, `[{"name":"check1","status":"ok"},{"name":"check2","status":"ok"}]`+"\n", string(b))

resp, err = http.Get(ts.URL + "/blah")
require.Nil(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
defer resp.Body.Close()
b, err = io.ReadAll(resp.Body)
assert.NoError(t, err)
assert.Equal(t, "blah blah", string(b))
}

func TestHealthFailed(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, err := w.Write([]byte("blah blah"))
require.NoError(t, err)
})

check1 := func(ctx context.Context) (string, error) {
return "check1", nil
}
check2 := func(ctx context.Context) (string, error) {
return "check2", errors.New("some error")
}

ts := httptest.NewServer(Health("/health", check1, check2)(handler))
defer ts.Close()

resp, err := http.Get(ts.URL + "/health")
require.Nil(t, err)
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
assert.NoError(t, err)
assert.Equal(t, `[{"name":"check1","status":"ok"},{"name":"check2","status":"failed","error":"some error"}]`+"\n", string(b))
}

type mockLgr struct {
buf bytes.Buffer
}
Expand Down
13 changes: 6 additions & 7 deletions rest.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,9 @@ package rest
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"time"

"github.com/pkg/errors"
)

// JSON is a map alias, just for convenience
Expand All @@ -30,7 +29,7 @@ func RenderJSON(w http.ResponseWriter, data interface{}) {
func RenderJSONFromBytes(w http.ResponseWriter, r *http.Request, data []byte) error {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
if _, err := w.Write(data); err != nil {
return errors.Wrapf(err, "failed to send response to %s", r.RemoteAddr)
return fmt.Errorf("failed to send response to %s: %w", r.RemoteAddr, err)
}
return nil
}
Expand All @@ -43,7 +42,7 @@ func RenderJSONWithHTML(w http.ResponseWriter, r *http.Request, v interface{}) e
enc := json.NewEncoder(buf)
enc.SetEscapeHTML(false)
if err := enc.Encode(v); err != nil {
return nil, errors.Wrap(err, "json encoding failed")
return nil, fmt.Errorf("json encoding failed: %w", err)
}
return buf.Bytes(), nil
}
Expand Down Expand Up @@ -86,15 +85,15 @@ func ParseFromTo(r *http.Request) (from, to time.Time, err error) {
return t, nil
}
}
return time.Time{}, errors.Errorf("can't parse date %q", ts)
return time.Time{}, fmt.Errorf("can't parse date %q", ts)
}

if from, err = parseTimeStamp(r.URL.Query().Get("from")); err != nil {
return from, to, errors.Wrap(err, "incorrect from time")
return from, to, fmt.Errorf("incorrect from time: %w", err)
}

if to, err = parseTimeStamp(r.URL.Query().Get("to")); err != nil {
return from, to, errors.Wrap(err, "incorrect to time")
return from, to, fmt.Errorf("incorrect to time: %w", err)
}
return from, to, nil
}
2 changes: 1 addition & 1 deletion rest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@ package rest

import (
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"

"github.com/pkg/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
Expand Down