From 1b94bc367c6773976eb7456796fb170de311b325 Mon Sep 17 00:00:00 2001 From: Paramtamtam <7326800+tarampampam@users.noreply.github.com> Date: Sun, 23 Jun 2024 16:12:06 +0400 Subject: [PATCH] =?UTF-8?q?wip:=20=F0=9F=94=95=20temporary=20commit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 1 - Dockerfile | 19 +- docker-compose.yml | 2 +- internal/cli/app.go | 5 +- internal/http/handlers/live/handler.go | 19 +- internal/http/handlers/live/handler_test.go | 53 ++++- internal/http/handlers/version/handler.go | 15 +- .../http/handlers/version/handler_test.go | 52 ++++- internal/http/server.go | 70 ++++-- internal/http/server_test.go | 221 ++++++++++++++++++ 10 files changed, 393 insertions(+), 64 deletions(-) create mode 100644 internal/http/server_test.go diff --git a/.dockerignore b/.dockerignore index d3cdf73..eaeb446 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,5 +5,4 @@ !/cmd !/internal !/templates -!/error-pages.yml !/go.* diff --git a/Dockerfile b/Dockerfile index 112e3ef..108585c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -48,22 +48,19 @@ WORKDIR /tmp/rootfs # prepare rootfs for runtime RUN --mount=type=bind,source=.,target=/src set -x \ - && mkdir -p ./etc ./bin ./opt/html \ + && mkdir -p ./etc ./bin \ && echo 'appuser:x:10001:10001::/nonexistent:/sbin/nologin' > ./etc/passwd \ - && echo 'appuser:x:10001:' > ./etc/group \ - && cp -rv /src/templates ./opt/templates \ - && rm -v ./opt/templates/*.md \ - && cp -rv /src/error-pages.yml ./opt/error-pages.yml + && echo 'appuser:x:10001:' > ./etc/group # take the binary from the compile stage COPY --from=compile /tmp/error-pages ./bin/error-pages WORKDIR /tmp/rootfs/opt -# generate static error pages (for usage inside another docker images, for example) -RUN set -x \ - && ./../bin/error-pages --verbose build --config-file ./error-pages.yml --index ./html \ - && ls -l ./html +## generate static error pages (for usage inside another docker images, for example) +#RUN set -x \ +# && ./../bin/error-pages --verbose build --config-file ./error-pages.yml --index ./html \ +# && ls -l ./html # -✂- and this is the final stage (an empty filesystem is used) ------------------------------------------------------- FROM scratch AS runtime @@ -98,9 +95,9 @@ WORKDIR /opt # docs: https://docs.docker.com/reference/dockerfile/#healthcheck HEALTHCHECK --interval=10s --start-interval=1s --start-period=5s --timeout=2s CMD [\ - "/bin/error-pages", "--log-json", "healthcheck" \ + "/bin/error-pages", "--log-format", "json", "healthcheck" \ ] ENTRYPOINT ["/bin/error-pages"] -CMD ["--log-json", "serve"] +CMD ["--log-format", "json", "serve"] diff --git a/docker-compose.yml b/docker-compose.yml index 9b57013..dede363 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,7 +10,7 @@ services: web: build: {target: runtime} ports: ['8080:8080/tcp'] # open http://127.0.0.1:8080 - command: serve --show-details --proxy-headers=X-Foo,Bar,Baz_blah --catch-all + command: --log-level debug serve --show-details --proxy-headers=X-Foo,Bar,Baz_blah develop: # available since docker compose v2.22, https://docs.docker.com/compose/file-watch/ watch: [{action: rebuild, path: .}] security_opt: [no-new-privileges:true] diff --git a/internal/cli/app.go b/internal/cli/app.go index 3ae50e5..7168e2b 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -9,7 +9,6 @@ import ( _ "github.com/urfave/cli-docs/v3" // required for `go generate` to work "github.com/urfave/cli/v3" - "gh.tarampamp.am/error-pages/internal-old/env" "gh.tarampamp.am/error-pages/internal/appmeta" "gh.tarampamp.am/error-pages/internal/cli/healthcheck" "gh.tarampamp.am/error-pages/internal/cli/serve" @@ -25,7 +24,7 @@ func NewApp(appName string) *cli.Command { //nolint:funlen Name: "log-level", Value: logger.InfoLevel.String(), Usage: "logging level (" + strings.Join(logger.LevelStrings(), "/") + ")", - Sources: cli.EnvVars(env.LogLevel.String()), + Sources: cli.EnvVars("LOG_LEVEL"), OnlyOnce: true, Config: cli.StringConfig{TrimSpace: true}, Validator: func(s string) error { @@ -41,7 +40,7 @@ func NewApp(appName string) *cli.Command { //nolint:funlen Name: "log-format", Value: logger.ConsoleFormat.String(), Usage: "logging format (" + strings.Join(logger.FormatStrings(), "/") + ")", - Sources: cli.EnvVars(env.LogFormat.String()), + Sources: cli.EnvVars("LOG_FORMAT"), OnlyOnce: true, Config: cli.StringConfig{TrimSpace: true}, Validator: func(s string) error { diff --git a/internal/http/handlers/live/handler.go b/internal/http/handlers/live/handler.go index 14852ce..acf4164 100644 --- a/internal/http/handlers/live/handler.go +++ b/internal/http/handlers/live/handler.go @@ -4,13 +4,22 @@ import ( "net/http" ) -// New creates a new handler that always returns "OK" with status code 200. +// New creates a new handler that returns "OK" for GET and HEAD requests. func New() http.Handler { - var body = []byte("OK") + var body = []byte("OK\n") return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/plain; charset=utf-8") - w.WriteHeader(http.StatusOK) - _, _ = w.Write(body) + switch r.Method { + case http.MethodGet: + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(body) + + case http.MethodHead: + w.WriteHeader(http.StatusOK) + + default: + http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) + } }) } diff --git a/internal/http/handlers/live/handler_test.go b/internal/http/handlers/live/handler_test.go index 55e64f4..ce04b06 100644 --- a/internal/http/handlers/live/handler_test.go +++ b/internal/http/handlers/live/handler_test.go @@ -13,14 +13,51 @@ import ( func TestServeHTTP(t *testing.T) { t.Parallel() - var ( - req = httptest.NewRequest(http.MethodGet, "http://testing", http.NoBody) - rr = httptest.NewRecorder() - ) + var handler = live.New() - live.New().ServeHTTP(rr, req) + t.Run("get", func(t *testing.T) { + var ( + req = httptest.NewRequest(http.MethodGet, "http://testing", http.NoBody) + rr = httptest.NewRecorder() + ) - assert.Equal(t, rr.Header().Get("Content-Type"), "text/plain; charset=utf-8") - assert.Equal(t, rr.Code, http.StatusOK) - assert.Equal(t, rr.Body.String(), "OK") + handler.ServeHTTP(rr, req) + + assert.Equal(t, rr.Header().Get("Content-Type"), "text/plain; charset=utf-8") + assert.Equal(t, rr.Code, http.StatusOK) + assert.Equal(t, "OK\n", rr.Body.String()) + }) + + t.Run("head", func(t *testing.T) { + var ( + req = httptest.NewRequest(http.MethodHead, "http://testing", http.NoBody) + rr = httptest.NewRecorder() + ) + + handler.ServeHTTP(rr, req) + + assert.Equal(t, rr.Code, http.StatusOK) + assert.Empty(t, rr.Header().Get("Content-Type")) + assert.Empty(t, rr.Body.Bytes()) + }) + + t.Run("method not allowed", func(t *testing.T) { + for _, method := range []string{ + http.MethodDelete, + http.MethodPatch, + http.MethodPost, + http.MethodPut, + } { + var ( + req = httptest.NewRequest(method, "http://testing", http.NoBody) + rr = httptest.NewRecorder() + ) + + handler.ServeHTTP(rr, req) + + assert.Equal(t, rr.Header().Get("Content-Type"), "text/plain; charset=utf-8") + assert.Equal(t, rr.Code, http.StatusMethodNotAllowed) + assert.Equal(t, "Method Not Allowed\n", rr.Body.String()) + } + }) } diff --git a/internal/http/handlers/version/handler.go b/internal/http/handlers/version/handler.go index 1a685ed..6e4d92d 100644 --- a/internal/http/handlers/version/handler.go +++ b/internal/http/handlers/version/handler.go @@ -15,8 +15,17 @@ func New(ver string) http.Handler { }) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json; charset=utf-8") - w.WriteHeader(http.StatusOK) - _, _ = w.Write(body) + switch r.Method { + case http.MethodGet: + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(body) + + case http.MethodHead: + w.WriteHeader(http.StatusOK) + + default: + http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) + } }) } diff --git a/internal/http/handlers/version/handler_test.go b/internal/http/handlers/version/handler_test.go index 6fc8420..d8dea46 100644 --- a/internal/http/handlers/version/handler_test.go +++ b/internal/http/handlers/version/handler_test.go @@ -13,13 +13,51 @@ import ( func TestServeHTTP(t *testing.T) { t.Parallel() - var ( - req = httptest.NewRequest(http.MethodGet, "http://testing", http.NoBody) - rr = httptest.NewRecorder() - ) + var handler = version.New("\t\n foo@bar ") - version.New("\t\n foo@bar ").ServeHTTP(rr, req) + t.Run("get", func(t *testing.T) { + var ( + req = httptest.NewRequest(http.MethodGet, "http://testing", http.NoBody) + rr = httptest.NewRecorder() + ) - assert.Equal(t, rr.Code, http.StatusOK) - assert.Equal(t, rr.Body.String(), `{"version":"foo@bar"}`) + handler.ServeHTTP(rr, req) + + assert.Equal(t, rr.Header().Get("Content-Type"), "application/json; charset=utf-8") + assert.Equal(t, rr.Code, http.StatusOK) + assert.Equal(t, rr.Body.String(), `{"version":"foo@bar"}`) + }) + + t.Run("head", func(t *testing.T) { + var ( + req = httptest.NewRequest(http.MethodHead, "http://testing", http.NoBody) + rr = httptest.NewRecorder() + ) + + handler.ServeHTTP(rr, req) + + assert.Equal(t, rr.Code, http.StatusOK) + assert.Empty(t, rr.Header().Get("Content-Type")) + assert.Empty(t, rr.Body.Bytes()) + }) + + t.Run("method not allowed", func(t *testing.T) { + for _, method := range []string{ + http.MethodDelete, + http.MethodPatch, + http.MethodPost, + http.MethodPut, + } { + var ( + req = httptest.NewRequest(method, "http://testing", http.NoBody) + rr = httptest.NewRecorder() + ) + + handler.ServeHTTP(rr, req) + + assert.Equal(t, rr.Header().Get("Content-Type"), "text/plain; charset=utf-8") + assert.Equal(t, rr.Code, http.StatusMethodNotAllowed) + assert.Equal(t, "Method Not Allowed\n", rr.Body.String()) + } + }) } diff --git a/internal/http/server.go b/internal/http/server.go index c1e4173..3ba4c28 100644 --- a/internal/http/server.go +++ b/internal/http/server.go @@ -4,6 +4,7 @@ import ( "context" "net" "net/http" + "regexp" "strconv" "strings" "time" @@ -22,7 +23,6 @@ import ( type Server struct { log *zap.Logger server *http.Server - mux *http.ServeMux } // NewServer creates a new HTTP server. @@ -33,45 +33,65 @@ func NewServer(baseCtx context.Context, log *zap.Logger) Server { maxHeaderBytes = (1 << 20) * 5 //nolint:mnd // 5 MB ) - var ( - mux = http.NewServeMux() - srv = &http.Server{ - Handler: mux, + return Server{ + log: log, + server: &http.Server{ ReadTimeout: readTimeout, WriteTimeout: writeTimeout, ReadHeaderTimeout: readTimeout, MaxHeaderBytes: maxHeaderBytes, ErrorLog: zap.NewStdLog(log), BaseContext: func(net.Listener) context.Context { return baseCtx }, - } - ) - - return Server{log: log, server: srv, mux: mux} + }, + } } // Register server handlers, middlewares, etc. func (s *Server) Register(cfg *config.Config) error { - // register middleware + var ( + liveHandler = live.New() + versionHandler = version.New(appmeta.Version()) + errorPagesHandler = error_page.New() + + errorPageRegex = regexp.MustCompile(`^/(\d{3})(?:\.html|\.htm)?$`) // TODO: rewrite to function + ) + + s.server.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var url, method = r.URL.Path, r.Method + + switch { + // live endpoints + case url == "/health/live" || url == "/health" || url == "/healthz" || url == "/live": + liveHandler.ServeHTTP(w, r) + // version endpoint + case url == "/version": + versionHandler.ServeHTTP(w, r) + // error pages endpoints: + // - / + // - /{code}.html + // - /{code}.htm + // - /{code} + case method == http.MethodGet && (url == "/" || errorPageRegex.MatchString(url)): + errorPagesHandler.ServeHTTP(w, r) + // wrong requests handling + default: + switch { + case method == http.MethodHead: + w.WriteHeader(http.StatusNotFound) + case method == http.MethodGet: + http.NotFound(w, r) + default: + http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) + } + } + }) + + // apply middleware s.server.Handler = logreq.New(s.log, func(r *http.Request) bool { // skip logging healthcheck requests return strings.Contains(strings.ToLower(r.UserAgent()), "healthcheck") })(s.server.Handler) - { // register handlers (https://go.dev/blog/routing-enhancements) - var errorPageHandler = error_page.New() - - s.mux.Handle("/", errorPageHandler) - s.mux.Handle("/{any}", errorPageHandler) - - var liveHandler = live.New() - - s.mux.Handle("GET /health/live", liveHandler) - s.mux.Handle("GET /healthz", liveHandler) - s.mux.Handle("GET /live", liveHandler) - - s.mux.Handle("GET /version", version.New(appmeta.Version())) - } - return nil } diff --git a/internal/http/server_test.go b/internal/http/server_test.go new file mode 100644 index 0000000..4ac2a84 --- /dev/null +++ b/internal/http/server_test.go @@ -0,0 +1,221 @@ +package http_test + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "gh.tarampamp.am/error-pages/internal/config" + appHttp "gh.tarampamp.am/error-pages/internal/http" +) + +func TestRouting(t *testing.T) { + var ( + srv = appHttp.NewServer(context.Background(), zap.NewNop()) + cfg = config.New() + ) + + require.NoError(t, srv.Register(&cfg)) + + var baseUrl, stopServer = startServer(t, &srv) + + defer stopServer() + + t.Run("health", func(t *testing.T) { + var routes = []string{"/health/live", "/health", "/healthz", "/live"} + + t.Run("success (get)", func(t *testing.T) { + for _, route := range routes { + status, body, headers := sendRequest(t, http.MethodGet, baseUrl+route) + + assert.Equal(t, http.StatusOK, status) + assert.NotEmpty(t, body) + assert.Contains(t, headers.Get("Content-Type"), "text/plain") + } + }) + + t.Run("success (head)", func(t *testing.T) { + for _, route := range routes { + status, body, headers := sendRequest(t, http.MethodHead, baseUrl+route) + + assert.Equal(t, http.StatusOK, status) + assert.Empty(t, body) + assert.Empty(t, headers.Get("Content-Type")) + } + }) + + t.Run("method not allowed", func(t *testing.T) { + for _, route := range routes { + var url = baseUrl + route + + for _, method := range []string{http.MethodDelete, http.MethodPatch, http.MethodPost, http.MethodPut} { + status, body, headers := sendRequest(t, method, url) + + assert.Equal(t, http.StatusMethodNotAllowed, status) + assert.NotEmpty(t, body) + assert.Contains(t, headers.Get("Content-Type"), "text/plain") + } + } + }) + }) + + t.Run("version", func(t *testing.T) { + var url = baseUrl + "/version" + + t.Run("success (get)", func(t *testing.T) { + status, body, headers := sendRequest(t, http.MethodGet, url) + + assert.Equal(t, http.StatusOK, status) + assert.NotEmpty(t, body) + assert.Contains(t, headers.Get("Content-Type"), "application/json") + }) + + t.Run("success (head)", func(t *testing.T) { + status, body, headers := sendRequest(t, http.MethodHead, url) + + assert.Equal(t, http.StatusOK, status) + assert.Empty(t, body) + assert.Empty(t, headers.Get("Content-Type")) + }) + + t.Run("method not allowed", func(t *testing.T) { + for _, method := range []string{http.MethodDelete, http.MethodPatch, http.MethodPost, http.MethodPut} { + status, body, headers := sendRequest(t, method, url) + + assert.Equal(t, http.StatusMethodNotAllowed, status) + assert.NotEmpty(t, body) + assert.Contains(t, headers.Get("Content-Type"), "text/plain") + } + }) + }) + + t.Run("error page", func(t *testing.T) { + t.Skip("not implemented") + }) + + t.Run("errors handling", func(t *testing.T) { + var missingRoutes = []string{"/not-found", "/not-found/", "/not-found.html"} + + t.Run("not found (get)", func(t *testing.T) { + for _, path := range missingRoutes { + status, body, headers := sendRequest(t, http.MethodGet, baseUrl+path) + + assert.Equal(t, http.StatusNotFound, status) + assert.NotEmpty(t, body) + assert.Contains(t, headers.Get("Content-Type"), "text/plain") + } + }) + + t.Run("not found (head)", func(t *testing.T) { + for _, path := range missingRoutes { + status, body, headers := sendRequest(t, http.MethodHead, baseUrl+path) + + assert.Equal(t, http.StatusNotFound, status) + assert.Empty(t, body) + assert.Empty(t, headers.Get("Content-Type")) + } + }) + + t.Run("methods not allowed", func(t *testing.T) { + for _, path := range missingRoutes { + for _, method := range []string{http.MethodDelete, http.MethodPatch, http.MethodPost, http.MethodPut} { + status, body, headers := sendRequest(t, method, baseUrl+path) + + assert.Equal(t, http.StatusMethodNotAllowed, status) + assert.NotEmpty(t, body) + assert.Contains(t, headers.Get("Content-Type"), "text/plain") + } + } + }) + }) +} + +// sendRequest is a helper function to send an HTTP request and return its status code, body, and headers. +func sendRequest(t *testing.T, method, url string, headers ...map[string]string) ( + status int, + body []byte, + _ http.Header, +) { + t.Helper() + + req, reqErr := http.NewRequest(method, url, nil) + + require.NoError(t, reqErr) + + if len(headers) > 0 { + for key, value := range headers[0] { + req.Header.Add(key, value) + } + } + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + + body, _ = io.ReadAll(resp.Body) + + require.NoError(t, resp.Body.Close()) + + return resp.StatusCode, body, resp.Header +} + +// startServer is a helper function to start an HTTP server and return its base URL and a stop function. +func startServer(t *testing.T, srv *appHttp.Server) (_ string, stop func()) { + t.Helper() + + var ( + port = getFreeTcpPort(t) + hostPort = fmt.Sprintf("%s:%d", "127.0.0.1", port) + ) + + go func() { + if err := srv.Start("127.0.0.1", port); err != nil && !errors.Is(err, http.ErrServerClosed) { + assert.NoError(t, err) + } + }() + + // wait until the server starts + for { + if conn, err := net.DialTimeout("tcp", hostPort, time.Second); err == nil { + require.NoError(t, conn.Close()) + + break + } + + <-time.After(5 * time.Millisecond) + } + + return fmt.Sprintf("http://%s", hostPort), func() { assert.NoError(t, srv.Stop(10*time.Millisecond)) } +} + +// getFreeTcpPort is a helper function to get a free TCP port number. +func getFreeTcpPort(t *testing.T) uint16 { + t.Helper() + + l, lErr := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, lErr) + + port := l.Addr().(*net.TCPAddr).Port + require.NoError(t, l.Close()) + + // make sure port is closed + for { + conn, err := net.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", port)) + if err != nil { + break + } + + require.NoError(t, conn.Close()) + <-time.After(5 * time.Millisecond) + } + + return uint16(port) +}