From a52dbde00ca533287592bd7546df3e9567ed55d2 Mon Sep 17 00:00:00 2001 From: Paramtamtam <7326800+tarampampam@users.noreply.github.com> Date: Sun, 23 Jun 2024 23:48:51 +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 --- README.md | 30 ++--- internal/cli/serve/command.go | 26 ++-- internal/config/config.go | 20 +-- internal/config/config_test.go | 4 +- internal/http/handlers/error_page/handler.go | 82 +++++++++++- internal/http/server.go | 12 +- internal/http/server_test.go | 129 ++++++++++++++++++- 7 files changed, 251 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 984d25d..c9cde26 100644 --- a/README.md +++ b/README.md @@ -27,21 +27,21 @@ $ error-pages [GLOBAL FLAGS] serve [COMMAND FLAGS] [ARGUMENTS...] The following flags are supported: -| Name | Description | Default value | Environment variables | -|------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:-----------------------------------------:|:----------------------:| -| `--listen="…"` (`-l`) | IP (v4 or v6) address to listen on | `0.0.0.0` | `LISTEN_ADDR` | -| `--port="…"` (`-p`) | TCP port number | `8080` | `LISTEN_PORT` | -| `--add-template="…"` | to add a new template, provide the path to the file using this flag (the filename without the extension will be used as the template name) | `[]` | *none* | -| `--add-http-code="…"` | to add a new HTTP status code, provide the code and its message/description using this flag (the format should be '%code%=%message%/%description%'; the code may contain a wildcard '*' to cover multiple codes at once, for example, '4**' will cover all 4xx codes, unless a more specific code was described previously) | `map[]` | *none* | -| `--json-format="…"` | override the default error page response in JSON format (Go templates are supported) | | `RESPONSE_JSON_FORMAT` | -| `--xml-format="…"` | override the default error page response in XML format (Go templates are supported) | | `RESPONSE_XML_FORMAT` | -| `--template-name="…"` (`-t`) | name of the template to use for rendering error pages | `template-1` | `TEMPLATE_NAME` | -| `--disable-l10n` | disable localization of error pages (if the template supports localization) | `false` | `DISABLE_L10N` | -| `--default-error-page="…"` | the code of the default (index page, when a code is not specified) error page to render | `404` | `DEFAULT_ERROR_PAGE` | -| `--default-http-code="…"` | the default (index page, when a code is not specified) HTTP response code | `404` | `DEFAULT_HTTP_CODE` | -| `--show-details` | show request details in the error page response (if supported by the template) | `false` | `SHOW_DETAILS` | -| `--proxy-headers="…"` | listed here HTTP headers will be proxied from the original request to the error page response (comma-separated list) | `X-Request-Id,X-Trace-Id,X-Amzn-Trace-Id` | `PROXY_HTTP_HEADERS` | -| `--read-buffer-size="…"` | customize the HTTP read buffer size (set per connection for reading requests, also limits the maximum header size; consider increasing it if your clients send multi-KB request URIs or multi-KB headers, such as large cookies) | `0` | `READ_BUFFER_SIZE` | +| Name | Description | Default value | Environment variables | +|------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:-----------------------------------------:|:-------------------------:| +| `--listen="…"` (`-l`) | IP (v4 or v6) address to listen on | `0.0.0.0` | `LISTEN_ADDR` | +| `--port="…"` (`-p`) | TCP port number | `8080` | `LISTEN_PORT` | +| `--add-template="…"` | to add a new template, provide the path to the file using this flag (the filename without the extension will be used as the template name) | `[]` | *none* | +| `--add-http-code="…"` | to add a new HTTP status code, provide the code and its message/description using this flag (the format should be '%code%=%message%/%description%'; the code may contain a wildcard '*' to cover multiple codes at once, for example, '4**' will cover all 4xx codes, unless a more specific code was described previously) | `map[]` | *none* | +| `--json-format="…"` | override the default error page response in JSON format (Go templates are supported) | | `RESPONSE_JSON_FORMAT` | +| `--xml-format="…"` | override the default error page response in XML format (Go templates are supported) | | `RESPONSE_XML_FORMAT` | +| `--template-name="…"` (`-t`) | name of the template to use for rendering error pages | `template-1` | `TEMPLATE_NAME` | +| `--disable-l10n` | disable localization of error pages (if the template supports localization) | `false` | `DISABLE_L10N` | +| `--default-error-page="…"` | the code of the default (index page, when a code is not specified) error page to render | `404` | `DEFAULT_ERROR_PAGE` | +| `--send-same-http-code` | the HTTP response should have the same status code as the requested error page (by default, every response with an error page will have a status code of 200) | `false` | `SEND_SAME_HTTP_CODE` | +| `--show-details` | show request details in the error page response (if supported by the template) | `false` | `SHOW_DETAILS` | +| `--proxy-headers="…"` | listed here HTTP headers will be proxied from the original request to the error page response (comma-separated list) | `X-Request-Id,X-Trace-Id,X-Amzn-Trace-Id` | `PROXY_HTTP_HEADERS` | +| `--rotation-mode="…"` | templates automatic rotation mode (disabled/random-on-startup/random-on-each-request/random-daily/random-hourly) | `disabled` | `TEMPLATES_ROTATION_MODE` | ### `healthcheck` command (aliases: `chk`, `health`, `check`) diff --git a/internal/cli/serve/command.go b/internal/cli/serve/command.go index 8d172a3..2cc86d2 100644 --- a/internal/cli/serve/command.go +++ b/internal/cli/serve/command.go @@ -74,7 +74,7 @@ func NewCommand(log *zap.Logger) *cli.Command { //nolint:funlen,gocognit,gocyclo defaultCodeToRenderFlag = cli.UintFlag{ Name: "default-error-page", Usage: "the code of the default (index page, when a code is not specified) error page to render", - Value: uint64(cfg.Default.CodeToRender), + Value: uint64(cfg.DefaultCodeToRender), Sources: env("DEFAULT_ERROR_PAGE"), Validator: func(code uint64) error { if code > 999 { //nolint:mnd @@ -85,13 +85,13 @@ func NewCommand(log *zap.Logger) *cli.Command { //nolint:funlen,gocognit,gocyclo }, OnlyOnce: true, } - defaultHTTPCodeFlag = cli.UintFlag{ - Name: "default-http-code", - Usage: "the default (index page, when a code is not specified) HTTP response code", - Value: uint64(cfg.Default.HttpCode), - Sources: env("DEFAULT_HTTP_CODE"), - Validator: defaultCodeToRenderFlag.Validator, - OnlyOnce: true, + sendSameHTTPCodeFlag = cli.BoolFlag{ + Name: "send-same-http-code", + Usage: "the HTTP response should have the same status code as the requested error page (by default, " + + "every response with an error page will have a status code of 200)", + Value: cfg.RespondWithSameHTTPCode, + Sources: env("SEND_SAME_HTTP_CODE"), + OnlyOnce: true, } showDetailsFlag = cli.BoolFlag{ Name: "show-details", @@ -157,8 +157,8 @@ func NewCommand(log *zap.Logger) *cli.Command { //nolint:funlen,gocognit,gocyclo cfg.TemplateName = c.String(templateNameFlag.Name) cfg.L10n.Disable = c.Bool(disableL10nFlag.Name) - cfg.Default.CodeToRender = uint16(c.Uint(defaultCodeToRenderFlag.Name)) - cfg.Default.HttpCode = uint16(c.Uint(defaultHTTPCodeFlag.Name)) + cfg.DefaultCodeToRender = uint16(c.Uint(defaultCodeToRenderFlag.Name)) + cfg.RespondWithSameHTTPCode = c.Bool(sendSameHTTPCodeFlag.Name) cfg.RotationMode, _ = config.ParseRotationMode(c.String(rotationModeFlag.Name)) cfg.ShowDetails = c.Bool(showDetailsFlag.Name) @@ -231,8 +231,8 @@ func NewCommand(log *zap.Logger) *cli.Command { //nolint:funlen,gocognit,gocyclo zap.String("XML format", cfg.Formats.XML), zap.String("template name", cfg.TemplateName), zap.Bool("disable localization", cfg.L10n.Disable), - zap.Uint16("default code to render", cfg.Default.CodeToRender), - zap.Uint16("default HTTP code", cfg.Default.HttpCode), + zap.Uint16("default code to render", cfg.DefaultCodeToRender), + zap.Bool("respond with the same HTTP code", cfg.RespondWithSameHTTPCode), zap.Bool("show details", cfg.ShowDetails), zap.Strings("proxy HTTP headers", cfg.ProxyHeaders), ) @@ -249,7 +249,7 @@ func NewCommand(log *zap.Logger) *cli.Command { //nolint:funlen,gocognit,gocyclo &templateNameFlag, &disableL10nFlag, &defaultCodeToRenderFlag, - &defaultHTTPCodeFlag, + &sendSameHTTPCodeFlag, &showDetailsFlag, &proxyHeadersListFlag, &rotationModeFlag, diff --git a/internal/config/config.go b/internal/config/config.go index 6effb1b..b179135 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -37,15 +37,16 @@ type Config struct { Disable bool } - // Default contains default settings. - Default struct { - // CodeToRender is the code for the default error page to be displayed. It is used when the requested - // code is not defined in the incoming request (i.e., the code to render as the index page). - CodeToRender uint16 + // DefaultCodeToRender is the code for the default error page to be displayed. It is used when the requested + // code is not defined in the incoming request (i.e., the code to render as the index page). + DefaultCodeToRender uint16 - // HTTPCode is the HTTP code to return when the requested code is not defined in the incoming request. - HttpCode uint16 - } + // RespondWithSameHTTPCode determines whether the response should have the same HTTP status code as the requested + // error page. + // In other words, if set to true and the requested error page has a code of 404, the HTTP response will also have + // a status code of 404. If set to false, the HTTP response will have a status code of 200 regardless of the + // requested error page's status code. + RespondWithSameHTTPCode bool // RotationMode allows to set the rotation mode for templates to switch between them automatically on startup, // on each request, daily, hourly and so on. @@ -150,8 +151,7 @@ func New() Config { cfg.ProxyHeaders = slices.Clone(defaultProxyHeaders) // set defaults - cfg.Default.CodeToRender = 404 - cfg.Default.HttpCode = http.StatusNotFound + cfg.DefaultCodeToRender = http.StatusNotFound return cfg } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index ab76b05..f12f707 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1,6 +1,7 @@ package config_test import ( + "net/http" "testing" "github.com/stretchr/testify/assert" @@ -20,8 +21,7 @@ func TestNew(t *testing.T) { assert.True(t, len(cfg.Templates) >= 2) assert.NotEmpty(t, cfg.TemplateName) assert.True(t, cfg.Templates.Has(cfg.TemplateName)) - assert.Equal(t, uint16(404), cfg.Default.CodeToRender) - assert.Equal(t, uint16(404), cfg.Default.HttpCode) + assert.Equal(t, uint16(http.StatusNotFound), cfg.DefaultCodeToRender) }) t.Run("changing cfg1 should not affect cfg2", func(t *testing.T) { diff --git a/internal/http/handlers/error_page/handler.go b/internal/http/handlers/error_page/handler.go index 6a475de..a1c354f 100644 --- a/internal/http/handlers/error_page/handler.go +++ b/internal/http/handlers/error_page/handler.go @@ -1,12 +1,84 @@ package error_page -import "net/http" +import ( + "fmt" + "net/http" + "path/filepath" + "strconv" + "strings" -func New() http.Handler { - var body = []byte("error page") + "gh.tarampamp.am/error-pages/internal/config" +) +func New(cfg *config.Config) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - _, _ = w.Write(body) + var code uint16 + + if fromUrl, okUrl := ExtractCodeFromURL(r.URL.Path); okUrl { + code = fromUrl + } else if fromHeader, okHeaders := ExtractCodeFromHeaders(r.Header); okHeaders { + code = fromHeader + } else { + code = cfg.DefaultCodeToRender + } + + var httpCode int + + if cfg.RespondWithSameHTTPCode { + httpCode = int(code) + } else { + httpCode = http.StatusOK + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") // TODO: should depends on requested type + w.WriteHeader(httpCode) + _, _ = w.Write([]byte(fmt.Sprintf("error page for the code %d", code))) }) } + +// ExtractCodeFromURL extracts the error code from the given URL. +func ExtractCodeFromURL(url string) (uint16, bool) { + var parts = strings.SplitN(strings.TrimLeft(url, "/"), "/", 1) + + if len(parts) == 0 { + return 0, false + } + + var ( + fileName = parts[0] + ext = strings.ToLower(filepath.Ext(fileName)) // ".html", ".htm", ".%something%" or an empty string + ) + + if ext != "" && ext != ".html" && ext != ".htm" { + return 0, false + } else if ext != "" { + fileName = strings.TrimSuffix(fileName, ext) + } + + if code, err := strconv.ParseUint(fileName, 10, 16); err == nil && code > 0 && code < 999 { + return uint16(code), true + } + + return 0, false +} + +// URLContainsCode checks if the given URL contains an error code. +func URLContainsCode(url string) (ok bool) { _, ok = ExtractCodeFromURL(url); return } //nolint:nlreturn + +// ExtractCodeFromHeaders extracts the error code from the given headers. +func ExtractCodeFromHeaders(headers http.Header) (uint16, bool) { + if value := headers.Get("X-Code"); len(value) > 0 && len(value) <= 3 { + if code, err := strconv.ParseUint(value, 10, 16); err == nil && code > 0 && code < 999 { + return uint16(code), true + } + } + + return 0, false +} + +// HeadersContainCode checks if the given headers contain an error code. +func HeadersContainCode(headers http.Header) (ok bool) { + _, ok = ExtractCodeFromHeaders(headers) + + return +} diff --git a/internal/http/server.go b/internal/http/server.go index 3ba4c28..16df314 100644 --- a/internal/http/server.go +++ b/internal/http/server.go @@ -4,7 +4,6 @@ import ( "context" "net" "net/http" - "regexp" "strconv" "strings" "time" @@ -13,7 +12,7 @@ import ( "gh.tarampamp.am/error-pages/internal/appmeta" "gh.tarampamp.am/error-pages/internal/config" - "gh.tarampamp.am/error-pages/internal/http/handlers/error_page" + ep "gh.tarampamp.am/error-pages/internal/http/handlers/error_page" "gh.tarampamp.am/error-pages/internal/http/handlers/live" "gh.tarampamp.am/error-pages/internal/http/handlers/version" "gh.tarampamp.am/error-pages/internal/http/middleware/logreq" @@ -51,9 +50,7 @@ func (s *Server) Register(cfg *config.Config) error { var ( liveHandler = live.New() versionHandler = version.New(appmeta.Version()) - errorPagesHandler = error_page.New() - - errorPageRegex = regexp.MustCompile(`^/(\d{3})(?:\.html|\.htm)?$`) // TODO: rewrite to function + errorPagesHandler = ep.New(cfg) ) s.server.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -63,16 +60,19 @@ func (s *Server) Register(cfg *config.Config) error { // 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)): + case method == http.MethodGet && (url == "/" || ep.URLContainsCode(url) || ep.HeadersContainCode(r.Header)): errorPagesHandler.ServeHTTP(w, r) + // wrong requests handling default: switch { diff --git a/internal/http/server_test.go b/internal/http/server_test.go index 4ac2a84..0e3577e 100644 --- a/internal/http/server_test.go +++ b/internal/http/server_test.go @@ -7,6 +7,7 @@ import ( "io" "net" "net/http" + "strconv" "testing" "time" @@ -99,7 +100,133 @@ func TestRouting(t *testing.T) { }) t.Run("error page", func(t *testing.T) { - t.Skip("not implemented") + t.Run("success", func(t *testing.T) { + var assertErrorPage = func(t *testing.T, wantErrorPageCode int, body []byte, headers http.Header) { + t.Helper() + + var bodyStr = string(body) + + assert.NotEmpty(t, bodyStr) + assert.Contains(t, bodyStr, "error page") // FIXME + assert.Contains(t, bodyStr, strconv.Itoa(wantErrorPageCode)) // FIXME? + assert.Contains(t, headers.Get("Content-Type"), "text/html") // FIXME + } + + t.Run("index, default", func(t *testing.T) { + var status, body, headers = sendRequest(t, http.MethodGet, baseUrl+"/") + + assert.Equal(t, http.StatusOK, status) + assertErrorPage(t, int(cfg.DefaultCodeToRender), body, headers) + }) + + t.Run("index, code in HTTP header", func(t *testing.T) { + var status, body, headers = sendRequest(t, http.MethodGet, baseUrl+"/", map[string]string{"X-Code": "404"}) + + assert.Equal(t, http.StatusOK, status) // because of [cfg.RespondWithSameHTTPCode] is false by default + assertErrorPage(t, 404, body, headers) + }) + + t.Run("code in URL, .html", func(t *testing.T) { + var status, body, headers = sendRequest(t, http.MethodGet, baseUrl+"/500.html") + + assert.Equal(t, http.StatusOK, status) + assertErrorPage(t, 500, body, headers) + }) + + t.Run("code in URL, .htm", func(t *testing.T) { + var status, body, headers = sendRequest(t, http.MethodGet, baseUrl+"/409.htm") + + assert.Equal(t, http.StatusOK, status) + assertErrorPage(t, 409, body, headers) + }) + + t.Run("code in URL, without extension", func(t *testing.T) { + var status, body, headers = sendRequest(t, http.MethodGet, baseUrl+"/405") + + assert.Equal(t, http.StatusOK, status) + assertErrorPage(t, 405, body, headers) + }) + + t.Run("code in the URL have higher priority than in the headers", func(t *testing.T) { + var status, body, headers = sendRequest(t, http.MethodGet, baseUrl+"/405", map[string]string{"X-Code": "404"}) + + assert.Equal(t, http.StatusOK, status) + assertErrorPage(t, 405, body, headers) + }) + + t.Run("invalid code in HTTP header (with a string)", func(t *testing.T) { + var status, body, headers = sendRequest(t, http.MethodGet, baseUrl+"/", map[string]string{"X-Code": "foobar"}) + + assert.Equal(t, http.StatusOK, status) + assertErrorPage(t, int(cfg.DefaultCodeToRender), body, headers) + }) + + t.Run("invalid code in HTTP header (too small)", func(t *testing.T) { + var status, body, headers = sendRequest(t, http.MethodGet, baseUrl+"/", map[string]string{"X-Code": "0"}) + + assert.Equal(t, http.StatusOK, status) + assertErrorPage(t, int(cfg.DefaultCodeToRender), body, headers) + }) + + t.Run("invalid code in HTTP header (too big)", func(t *testing.T) { + var status, body, headers = sendRequest(t, http.MethodGet, baseUrl+"/", map[string]string{"X-Code": "1000"}) + + assert.Equal(t, http.StatusOK, status) + assertErrorPage(t, int(cfg.DefaultCodeToRender), body, headers) + }) + }) + + t.Run("failure", func(t *testing.T) { + var assertIsNotErrorPage = func(t *testing.T, body []byte) { + t.Helper() + + assert.NotContains(t, string(body), "error page") // FIXME + } + + t.Run("invalid code in URL (too small)", func(t *testing.T) { + var status, body, _ = sendRequest(t, http.MethodGet, baseUrl+"/0.html") + + assert.Equal(t, http.StatusNotFound, status) + assertIsNotErrorPage(t, body) + }) + + t.Run("invalid code in URL (too big)", func(t *testing.T) { + var status, body, _ = sendRequest(t, http.MethodGet, baseUrl+"/1000.html") + + assert.Equal(t, http.StatusNotFound, status) + assertIsNotErrorPage(t, body) + }) + + t.Run("invalid code in URL (with a string suffix)", func(t *testing.T) { + var status, body, _ = sendRequest(t, http.MethodGet, baseUrl+"/404foobar.html") + + assert.Equal(t, http.StatusNotFound, status) + assertIsNotErrorPage(t, body) + }) + + t.Run("invalid code in URL (with a string prefix)", func(t *testing.T) { + var status, body, _ = sendRequest(t, http.MethodGet, baseUrl+"/foobar404.html") + + assert.Equal(t, http.StatusNotFound, status) + assertIsNotErrorPage(t, body) + }) + + t.Run("invalid code in URL (with a string)", func(t *testing.T) { + var status, body, _ = sendRequest(t, http.MethodGet, baseUrl+"/foobar.html") + + assert.Equal(t, http.StatusNotFound, status) + assertIsNotErrorPage(t, body) + }) + + t.Run("invalid HTTP methods", func(t *testing.T) { + for _, method := range []string{http.MethodDelete, http.MethodPatch, http.MethodPost, http.MethodPut} { + var status, body, _ = sendRequest(t, method, baseUrl+"/404.html") + + assert.Equal(t, http.StatusMethodNotAllowed, status) + assertIsNotErrorPage(t, body) + } + }) + }) }) t.Run("errors handling", func(t *testing.T) {