From 4b43a10f1c8f4a5d21e6101b1ae4cfd789fa978c Mon Sep 17 00:00:00 2001 From: Fedor Korotkiy Date: Sat, 25 Mar 2023 03:08:06 +0400 Subject: [PATCH] Add middleware task --- go.mod | 2 + go.sum | 4 + middleware/README.md | 9 +++ middleware/auth/README.md | 10 +++ middleware/auth/auth.go | 28 +++++++ middleware/auth/auth_test.go | 99 ++++++++++++++++++++++++ middleware/httpgauge/README.md | 16 ++++ middleware/httpgauge/httpgauge.go | 23 ++++++ middleware/httpgauge/httpgauge_test.go | 62 +++++++++++++++ middleware/requestlog/README.md | 31 ++++++++ middleware/requestlog/requestlog.go | 13 ++++ middleware/requestlog/requestlog_test.go | 85 ++++++++++++++++++++ 12 files changed, 382 insertions(+) create mode 100644 middleware/README.md create mode 100644 middleware/auth/README.md create mode 100644 middleware/auth/auth.go create mode 100644 middleware/auth/auth_test.go create mode 100644 middleware/httpgauge/README.md create mode 100644 middleware/httpgauge/httpgauge.go create mode 100644 middleware/httpgauge/httpgauge_test.go create mode 100644 middleware/requestlog/README.md create mode 100644 middleware/requestlog/requestlog.go create mode 100644 middleware/requestlog/requestlog_test.go diff --git a/go.mod b/go.mod index 81ff9a4..2739203 100644 --- a/go.mod +++ b/go.mod @@ -33,6 +33,8 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/emirpasic/gods v1.12.0 // indirect + github.com/felixge/httpsnoop v1.0.3 // indirect + github.com/go-chi/chi/v5 v5.0.8 // indirect github.com/inconshreveable/mousetrap v1.0.0 // indirect github.com/jackc/chunkreader/v2 v2.0.1 // indirect github.com/jackc/pgconn v1.5.0 // indirect diff --git a/go.sum b/go.sum index e209089..027ca94 100644 --- a/go.sum +++ b/go.sum @@ -58,6 +58,8 @@ github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8 github.com/emirpasic/gods v1.12.0 h1:QAUIPSaCu4G+POclxeqb3F+WPpdKqFGlw36+yOzGlrg= github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk= +github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= @@ -65,6 +67,8 @@ github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4 github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/gliderlabs/ssh v0.2.2 h1:6zsha5zo/TWhRhwqCD3+EarCAgZ2yN28ipRnGPnwkI0= github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0= +github.com/go-chi/chi/v5 v5.0.8 h1:lD+NLqFcAi1ovnVZpsnObHGW4xb4J8lNmoYVfECH1Y0= +github.com/go-chi/chi/v5 v5.0.8/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= diff --git a/middleware/README.md b/middleware/README.md new file mode 100644 index 0000000..c5d7fea --- /dev/null +++ b/middleware/README.md @@ -0,0 +1,9 @@ +# middleware + +В это задаче, вам нужно научиться писать типичные middleware. Задача +состоит из нескольких пакетов, все они независимы между собой. Чтобы +решить задачу, нужно реализовать все пакеты. + + - `auth` - реализует аутентификация + - `requestlog` - логирует запросы + - `httpgauge` - собирает статистики исполнения запросов diff --git a/middleware/auth/README.md b/middleware/auth/README.md new file mode 100644 index 0000000..a3beb6a --- /dev/null +++ b/middleware/auth/README.md @@ -0,0 +1,10 @@ +# auth + +Напишите middleware проверяющую токен. Эта middleware должна проверять, +что в заголовке `Authorization` прислали строку `Bearer TOKEN`, где `TOKEN` - валиден. + + * Если токен не валиден, то middleware должна не пропускать запрос и возвращать `http.StatusUnauthorized`. + * Если проверка токена завершилась ошибкой (тоесть не ясно, валиден он или нет), + то запрос должен завершаться с кодом `StatusInternalServerError`. + * Если токен валиден, то пользователь соответствующий токену, должен быть доступен следующим + хендлерам через метод `ContextUser`. diff --git a/middleware/auth/auth.go b/middleware/auth/auth.go new file mode 100644 index 0000000..445eda8 --- /dev/null +++ b/middleware/auth/auth.go @@ -0,0 +1,28 @@ +//go:build !solution + +package auth + +import ( + "context" + "errors" + "net/http" +) + +type User struct { + Name string + Email string +} + +func ContextUser(ctx context.Context) (*User, bool) { + panic("not implemented") +} + +var ErrInvalidToken = errors.New("invalid token") + +type TokenChecker interface { + CheckToken(ctx context.Context, token string) (*User, error) +} + +func CheckAuth(checker TokenChecker) func(next http.Handler) http.Handler { + panic("not implemented") +} diff --git a/middleware/auth/auth_test.go b/middleware/auth/auth_test.go new file mode 100644 index 0000000..d40e814 --- /dev/null +++ b/middleware/auth/auth_test.go @@ -0,0 +1,99 @@ +package auth_test + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/stretchr/testify/require" + "gitlab.com/slon/shad-go/middleware/auth" +) + +type fakeChecker map[string]struct { + user *auth.User + err error +} + +func (c fakeChecker) CheckToken(ctx context.Context, token string) (*auth.User, error) { + res := c[token] + return res.user, res.err +} + +func TestAuth(t *testing.T) { + m := chi.NewRouter() + + c := fakeChecker{ + "token0": { + user: &auth.User{Name: "Fedor", Email: "dartslon@gmail.com"}, + }, + + "token1": { + err: fmt.Errorf("database offline"), + }, + + "token2": { + err: fmt.Errorf("token expired: %w", auth.ErrInvalidToken), + }, + } + + m.Use(auth.CheckAuth(c)) + + var ( + lastUser *auth.User + lastUserOK bool + called bool + ) + + m.Get("/path/ok", func(w http.ResponseWriter, r *http.Request) { + called = true + lastUser, lastUserOK = auth.ContextUser(r.Context()) + w.WriteHeader(http.StatusOK) + }) + + m.Get("/path/error", func(w http.ResponseWriter, r *http.Request) { + called = true + w.WriteHeader(http.StatusConflict) + }) + + t.Run("NoToken", func(t *testing.T) { + called = false + + w := httptest.NewRecorder() + m.ServeHTTP(w, httptest.NewRequest("GET", "/path/ok", nil)) + require.Equal(t, w.Code, http.StatusUnauthorized) + require.False(t, called) + }) + + t.Run("InvalidToken", func(t *testing.T) { + + }) + + t.Run("DatabaseError", func(t *testing.T) { + + }) + + t.Run("GoodToken", func(t *testing.T) { + called = false + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/path/ok", nil) + r.Header.Add("authorization", "Bearer token0") + + m.ServeHTTP(w, r) + require.Equal(t, w.Code, http.StatusOK) + require.True(t, called) + require.True(t, lastUserOK) + require.Equal(t, lastUser, &auth.User{Name: "Fedor", Email: "dartslon@gmail.com"}) + + called = false + w = httptest.NewRecorder() + r = httptest.NewRequest("GET", "/path/error", nil) + r.Header.Add("authorization", "Bearer token0") + + m.ServeHTTP(w, r) + require.Equal(t, w.Code, http.StatusConflict) + require.True(t, called) + }) +} diff --git a/middleware/httpgauge/README.md b/middleware/httpgauge/README.md new file mode 100644 index 0000000..df86df6 --- /dev/null +++ b/middleware/httpgauge/README.md @@ -0,0 +1,16 @@ +# httpgauge + +Напишите middleware, считающий метрики числа запросов. Обычно, метрики измеряют +с помощью библиотеки [prometheus](https://github.com/prometheus/client_golang). + +Но мы будем писать упрошённую версию, которая будет хранить метрики в `map[string]int`. + +Ключём в такой `map` можно было бы сделать `Path` запроса, но это не очень хорошее решение. +В пути часто встречаются изменяемые параметры. Например `/user/0`, `/user/1`, etc. В этом случае, +мы не хотим отдельно измерять, что пришло 5 запросов к `/user/0` и 10 запросов к `/user/1`. +В этом случае, для мониторинга за сервером нам нужно знать, что пришло 15 запросов к пути `/user/{userID}`. + +Чтобы получить такой паттерн (`/user/{userID}`) вместо полного пути, нужно использовать библиотеку-роутер. +Стандартная библиотека такого не умеет. В этой задаче мы будем использовать библиоетку [chi](https://github.com/go-chi/chi). + +Как получить такой паттерн из библиотеки `chi` - найдите сами) \ No newline at end of file diff --git a/middleware/httpgauge/httpgauge.go b/middleware/httpgauge/httpgauge.go new file mode 100644 index 0000000..25ede7b --- /dev/null +++ b/middleware/httpgauge/httpgauge.go @@ -0,0 +1,23 @@ +//go:build !solution + +package httpgauge + +import "net/http" + +type Gauge struct{} + +func New() *Gauge { + panic("not implemented") +} + +func (g *Gauge) Snapshot() map[string]int { + panic("not implemented") +} + +func (g *Gauge) ServeHTTP(w http.ResponseWriter, r *http.Request) { + panic("not implemented") +} + +func (g *Gauge) Wrap(next http.Handler) http.Handler { + panic("not implemented") +} diff --git a/middleware/httpgauge/httpgauge_test.go b/middleware/httpgauge/httpgauge_test.go new file mode 100644 index 0000000..ebd0bf2 --- /dev/null +++ b/middleware/httpgauge/httpgauge_test.go @@ -0,0 +1,62 @@ +package httpgauge_test + +import ( + "fmt" + "net/http" + "net/http/httptest" + "sync" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/stretchr/testify/require" + "gitlab.com/slon/shad-go/middleware/httpgauge" +) + +func TestMiddleware(t *testing.T) { + g := httpgauge.New() + + m := chi.NewRouter() + m.Use(g.Wrap) + + m.Get("/simple", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + m.Get("/panic", func(w http.ResponseWriter, r *http.Request) { + panic("bug") + }) + m.Get("/user/{userID}", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + m.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/simple", nil)) + m.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/simple", nil)) + + require.Panics(t, func() { + m.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/panic", nil)) + }) + + var wg sync.WaitGroup + for i := 0; i < 10; i++ { + wg.Add(1) + go func() { + defer wg.Done() + + for j := 0; j < 1000; j++ { + m.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", fmt.Sprintf("/user/%d", j), nil)) + } + }() + } + + wg.Wait() + + require.Equal(t, g.Snapshot(), map[string]int{ + "/simple": 2, + "/panic": 1, + "/user/{userID}": 10000, + }) + + w := httptest.NewRecorder() + g.ServeHTTP(w, httptest.NewRequest("GET", "/", nil)) + + require.Equal(t, w.Body.String(), "/panic 1\n/simple 2\n/user/{userID} 10000\n") +} diff --git a/middleware/requestlog/README.md b/middleware/requestlog/README.md new file mode 100644 index 0000000..261b60c --- /dev/null +++ b/middleware/requestlog/README.md @@ -0,0 +1,31 @@ +# requestlog + +Реализуйте middleware логирующую запросы. + +Для логирования вам нужно использовать библиотеку zap. Прочитайте [README](https://github.com/uber-go/zap) +на библиотеку, этого должно быть достаточно для выполнения задания. Вам нужно использовать `structured` +формат логов. + +```go +logger.Info("failed to fetch URL", + // Structured context as strongly typed Field values. + zap.String("url", url), + zap.Int("attempt", 3), + zap.Duration("backoff", time.Second), +) +``` + + * Каждый запрос должен писать в лог две строчки. Одну строчку до начала обработки запроса и одну строчку после + конца обработки запроса. + * Обе строчки должны содержать поля `path` и `method`, соответствующие полям запроса. + * Так же обе строчки должны содержать поле `request_id` с удинальным идентификатором запроса. Как вы сгенерируете + этот идентификатор не важно, главное чтобы он был уникальным и совпадал. Это поле нужно, чтобы по первой строчке + можно было найти вторую и по второй - первую. + * Вторая строчка должна содержать поле `duration` с временем исполнения запроса. + * Вторая строчка должна содержать поле `status_code` со кодом возврата. Чтобы сохранить это поле, + вам нужно написать обёртку над `http.ResponseWriter`. Можете сделать это сами, а можете + воспользоваться библиотекой [httpsnoop](https://github.com/felixge/httpsnoop). + * Сообщение в первой строчке всегда должно быть `request started` + * Сообщение во второй строчке должно быть либо `request finished`, либо `request panicked`. + Если обработчик запроса паниковал, ваша middleware не должна глотать эту панику. Она должна только + логировать, что паника случилась, и пробрасывать панику дальше. \ No newline at end of file diff --git a/middleware/requestlog/requestlog.go b/middleware/requestlog/requestlog.go new file mode 100644 index 0000000..7629cf8 --- /dev/null +++ b/middleware/requestlog/requestlog.go @@ -0,0 +1,13 @@ +//go:build !solution + +package requestlog + +import ( + "net/http" + + "go.uber.org/zap" +) + +func Log(l *zap.Logger) func(next http.Handler) http.Handler { + panic("not implemented") +} diff --git a/middleware/requestlog/requestlog_test.go b/middleware/requestlog/requestlog_test.go new file mode 100644 index 0000000..6afdbac --- /dev/null +++ b/middleware/requestlog/requestlog_test.go @@ -0,0 +1,85 @@ +package requestlog_test + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/go-chi/chi/v5" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + "go.uber.org/zap/zaptest/observer" + + "gitlab.com/slon/shad-go/middleware/requestlog" +) + +func TestRequestLog(t *testing.T) { + core, obs := observer.New(zap.DebugLevel) + + m := chi.NewRouter() + m.Use(requestlog.Log(zap.New(core))) + + m.Get("/simple", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + m.Post("/post", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + m.Get("/forbidden", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + }) + + m.Get("/slow", func(w http.ResponseWriter, r *http.Request) { + time.Sleep(time.Millisecond * 200) + w.WriteHeader(http.StatusOK) + }) + m.Get("/forgetful", func(w http.ResponseWriter, r *http.Request) { + // TODO + }) + m.Get("/buggy", func(w http.ResponseWriter, r *http.Request) { + panic("bug") + }) + + m.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/simple", nil)) + m.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("POST", "/post", nil)) + m.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/forbidden", nil)) + m.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/slow", nil)) + m.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/forgetful", nil)) + + require.Panics(t, func() { + m.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/buggy", nil)) + }) + + checkEntries := func(path string, panic bool, code int) { + entries := obs.FilterField(zap.String("path", path)).All() + + require.Len(t, entries, 2) + require.Equal(t, entries[0].Message, "request started") + require.Contains(t, entries[0].ContextMap(), "request_id") + + var requestID zap.Field + for _, f := range entries[0].Context { + if f.Key == "request_id" { + requestID = f + } + } + + if !panic { + require.Equal(t, entries[1].Message, "request finished") + require.Contains(t, entries[1].Context, zap.Int("status_code", code)) + require.Contains(t, entries[1].ContextMap(), "duration") + require.Contains(t, entries[1].Context, requestID) + } else { + require.Equal(t, entries[1].Message, "request panicked") + require.Contains(t, entries[1].Context, requestID) + } + } + + checkEntries("/simple", false, http.StatusOK) + checkEntries("/post", false, http.StatusOK) + checkEntries("/forbidden", false, http.StatusForbidden) + checkEntries("/slow", false, http.StatusOK) + checkEntries("/forgetful", false, http.StatusInternalServerError) + checkEntries("/buggy", true, http.StatusInternalServerError) +}