From bed576f26cc1952baad93d3d9819dcd018a0ea51 Mon Sep 17 00:00:00 2001 From: Paramtamtam <7326800+tarampampam@users.noreply.github.com> Date: Thu, 27 Jan 2022 17:29:49 +0500 Subject: [PATCH] Go templates support, XML, JSON, Ingress (#49) --- .github/workflows/tests.yml | 39 ++- .gitignore | 2 +- CHANGELOG.md | 19 ++ Dockerfile | 8 +- Makefile | 7 +- README.md | 38 ++- cmd/error-pages/main_test.go | 41 +++ docker-compose.yml | 14 +- error-pages.yml | 37 +++ internal/breaker/os_signal_test.go | 4 + internal/checkers/health.go | 2 +- internal/checkers/health_test.go | 6 +- internal/checkers/live_test.go | 2 + internal/cli/build/command.go | 224 +++++-------- internal/cli/build/command_test.go | 2 + internal/cli/build/history.go | 59 ++++ internal/cli/build/index.tpl.html | 35 ++ internal/cli/healthcheck/command_test.go | 2 + internal/cli/root_test.go | 10 + internal/cli/serve/command.go | 64 +--- internal/cli/serve/command_test.go | 2 + internal/cli/serve/flags.go | 17 +- internal/cli/version/command_test.go | 4 + internal/config/config.go | 198 +++++++++-- internal/config/config_test.go | 314 ++++++------------ internal/config/testdata/bar-tpl.html | 1 + internal/config/testdata/foo-tpl.html | 1 + internal/config/testdata/simple.yml | 4 +- internal/env/env.go | 1 + internal/env/env_test.go | 4 + internal/http/common/middlewares_test.go | 2 + internal/http/core/errorpage.go | 107 ++++++ internal/http/core/formats.go | 62 ++++ internal/http/core/formats_test.go | 110 ++++++ internal/http/core/headers.go | 27 ++ internal/http/handlers/errorpage/handler.go | 34 +- .../http/handlers/errorpage/handler_test.go | 2 + internal/http/handlers/healthz/handler.go | 1 + .../http/handlers/healthz/handler_test.go | 2 + internal/http/handlers/index/handler.go | 40 ++- internal/http/handlers/index/handler_test.go | 2 + .../http/handlers/notfound/handler_test.go | 2 + .../http/handlers/version/handler_test.go | 2 + internal/http/server.go | 28 +- internal/http/server_test.go | 2 + internal/pick/picker.go | 83 +++++ internal/pick/picker_test.go | 65 ++++ internal/pick/strings_slice.go | 54 +-- internal/pick/strings_slice_test.go | 129 +++---- internal/tpl/error_pages.go | 136 -------- internal/tpl/error_pages_test.go | 132 -------- internal/tpl/properties.go | 33 ++ internal/tpl/properties_test.go | 44 +++ internal/tpl/render.go | 66 ++++ internal/tpl/render_test.go | 80 +++++ internal/version/version_test.go | 2 + schemas/config/1.0.schema.json | 108 ++++++ schemas/config/readme.md | 13 + schemas/readme.md | 15 + templates/cats.html | 70 +++- templates/ghost.html | 92 +++-- templates/hacker-terminal.html | 44 ++- templates/l7-dark.html | 56 +++- templates/l7-light.html | 56 +++- templates/noise.html | 9 + templates/readme.md | 1 + templates/shuffle.html | 102 +++++- test/hurl/404.hurl | 7 + test/hurl/code_502_default.hurl | 10 + test/hurl/code_502_json.hurl | 39 +++ test/hurl/code_502_xml.hurl | 38 +++ test/hurl/healthz.hurl | 12 + test/hurl/index.hurl | 34 ++ test/hurl/readme.md | 27 ++ test/hurl/version.hurl | 8 + test/hurl/x_code.hurl | 37 +++ 76 files changed, 2200 insertions(+), 986 deletions(-) create mode 100644 cmd/error-pages/main_test.go create mode 100644 internal/cli/build/history.go create mode 100644 internal/cli/build/index.tpl.html create mode 100644 internal/config/testdata/bar-tpl.html create mode 100644 internal/config/testdata/foo-tpl.html create mode 100644 internal/http/core/errorpage.go create mode 100644 internal/http/core/formats.go create mode 100644 internal/http/core/formats_test.go create mode 100644 internal/http/core/headers.go create mode 100644 internal/pick/picker.go create mode 100644 internal/pick/picker_test.go delete mode 100644 internal/tpl/error_pages.go delete mode 100644 internal/tpl/error_pages_test.go create mode 100644 internal/tpl/properties.go create mode 100644 internal/tpl/properties_test.go create mode 100644 internal/tpl/render.go create mode 100644 internal/tpl/render_test.go create mode 100644 schemas/config/1.0.schema.json create mode 100644 schemas/config/readme.md create mode 100644 schemas/readme.md create mode 100644 templates/readme.md create mode 100644 test/hurl/404.hurl create mode 100644 test/hurl/code_502_default.hurl create mode 100644 test/hurl/code_502_json.hurl create mode 100644 test/hurl/code_502_xml.hurl create mode 100644 test/hurl/healthz.hurl create mode 100644 test/hurl/index.hurl create mode 100644 test/hurl/readme.md create mode 100644 test/hurl/version.hurl create mode 100644 test/hurl/x_code.hurl diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a217b3c..1ec09c9 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -27,9 +27,24 @@ jobs: # Docs: - name: Run linter uses: golangci/golangci-lint-action@v2 # Action page: with: - version: v1.42 # without patch version + version: v1.44 # without patch version only-new-issues: false # show only new issues if it's a pull request + validate-config-file: + name: Validate config file + runs-on: ubuntu-20.04 + steps: + - uses: actions/checkout@v2 + + - uses: actions/setup-node@v2 + with: {node-version: '16'} + + - name: Install linter + run: npm install -g ajv-cli # Package page: + + - name: Run linter + run: ajv validate --all-errors --verbose -s ./schemas/config/1.0.schema.json -d ./error-pages.y*ml + go-test: name: Unit tests runs-on: ubuntu-20.04 @@ -68,7 +83,7 @@ jobs: # Docs: matrix: os: [linux, darwin] # linux, freebsd, darwin, windows arch: [amd64] # amd64, 386 - needs: [golangci-lint, go-test] + needs: [golangci-lint, go-test, validate-config-file] steps: - uses: actions/setup-go@v2 with: {go-version: 1.17} @@ -139,7 +154,7 @@ jobs: # Docs: docker-image: name: Build docker image runs-on: ubuntu-20.04 - needs: [golangci-lint, go-test] + needs: [golangci-lint, go-test, validate-config-file] steps: - uses: actions/checkout@v2 @@ -187,6 +202,8 @@ jobs: # Docs: needs: [docker-image] timeout-minutes: 2 steps: + - uses: actions/checkout@v2 + - uses: actions/download-artifact@v2 with: name: docker-image @@ -195,17 +212,21 @@ jobs: # Docs: - working-directory: .artifact run: docker load < docker-image.tar + - name: Download hurl + env: + VERSION: 1.5.0 + run: curl -SL -o hurl.deb https://github.com/Orange-OpenSource/hurl/releases/download/${VERSION}/hurl_${VERSION}_amd64.deb + + - name: Install hurl + run: sudo dpkg -i hurl.deb + - name: Run container with the app - run: docker run --rm -d -p "8080:8080/tcp" -e "DEFAULT_HTTP_CODE=401" --name app app:ci + run: docker run --rm -d -p "8080:8080/tcp" -e "SHOW_DETAILS=true" --name app app:ci - name: Wait for container "healthy" state run: until [[ "`docker inspect -f {{.State.Health.Status}} app`" == "healthy" ]]; do echo "wait 1 sec.."; sleep 1; done - - run: test $(curl --write-out %{http_code} --silent --output /dev/null http://127.0.0.1:8080/) -eq 401 - - run: curl --fail http://127.0.0.1:8080/500.html - - run: curl --fail http://127.0.0.1:8080/400.html - - run: curl --fail http://127.0.0.1:8080/health/live - - run: test $(curl --write-out %{http_code} --silent --output /dev/null http://127.0.0.1:8080/foobar) -eq 404 + - run: hurl --color --test --fail-at-end --variable host=127.0.0.1 --variable port=8080 --summary ./test/hurl/*.hurl - name: Stop the container if: always() diff --git a/.gitignore b/.gitignore index c569fe6..9dc7412 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,7 @@ /.vscode ## Binaries -error-pages +/error-pages ## Temp dirs & trash /temp diff --git a/CHANGELOG.md b/CHANGELOG.md index d7d0f61..9a24329 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,25 @@ All notable changes to this package will be documented in this file. The format is based on [Keep a Changelog][keepachangelog] and this project adheres to [Semantic Versioning][semver]. +## UNRELEASED + +### Changed + +- It is now possible to use [golang-tags of templates](https://pkg.go.dev/text/template) in error page templates and formatted (`json`, `xml`) responses +- Health-check route become `/healthz` (instead `/health/live`, previous route marked ad deprecated) + +### Added + +- The templates contain details block now (can be enabled using `--show-details` flag for the `serve` command or environment variable `SHOW_DETAILS=true`) +- Formatted response templates (`json`, `xml`) - the server responds with a formatted response depending on the `Content-Type` (and `X-Format`) request header value +- HTTP header `X-Robots-Tag: noindex` for the error pages +- Possibility to pass the needed error page code using `X-Code` HTTP header +- Possibility to integrate with [ingress-nginx](https://kubernetes.github.io/ingress-nginx/) + +### Fixed + +- Potential race condition (in the `pick.StringsSlice` struct) + ## v2.3.0 ### Added diff --git a/Dockerfile b/Dockerfile index ca10380..25ed4fb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,6 +31,7 @@ RUN set -x \ && echo 'appuser:x:10001:' > ./etc/group \ && mv /src/error-pages ./bin/error-pages \ && mv /src/templates ./opt/templates \ + && rm ./opt/templates/*.md \ && mv /src/error-pages.yml ./opt/error-pages.yml WORKDIR /tmp/rootfs/opt @@ -66,12 +67,11 @@ WORKDIR /opt ENV LISTEN_PORT="8080" \ TEMPLATE_NAME="ghost" \ DEFAULT_ERROR_PAGE="404" \ - DEFAULT_HTTP_CODE="404" + DEFAULT_HTTP_CODE="404" \ + SHOW_DETAILS="false" # Docs: -HEALTHCHECK --interval=7s --timeout=2s CMD [ \ - "/bin/error-pages", "healthcheck", "--log-json" \ -] +HEALTHCHECK --interval=7s --timeout=2s CMD ["/bin/error-pages", "healthcheck", "--log-json"] ENTRYPOINT ["/bin/error-pages"] diff --git a/Makefile b/Makefile index 76395bf..e94253c 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,7 @@ DC_RUN_ARGS = --rm --user "$(shell id -u):$(shell id -g)" APP_NAME = $(notdir $(CURDIR)) .PHONY : help \ - image dive build fmt lint gotest test shell \ + image dive build fmt lint gotest int-test test shell \ up down restart \ clean .DEFAULT_GOAL : help @@ -42,7 +42,10 @@ lint: ## Run app linters gotest: ## Run app tests docker-compose run $(DC_RUN_ARGS) --no-deps app go test -v -race -timeout 10s ./... -test: lint gotest ## Run app tests and linters +int-test: ## Run integration tests (docs: https://hurl.dev/docs/man-page.html#options) + docker-compose run --rm hurl --color --test --fail-at-end --variable host=web --variable port=8080 --summary ./test/hurl/*.hurl + +test: lint gotest int-test ## Run app tests and linters shell: ## Start shell into container with golang docker-compose run $(DC_RUN_ARGS) app bash diff --git a/README.md b/README.md index d40b98a..3a0d4be 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,10 @@ One day you may want to replace the standard error pages of your HTTP server wit - Simple error pages generator, written on Go - Single-page error page templates with different designs (located in the [templates](templates) directory) - Fast and lightweight HTTP server (written on Go also, with the [FastHTTP][fasthttp] under the hood) +- HTTP server respects the `Content-Type` HTTP header (and `X-Format`) value and responds with the corresponding format - Already generated error pages (sources can be [found here][preview-sources], the **demonstration** is always accessible [here][preview-demo]) -- Lightweight docker image _(~3.5Mb compressed size)_ with all the things described above +- Lightweight docker image _(~3.7Mb compressed size)_ with all the things described above +- Ready to integrate with the Traefik and Ingress-nginx Also, this project can be used for the [**Traefik** error pages customization](https://doc.traefik.io/traefik/middlewares/http/errorpages/). @@ -30,10 +32,10 @@ Also, this project can be used for the [**Traefik** error pages customization](h Download the latest binary file for your os/arch from the [releases page][link_releases] or use our docker image: -Registry | Image --------------------------------------- | ----- -[Docker Hub][link_docker_hub] | `tarampampam/error-pages` -[GitHub Container Registry][link_ghcr] | `ghcr.io/tarampampam/error-pages` +| Registry | Image | +|--------------------------------------------|-----------------------------------| +| [Docker Hub][link_docker_hub] | `tarampampam/error-pages` | +| [GitHub Container Registry][link_ghcr] | `ghcr.io/tarampampam/error-pages` | > Using the `latest` tag for the docker image is highly discouraged because of possible backward-incompatible changes during **major** upgrades. Please, use tags in `X.Y.Z` format @@ -56,21 +58,21 @@ $ docker run --rm -it \ ## Templates -Name | Preview -:---------------: | :-----: -`ghost` | [![ghost](https://hsto.org/webt/oj/cl/4k/ojcl4ko_cvusy5xuki6efffzsyo.gif)](https://tarampampam.github.io/error-pages/ghost/404.html) -`l7-light` | [![l7-light](https://hsto.org/webt/xc/iq/vt/xciqvty-aoj-rchfarsjhutpjny.png)](https://tarampampam.github.io/error-pages/l7-light/404.html) -`l7-dark` | [![l7-dark](https://hsto.org/webt/s1/ih/yr/s1ihyrqs_y-sgraoimfhk6ypney.png)](https://tarampampam.github.io/error-pages/l7-dark/404.html) -`shuffle` | [![shuffle](https://hsto.org/webt/7w/rk/3m/7wrk3mrzz3y8qfqwovmuvacu-bs.gif)](https://tarampampam.github.io/error-pages/shuffle/404.html) -`noise` | [![noise](https://hsto.org/webt/42/oq/8y/42oq8yok_i-arrafjt6hds_7ahy.gif)](https://tarampampam.github.io/error-pages/noise/404.html) -`hacker-terminal` | [![hacker-terminal](https://hsto.org/webt/5s/l0/p1/5sl0p1_ud_nalzjzsj5slz6dfda.gif)](https://tarampampam.github.io/error-pages/hacker-terminal/404.html) -`cats` | [![cats](https://hsto.org/webt/_g/y-/ke/_gy-keqinz-3867jbw36v37-iwe.jpeg)](https://tarampampam.github.io/error-pages/cats/404.html) +| Name | Preview | +|:-----------------:|:--------------------------------------------------------------------------------------------------------------------------------------------------------:| +| `ghost` | [![ghost](https://hsto.org/webt/oj/cl/4k/ojcl4ko_cvusy5xuki6efffzsyo.gif)](https://tarampampam.github.io/error-pages/ghost/404.html) | +| `l7-light` | [![l7-light](https://hsto.org/webt/xc/iq/vt/xciqvty-aoj-rchfarsjhutpjny.png)](https://tarampampam.github.io/error-pages/l7-light/404.html) | +| `l7-dark` | [![l7-dark](https://hsto.org/webt/s1/ih/yr/s1ihyrqs_y-sgraoimfhk6ypney.png)](https://tarampampam.github.io/error-pages/l7-dark/404.html) | +| `shuffle` | [![shuffle](https://hsto.org/webt/7w/rk/3m/7wrk3mrzz3y8qfqwovmuvacu-bs.gif)](https://tarampampam.github.io/error-pages/shuffle/404.html) | +| `noise` | [![noise](https://hsto.org/webt/42/oq/8y/42oq8yok_i-arrafjt6hds_7ahy.gif)](https://tarampampam.github.io/error-pages/noise/404.html) | +| `hacker-terminal` | [![hacker-terminal](https://hsto.org/webt/5s/l0/p1/5sl0p1_ud_nalzjzsj5slz6dfda.gif)](https://tarampampam.github.io/error-pages/hacker-terminal/404.html) | +| `cats` | [![cats](https://hsto.org/webt/_g/y-/ke/_gy-keqinz-3867jbw36v37-iwe.jpeg)](https://tarampampam.github.io/error-pages/cats/404.html) | > Note: `noise` template highly uses the CPU, be careful ## Usage -All of the examples below will use a docker image with the application, but you can also use a binary. By the way, our docker image uses the **unleveled user** by default and **distroless**. +All the examples below will use a docker image with the application, but you can also use a binary. By the way, our docker image uses the **unleveled user** by default and **distroless**.
HTTP server @@ -86,7 +88,11 @@ $ docker run --rm \ tarampampam/error-pages ``` -And open [`http://127.0.0.1:8080/404.html`](http://127.0.0.1:8080/404.html) in your favorite browser. Error pages are accessible by the following URLs: `http://127.0.0.1:8080/{page_code}.html`. +And open [`http://127.0.0.1:8080/404.html`](http://127.0.0.1:8080/404.html) in your favorite browser. Error pages are accessible by the following URLs: `http://127.0.0.1:8080/{page_code}.html`. Another way is to pass the needed error page code (and HTTP response code) using the HTTP header `X-Code` when requesting an index page. + +Additionally, the server respects the `Content-Type` HTTP header (and `X-Format`) value and responds with the corresponding format instead of the HTML-formatted response only. The `xml` and `json` content types (formats) are allowed at this moment, and its format can be fully customized using a configuration file! + +For the integration with [ingress-nginx](https://kubernetes.github.io/ingress-nginx/) you can start the server with the flag `--show-details` (environment variable `SHOW_DETAILS=true`) - in this case, the error pages (`json`, `xml` responses too) will contain additional information that passed from the upstream reverse proxy! Environment variable `TEMPLATE_NAME` should be used for the theme switching (supported templates are described below). diff --git a/cmd/error-pages/main_test.go b/cmd/error-pages/main_test.go new file mode 100644 index 0000000..168a7c1 --- /dev/null +++ b/cmd/error-pages/main_test.go @@ -0,0 +1,41 @@ +package main + +import ( + "os" + "testing" + + "github.com/kami-zh/go-capturer" + "github.com/stretchr/testify/assert" +) + +func Test_Main(t *testing.T) { + os.Args = []string{"", "--help"} + exitFn = func(code int) { assert.Equal(t, 0, code) } + + output := capturer.CaptureStdout(main) + + assert.Contains(t, output, "Usage:") + assert.Contains(t, output, "Available Commands:") + assert.Contains(t, output, "Flags:") +} + +func Test_MainWithoutCommands(t *testing.T) { + os.Args = []string{""} + exitFn = func(code int) { assert.Equal(t, 0, code) } + + output := capturer.CaptureStdout(main) + + assert.Contains(t, output, "Usage:") + assert.Contains(t, output, "Available Commands:") + assert.Contains(t, output, "Flags:") +} + +func Test_MainUnknownSubcommand(t *testing.T) { + os.Args = []string{"", "foobar"} + exitFn = func(code int) { assert.Equal(t, 1, code) } + + output := capturer.CaptureStderr(main) + + assert.Contains(t, output, "unknown command") + assert.Contains(t, output, "foobar") +} diff --git a/docker-compose.yml b/docker-compose.yml index b83d645..118585a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,13 +30,14 @@ services: - serve - --verbose - --port=8080 + - --show-details healthcheck: - test: ['CMD', 'wget', '--spider', '-q', 'http://127.0.0.1:8080/health/live'] + test: ['CMD', 'wget', '--spider', '-q', 'http://127.0.0.1:8080/healthz'] interval: 5s timeout: 2s golint: - image: golangci/golangci-lint:v1.42-alpine # Image page: + image: golangci/golangci-lint:v1.44-alpine # Image page: environment: GOLANGCI_LINT_CACHE: /tmp/golint # volumes: @@ -44,3 +45,12 @@ services: - golint-cache:/tmp/golint:rw working_dir: /src command: /bin/true + + hurl: + image: orangeopensource/hurl:1.5.0 + volumes: + - .:/src:ro + working_dir: /src + depends_on: + web: + condition: service_healthy diff --git a/error-pages.yml b/error-pages.yml index 0c3db4f..35d2047 100644 --- a/error-pages.yml +++ b/error-pages.yml @@ -12,6 +12,43 @@ templates: - path: ./templates/hacker-terminal.html - path: ./templates/cats.html +formats: + json: + content: | + { + "error": true, + "code": {{ code | json }}, + "message": {{ message | json }}, + "description": {{ description | json }}{{ if show_details }}, + "details": { + "original_uri": {{ original_uri | json }}, + "namespace": {{ namespace | json }}, + "ingress_name": {{ ingress_name | json }}, + "service_name": {{ service_name | json }}, + "service_port": {{ service_port | json }}, + "request_id": {{ request_id | json }}, + "timestamp": {{ now.Unix }} + }{{ end }} + } + + xml: + content: | + + + {{ code }} + {{ message }} + {{ description }}{{ if show_details }} +
+ {{ original_uri }} + {{ namespace }} + {{ ingress_name }} + {{ service_name }} + {{ service_port }} + {{ request_id }} + {{ now.Unix }} +
{{ end }} +
+ pages: 400: message: Bad Request diff --git a/internal/breaker/os_signal_test.go b/internal/breaker/os_signal_test.go index fc2fc29..26de81f 100644 --- a/internal/breaker/os_signal_test.go +++ b/internal/breaker/os_signal_test.go @@ -12,6 +12,8 @@ import ( ) func TestNewOSSignals(t *testing.T) { + t.Parallel() + oss := breaker.NewOSSignals(context.Background()) gotSignal := make(chan os.Signal, 1) @@ -33,6 +35,8 @@ func TestNewOSSignals(t *testing.T) { } func TestNewOSSignalCtxCancel(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(context.Background()) oss := breaker.NewOSSignals(ctx) diff --git a/internal/checkers/health.go b/internal/checkers/health.go index a6da48f..fc4724a 100644 --- a/internal/checkers/health.go +++ b/internal/checkers/health.go @@ -34,7 +34,7 @@ func NewHealthChecker(ctx context.Context, client ...httpClient) *HealthChecker // Check application using liveness probe. func (c *HealthChecker) Check(port uint16) error { - req, err := http.NewRequestWithContext(c.ctx, http.MethodGet, fmt.Sprintf("http://127.0.0.1:%d/health/live", port), nil) //nolint:lll + req, err := http.NewRequestWithContext(c.ctx, http.MethodGet, fmt.Sprintf("http://127.0.0.1:%d/healthz", port), nil) //nolint:lll if err != nil { return err } diff --git a/internal/checkers/health_test.go b/internal/checkers/health_test.go index a4f72b2..2218af2 100644 --- a/internal/checkers/health_test.go +++ b/internal/checkers/health_test.go @@ -16,9 +16,11 @@ type httpClientFunc func(*http.Request) (*http.Response, error) func (f httpClientFunc) Do(req *http.Request) (*http.Response, error) { return f(req) } func TestHealthChecker_CheckSuccess(t *testing.T) { + t.Parallel() + var httpMock httpClientFunc = func(req *http.Request) (*http.Response, error) { assert.Equal(t, http.MethodGet, req.Method) - assert.Equal(t, "http://127.0.0.1:123/health/live", req.URL.String()) + assert.Equal(t, "http://127.0.0.1:123/healthz", req.URL.String()) assert.Equal(t, "HealthChecker/internal", req.Header.Get("User-Agent")) return &http.Response{ @@ -33,6 +35,8 @@ func TestHealthChecker_CheckSuccess(t *testing.T) { } func TestHealthChecker_CheckFail(t *testing.T) { + t.Parallel() + var httpMock httpClientFunc = func(req *http.Request) (*http.Response, error) { return &http.Response{ Body: ioutil.NopCloser(bytes.NewReader([]byte{})), diff --git a/internal/checkers/live_test.go b/internal/checkers/live_test.go index 413d89b..1b622e6 100644 --- a/internal/checkers/live_test.go +++ b/internal/checkers/live_test.go @@ -8,5 +8,7 @@ import ( ) func TestLiveChecker_Check(t *testing.T) { + t.Parallel() + assert.NoError(t, checkers.NewLiveChecker().Check()) } diff --git a/internal/cli/build/command.go b/internal/cli/build/command.go index 764f90c..2869286 100644 --- a/internal/cli/build/command.go +++ b/internal/cli/build/command.go @@ -1,12 +1,8 @@ package build import ( - "bytes" "os" "path" - "sort" - "text/template" - "time" "github.com/pkg/errors" "github.com/spf13/cobra" @@ -15,12 +11,8 @@ import ( "go.uber.org/zap" ) -type historyItem struct { - Code, Message, Path string -} - // NewCommand creates `build` command. -func NewCommand(log *zap.Logger, configFile *string) *cobra.Command { //nolint:funlen,gocognit,gocyclo +func NewCommand(log *zap.Logger, configFile *string) *cobra.Command { var ( generateIndex bool cfg *config.Config @@ -31,101 +23,23 @@ func NewCommand(log *zap.Logger, configFile *string) *cobra.Command { //nolint:f Aliases: []string{"b"}, Short: "Build the error pages", Args: cobra.ExactArgs(1), - PreRunE: func(*cobra.Command, []string) error { + PreRunE: func(*cobra.Command, []string) (err error) { if configFile == nil { return errors.New("path to the config file is required for this command") } - if c, err := config.FromYamlFile(*configFile); err != nil { + if cfg, err = config.FromYamlFile(*configFile); err != nil { return err - } else { - if err = c.Validate(); err != nil { - return err - } - - cfg = c } - return nil + return }, RunE: func(_ *cobra.Command, args []string) error { if len(args) != 1 { return errors.New("wrong arguments count") } - errorPages := tpl.NewErrorPages() - - log.Info("loading templates") - if templates, err := cfg.LoadTemplates(); err == nil { - if len(templates) > 0 { - for templateName, content := range templates { - errorPages.AddTemplate(templateName, content) - } - - for code, desc := range cfg.Pages { - errorPages.AddPage(code, desc.Message, desc.Description) - } - } else { - return errors.New("no loaded templates") - } - } else { - return err - } - - log.Debug("the output directory preparing", zap.String("Path", args[0])) - if err := createDirectory(args[0]); err != nil { - return errors.Wrap(err, "cannot prepare output directory") - } - - history, startedAt := make(map[string][]historyItem), time.Now() - - log.Info("saving the error pages") - if err := errorPages.IteratePages(func(template, code string, content []byte) error { - if e := createDirectory(path.Join(args[0], template)); e != nil { - return e - } - - fileName := code + ".html" - - if e := os.WriteFile(path.Join(args[0], template, fileName), content, 0664); e != nil { //nolint:gosec,gomnd - return e - } - - if _, ok := history[template]; !ok { - history[template] = make([]historyItem, 0, len(cfg.Pages)) - } - - history[template] = append(history[template], historyItem{ - Code: code, - Message: cfg.Pages[code].Message, - Path: path.Join(template, fileName), - }) - - return nil - }); err != nil { - return err - } - - log.Debug("saved", zap.Duration("duration", time.Since(startedAt))) - - if generateIndex { - for _, h := range history { - sort.Slice(h, func(i, j int) bool { - return h[i].Code < h[j].Code - }) - } - - log.Info("index file generation") - startedAt = time.Now() - - if err := writeIndexFile(path.Join(args[0], "index.html"), history); err != nil { - return err - } - - log.Debug("index file generated", zap.Duration("duration", time.Since(startedAt))) - } - - return nil + return run(log, cfg, args[0], generateIndex) }, } @@ -139,11 +53,86 @@ func NewCommand(log *zap.Logger, configFile *string) *cobra.Command { //nolint:f return cmd } -func createDirectory(path string) error { +const ( + outHTMLFileExt = ".html" + outIndexFileName = "index" + outFilePerm = os.FileMode(0664) + outDirPerm = os.FileMode(0775) +) + +func run(log *zap.Logger, cfg *config.Config, outDirectoryPath string, generateIndex bool) error { //nolint:funlen + if len(cfg.Templates) == 0 { + return errors.New("no loaded templates") + } + + log.Info("output directory preparing", zap.String("path", outDirectoryPath)) + + if err := createDirectory(outDirectoryPath, outDirPerm); err != nil { + return errors.Wrap(err, "cannot prepare output directory") + } + + history := newBuildingHistory() + + for _, template := range cfg.Templates { + log.Debug("template processing", zap.String("name", template.Name())) + + for _, page := range cfg.Pages { + if err := createDirectory(path.Join(outDirectoryPath, template.Name()), outDirPerm); err != nil { + return err + } + + var ( + fileName = page.Code() + outHTMLFileExt + filePath = path.Join(outDirectoryPath, template.Name(), fileName) + ) + + content, renderingErr := tpl.Render(template.Content(), tpl.Properties{ + Code: page.Code(), + Message: page.Message(), + Description: page.Description(), + ShowRequestDetails: false, + }) + if renderingErr != nil { + return renderingErr + } + + if err := os.WriteFile(filePath, content, outFilePerm); err != nil { + return err + } + + log.Debug("page rendered", zap.String("path", filePath)) + + if generateIndex { + history.Append( + template.Name(), + page.Code(), + page.Message(), + path.Join(template.Name(), fileName), + ) + } + } + } + + if generateIndex { + var filepath = path.Join(outDirectoryPath, outIndexFileName+outHTMLFileExt) + + log.Info("index file generation", zap.String("path", filepath)) + + if err := history.WriteIndexFile(filepath, outFilePerm); err != nil { + return err + } + } + + log.Info("job is done") + + return nil +} + +func createDirectory(path string, perm os.FileMode) error { stat, err := os.Stat(path) if err != nil { if os.IsNotExist(err) { - return os.MkdirAll(path, 0775) //nolint:gomnd + return os.MkdirAll(path, perm) } return err @@ -155,52 +144,3 @@ func createDirectory(path string) error { return nil } - -func writeIndexFile(path string, history map[string][]historyItem) error { - t, err := template.New("index").Parse(` - - - - Error pages list - - - -
-
-
- -

Error pages index

-
-{{- range $template, $item := . -}} -

Template name: {{ $template }}

- -{{ end }} -
-
- - -`) - if err != nil { - return err - } - - var buf bytes.Buffer - - if err = t.Execute(&buf, history); err != nil { - return err - } - - return os.WriteFile(path, buf.Bytes(), 0664) //nolint:gosec,gomnd -} diff --git a/internal/cli/build/command_test.go b/internal/cli/build/command_test.go index df9157a..9db3223 100644 --- a/internal/cli/build/command_test.go +++ b/internal/cli/build/command_test.go @@ -3,5 +3,7 @@ package build_test import "testing" func TestNothing(t *testing.T) { + t.Parallel() + t.Skip("tests for this package have not been implemented yet") } diff --git a/internal/cli/build/history.go b/internal/cli/build/history.go new file mode 100644 index 0000000..2baa73e --- /dev/null +++ b/internal/cli/build/history.go @@ -0,0 +1,59 @@ +package build + +import ( + "bytes" + _ "embed" + "os" + "sort" + "text/template" +) + +type ( + buildingHistory struct { + items map[string][]historyItem + } + + historyItem struct { + Code, Message, Path string + } +) + +func newBuildingHistory() buildingHistory { + return buildingHistory{items: make(map[string][]historyItem)} +} + +func (bh *buildingHistory) Append(templateName, pageCode, message, path string) { + if _, ok := bh.items[templateName]; !ok { + bh.items[templateName] = make([]historyItem, 0) + } + + bh.items[templateName] = append(bh.items[templateName], historyItem{ + Code: pageCode, + Message: message, + Path: path, + }) + + sort.Slice(bh.items[templateName], func(i, j int) bool { // keep history items sorted + return bh.items[templateName][i].Code < bh.items[templateName][j].Code + }) +} + +//go:embed index.tpl.html +var indexPageTemplate string + +func (bh *buildingHistory) WriteIndexFile(path string, perm os.FileMode) error { + t, err := template.New("index").Parse(indexPageTemplate) + if err != nil { + return err + } + + var buf bytes.Buffer + + if err = t.Execute(&buf, bh.items); err != nil { + return err + } + + defer buf.Reset() // optimization (is needed here?) + + return os.WriteFile(path, buf.Bytes(), perm) +} diff --git a/internal/cli/build/index.tpl.html b/internal/cli/build/index.tpl.html new file mode 100644 index 0000000..a8dfe0f --- /dev/null +++ b/internal/cli/build/index.tpl.html @@ -0,0 +1,35 @@ + + + + + Error pages list + + + +
+
+
+ +

Error pages index

+
+ {{- range $template, $item := . -}} +

Template name: {{ $template }}

+ + {{ end }} +
+
+ + + diff --git a/internal/cli/healthcheck/command_test.go b/internal/cli/healthcheck/command_test.go index 233aa90..0a12053 100644 --- a/internal/cli/healthcheck/command_test.go +++ b/internal/cli/healthcheck/command_test.go @@ -16,6 +16,8 @@ type fakeChecker struct{ err error } func (c *fakeChecker) Check(port uint16) error { return c.err } func TestProperties(t *testing.T) { + t.Parallel() + cmd := healthcheck.NewCommand(&fakeChecker{err: nil}) assert.Equal(t, "healthcheck", cmd.Use) diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index e023fc6..78398f6 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -9,6 +9,8 @@ import ( ) func TestSubcommands(t *testing.T) { + t.Parallel() + cmd := cli.NewCommand("unit test") cases := []struct { @@ -29,6 +31,8 @@ func TestSubcommands(t *testing.T) { for _, tt := range cases { tt := tt t.Run(tt.giveName, func(t *testing.T) { + t.Parallel() + if _, exists := subcommands[tt.giveName]; !exists { assert.Failf(t, "command not found", "command [%s] was not found", tt.giveName) } @@ -37,6 +41,8 @@ func TestSubcommands(t *testing.T) { } func TestFlags(t *testing.T) { + t.Parallel() + cmd := cli.NewCommand("unit test") cases := []struct { @@ -53,6 +59,8 @@ func TestFlags(t *testing.T) { for _, tt := range cases { tt := tt t.Run(tt.giveName, func(t *testing.T) { + t.Parallel() + flag := cmd.Flag(tt.giveName) if flag == nil { @@ -68,6 +76,8 @@ func TestFlags(t *testing.T) { } func TestExecuting(t *testing.T) { + t.Parallel() + cmd := cli.NewCommand("unit test") cmd.SetArgs([]string{}) diff --git a/internal/cli/serve/command.go b/internal/cli/serve/command.go index 6bc481b..cd94ff2 100644 --- a/internal/cli/serve/command.go +++ b/internal/cli/serve/command.go @@ -4,7 +4,6 @@ import ( "context" "errors" "os" - "sort" "time" "github.com/spf13/cobra" @@ -12,7 +11,6 @@ import ( "github.com/tarampampam/error-pages/internal/config" appHttp "github.com/tarampampam/error-pages/internal/http" "github.com/tarampampam/error-pages/internal/pick" - "github.com/tarampampam/error-pages/internal/tpl" "go.uber.org/zap" ) @@ -27,23 +25,17 @@ func NewCommand(ctx context.Context, log *zap.Logger, configFile *string) *cobra Use: "serve", Aliases: []string{"s", "server"}, Short: "Start HTTP server", - PreRunE: func(cmd *cobra.Command, _ []string) error { + PreRunE: func(cmd *cobra.Command, _ []string) (err error) { if configFile == nil { return errors.New("path to the config file is required for this command") } - if err := f.overrideUsingEnv(cmd.Flags()); err != nil { + if err = f.overrideUsingEnv(cmd.Flags()); err != nil { return err } - if c, err := config.FromYamlFile(*configFile); err != nil { + if cfg, err = config.FromYamlFile(*configFile); err != nil { return err - } else { - if err = c.Validate(); err != nil { - return err - } - - cfg = c } return f.validate() @@ -76,35 +68,10 @@ func run(parentCtx context.Context, log *zap.Logger, f flags, cfg *config.Config }() var ( - errorPages = tpl.NewErrorPages() - templateNames = make([]string, 0) // slice with all possible template names + templateNames = cfg.TemplateNames() + picker *pick.StringsSlice ) - log.Debug("Loading templates") - - if templates, err := cfg.LoadTemplates(); err == nil { - if len(templates) > 0 { - for templateName, content := range templates { - errorPages.AddTemplate(templateName, content) - templateNames = append(templateNames, templateName) - } - - for code, desc := range cfg.Pages { - errorPages.AddPage(code, desc.Message, desc.Description) - } - - log.Info("Templates loaded", zap.Int("templates", len(templates)), zap.Int("pages", len(cfg.Pages))) - } else { - return errors.New("no loaded templates") - } - } else { - return err - } - - sort.Strings(templateNames) // sorting is important for the first template picking - - var picker *pick.StringsSlice - switch f.template.name { case useRandomTemplate: log.Info("A random template will be used") @@ -122,29 +89,19 @@ func run(parentCtx context.Context, log *zap.Logger, f flags, cfg *config.Config picker = pick.NewStringsSlice(templateNames, pick.First) default: - var found bool - - for i := 0; i < len(templateNames); i++ { - if templateNames[i] == f.template.name { - found = true - - break - } - } - - if !found { + if t, found := cfg.Template(f.template.name); found { + log.Info("We will use the requested template", zap.String("name", t.Name())) + picker = pick.NewStringsSlice([]string{t.Name()}, pick.First) + } else { return errors.New("requested nonexistent template: " + f.template.name) } - - log.Info("We will use the requested template", zap.String("name", f.template.name)) - picker = pick.NewStringsSlice([]string{f.template.name}, pick.First) } // create HTTP server server := appHttp.NewServer(log) // register server routes, middlewares, etc. - server.Register(&errorPages, picker, f.defaultErrorPage, f.defaultHTTPCode) + server.Register(cfg, picker, f.defaultErrorPage, f.defaultHTTPCode, f.showDetails) startedAt, startingErrCh := time.Now(), make(chan error, 1) // channel for server starting error @@ -157,6 +114,7 @@ func run(parentCtx context.Context, log *zap.Logger, f flags, cfg *config.Config zap.Uint16("port", f.listen.port), zap.String("default error page", f.defaultErrorPage), zap.Uint16("default HTTP response code", f.defaultHTTPCode), + zap.Bool("show request details", f.showDetails), ) if err := server.Start(f.listen.ip, f.listen.port); err != nil { diff --git a/internal/cli/serve/command_test.go b/internal/cli/serve/command_test.go index 2882055..8b48ee1 100644 --- a/internal/cli/serve/command_test.go +++ b/internal/cli/serve/command_test.go @@ -3,5 +3,7 @@ package serve_test import "testing" func TestNothing(t *testing.T) { + t.Parallel() + t.Skip("tests for this package have not been implemented yet") } diff --git a/internal/cli/serve/flags.go b/internal/cli/serve/flags.go index 1c368bf..e138f59 100644 --- a/internal/cli/serve/flags.go +++ b/internal/cli/serve/flags.go @@ -20,6 +20,7 @@ type flags struct { } defaultErrorPage string defaultHTTPCode uint16 + showDetails bool } const ( @@ -28,6 +29,7 @@ const ( templateNameFlagName = "template-name" defaultErrorPageFlagName = "default-error-page" defaultHTTPCodeFlagName = "default-http-code" + showDetailsFlagName = "show-details" ) const ( @@ -69,9 +71,15 @@ func (f *flags) init(flagSet *pflag.FlagSet) { 404, //nolint:gomnd fmt.Sprintf("default HTTP response code [$%s]", env.DefaultHTTPCode), ) + flagSet.BoolVarP( + &f.showDetails, + showDetailsFlagName, "", + false, + fmt.Sprintf("show request details in response [$%s]", env.ShowDetails), + ) } -func (f *flags) overrideUsingEnv(flagSet *pflag.FlagSet) (lastErr error) { //nolint:gocognit +func (f *flags) overrideUsingEnv(flagSet *pflag.FlagSet) (lastErr error) { //nolint:gocognit,gocyclo flagSet.VisitAll(func(flag *pflag.Flag) { // flag was NOT defined using CLI (flags should have maximal priority) if !flag.Changed { //nolint:nestif @@ -108,6 +116,13 @@ func (f *flags) overrideUsingEnv(flagSet *pflag.FlagSet) (lastErr error) { //nol lastErr = fmt.Errorf("wrong default HTTP response code environment variable [%s] value", envVar) } } + + case showDetailsFlagName: + if envVar, exists := env.ShowDetails.Lookup(); exists { + if b, err := strconv.ParseBool(envVar); err == nil { + f.showDetails = b + } + } } } }) diff --git a/internal/cli/version/command_test.go b/internal/cli/version/command_test.go index 27db5bc..98e95f4 100644 --- a/internal/cli/version/command_test.go +++ b/internal/cli/version/command_test.go @@ -10,6 +10,8 @@ import ( ) func TestProperties(t *testing.T) { + t.Parallel() + cmd := version.NewCommand("") assert.Equal(t, "version", cmd.Use) @@ -18,6 +20,8 @@ func TestProperties(t *testing.T) { } func TestCommandRun(t *testing.T) { + t.Parallel() + cmd := version.NewCommand("1.2.3@foobar") cmd.SetArgs([]string{}) diff --git a/internal/config/config.go b/internal/config/config.go index b9eb7fa..5eeb995 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -2,6 +2,8 @@ package config import ( "io/ioutil" + "os" + "path" "path/filepath" "strconv" "strings" @@ -11,20 +13,116 @@ import ( "gopkg.in/yaml.v3" ) +// Config is a main (exportable) config struct. type Config struct { + Templates []Template + Pages map[string]Page // map key is a page code + Formats map[string]Format // map key is a format name +} + +// Template returns a Template with the passes name. +func (c *Config) Template(name string) (*Template, bool) { + for i := 0; i < len(c.Templates); i++ { + if c.Templates[i].name == name { + return &c.Templates[i], true + } + } + + return &Template{}, false +} + +func (c *Config) JSONFormat() (*Format, bool) { return c.format("json") } +func (c *Config) XMLFormat() (*Format, bool) { return c.format("xml") } + +func (c *Config) format(name string) (*Format, bool) { + if f, ok := c.Formats[name]; ok { + if len(f.content) > 0 { + return &f, true + } + } + + return &Format{}, false +} + +// TemplateNames returns all template names. +func (c *Config) TemplateNames() []string { + n := make([]string, len(c.Templates)) + + for i, t := range c.Templates { + n[i] = t.name + } + + return n +} + +// Template describes HTTP error page template. +type Template struct { + name string + content []byte +} + +// Name returns the name of the template. +func (t Template) Name() string { return t.name } + +// Content returns the template content. +func (t Template) Content() []byte { return t.content } + +func (t *Template) loadContentFromFile(filePath string) (err error) { + if t.content, err = ioutil.ReadFile(filePath); err != nil { + return errors.Wrap(err, "cannot load content for the template "+t.Name()+" from file "+filePath) + } + + return +} + +// Page describes error page. +type Page struct { + code string + message string + description string +} + +// Code returns the code of the Page. +func (p Page) Code() string { return p.code } + +// Message returns the message of the Page. +func (p Page) Message() string { return p.message } + +// Description returns the description of the Page. +func (p Page) Description() string { return p.description } + +// Format describes different response formats. +type Format struct { + name string + content []byte +} + +// Name returns the name of the format. +func (f Format) Name() string { return f.name } + +// Content returns the format content. +func (f Format) Content() []byte { return f.content } + +// config is internal struct for marshaling/unmarshaling configuration file content. +type config struct { Templates []struct { Path string `yaml:"path"` Name string `yaml:"name"` Content string `yaml:"content"` } `yaml:"templates"` + + Formats map[string]struct { + Content string `yaml:"content"` + } `yaml:"formats"` + Pages map[string]struct { Message string `yaml:"message"` Description string `yaml:"description"` } `yaml:"pages"` } -// Validate the config and return an error if something is wrong. -func (c Config) Validate() error { +// Validate the config struct and return an error if something is wrong. +func (c config) Validate() error { if len(c.Templates) == 0 { return errors.New("empty templates list") } else { @@ -53,64 +151,106 @@ func (c Config) Validate() error { } } + if len(c.Formats) > 0 { + for name := range c.Formats { + if name == "" { + return errors.New("empty format name") + } + + if strings.ContainsRune(name, ' ') { + return errors.New("format should not contain whitespaces") + } + } + } + return nil } -// LoadTemplates loading templates content from the local files and return it. -func (c Config) LoadTemplates() (map[string][]byte, error) { - var templates = make(map[string][]byte) +// Export the config struct into Config. +func (c *config) Export() (*Config, error) { + cfg := &Config{} + + cfg.Templates = make([]Template, 0, len(c.Templates)) for i := 0; i < len(c.Templates); i++ { - var name string - - if c.Templates[i].Name == "" { - basename := filepath.Base(c.Templates[i].Path) - name = strings.TrimSuffix(basename, filepath.Ext(basename)) - } else { - name = c.Templates[i].Name - } - - var content []byte + tpl := Template{name: c.Templates[i].Name} if c.Templates[i].Content == "" { - b, err := ioutil.ReadFile(c.Templates[i].Path) - if err != nil { - return nil, errors.Wrap(err, "cannot load content for the template "+name) + if c.Templates[i].Path == "" { + return nil, errors.New("path to the template " + c.Templates[i].Name + " not provided") } - content = b + if err := tpl.loadContentFromFile(c.Templates[i].Path); err != nil { + return nil, err + } } else { - content = []byte(c.Templates[i].Content) + tpl.content = []byte(c.Templates[i].Content) } - templates[name] = content + cfg.Templates = append(cfg.Templates, tpl) } - return templates, nil + cfg.Pages = make(map[string]Page, len(c.Pages)) + + for code, p := range c.Pages { + cfg.Pages[code] = Page{code: code, message: p.Message, description: p.Description} + } + + cfg.Formats = make(map[string]Format, len(c.Formats)) + + for name, f := range c.Formats { + cfg.Formats[name] = Format{name: name, content: []byte(strings.TrimSpace(f.Content))} + } + + return cfg, nil } -// FromYaml creates new config instance using YAML-structured content. -func FromYaml(in []byte) (cfg *Config, err error) { - cfg = &Config{} - +// FromYaml creates new Config instance using YAML-structured content. +func FromYaml(in []byte) (_ *Config, err error) { in, err = envsubst.Bytes(in) if err != nil { return nil, err } - if err = yaml.Unmarshal(in, cfg); err != nil { + c := &config{} + + if err = yaml.Unmarshal(in, c); err != nil { return nil, errors.Wrap(err, "cannot parse configuration file") } - return + var basename string + + for i := 0; i < len(c.Templates); i++ { + if c.Templates[i].Name == "" { // set the template name from file path + basename = filepath.Base(c.Templates[i].Path) + c.Templates[i].Name = strings.TrimSuffix(basename, filepath.Ext(basename)) + } + } + + if err = c.Validate(); err != nil { + return nil, err + } + + return c.Export() } -// FromYamlFile creates new config instance using YAML file. +// FromYamlFile creates new Config instance using YAML file. func FromYamlFile(filepath string) (*Config, error) { bytes, err := ioutil.ReadFile(filepath) if err != nil { return nil, errors.Wrap(err, "cannot read configuration file") } + // the following code makes it possible to use the relative links in the config file (`.` means "directory with + // the config file") + cwd, err := os.Getwd() + if err == nil { + if err = os.Chdir(path.Dir(filepath)); err != nil { + return nil, err + } + + defer func() { _ = os.Chdir(cwd) }() + } + return FromYaml(bytes) } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 48930b3..2dda2c7 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1,7 +1,6 @@ package config_test import ( - "errors" "os" "testing" @@ -9,196 +8,37 @@ import ( "github.com/tarampampam/error-pages/internal/config" ) -func TestConfig_Validate(t *testing.T) { - for name, tt := range map[string]struct { - giveConfig func() config.Config - wantError error - }{ - "valid": { - giveConfig: func() config.Config { - c := config.Config{} - - c.Templates = []struct { - Path string `yaml:"path"` - Name string `yaml:"name"` - Content string `yaml:"content"` - }{ - {"foo", "bar", "baz"}, - } - - c.Pages = map[string]struct { - Message string `yaml:"message"` - Description string `yaml:"description"` - }{ - "400": {"Bad Request", "The server did not understand the request"}, - } - - return c - }, - wantError: nil, - }, - "empty templates list": { - giveConfig: func() config.Config { - return config.Config{} - }, - wantError: errors.New("empty templates list"), - }, - "empty path and name": { - giveConfig: func() config.Config { - c := config.Config{} - - c.Templates = []struct { - Path string `yaml:"path"` - Name string `yaml:"name"` - Content string `yaml:"content"` - }{ - { - Path: "foo", - Name: "bar", - Content: "baz", - }, - { - Path: "", - Name: "", - Content: "blah", - }, - } - - return c - }, - wantError: errors.New("empty path and name with index 1"), - }, - "empty path and template content": { - giveConfig: func() config.Config { - c := config.Config{} - - c.Templates = []struct { - Path string `yaml:"path"` - Name string `yaml:"name"` - Content string `yaml:"content"` - }{ - { - Path: "foo", - Name: "bar", - Content: "baz", - }, - { - Path: "", - Name: "blah", - Content: "", - }, - } - - return c - }, - wantError: errors.New("empty path and template content with index 1"), - }, - "empty pages list": { - giveConfig: func() config.Config { - c := config.Config{} - - c.Templates = []struct { - Path string `yaml:"path"` - Name string `yaml:"name"` - Content string `yaml:"content"` - }{ - {"foo", "bar", "baz"}, - } - - c.Pages = map[string]struct { - Message string `yaml:"message"` - Description string `yaml:"description"` - }{} - - return c - }, - wantError: errors.New("empty pages list"), - }, - "empty page code": { - giveConfig: func() config.Config { - c := config.Config{} - - c.Templates = []struct { - Path string `yaml:"path"` - Name string `yaml:"name"` - Content string `yaml:"content"` - }{ - {"foo", "bar", "baz"}, - } - - c.Pages = map[string]struct { - Message string `yaml:"message"` - Description string `yaml:"description"` - }{ - "": {"foo", "bar"}, - } - - return c - }, - wantError: errors.New("empty page code"), - }, - "code should not contain whitespaces": { - giveConfig: func() config.Config { - c := config.Config{} - - c.Templates = []struct { - Path string `yaml:"path"` - Name string `yaml:"name"` - Content string `yaml:"content"` - }{ - {"foo", "bar", "baz"}, - } - - c.Pages = map[string]struct { - Message string `yaml:"message"` - Description string `yaml:"description"` - }{ - " 123": {"foo", "bar"}, - } - - return c - }, - wantError: errors.New("code should not contain whitespaces"), - }, - } { - tt := tt - - t.Run(name, func(t *testing.T) { - err := tt.giveConfig().Validate() - - if tt.wantError != nil { - assert.EqualError(t, err, tt.wantError.Error()) - } else { - assert.NoError(t, err) - } - }) - } -} - func TestFromYaml(t *testing.T) { - var cases = []struct { //nolint:maligned - name string + t.Parallel() + + var cases = map[string]struct { //nolint:maligned giveYaml []byte giveEnv map[string]string wantErr bool checkResultFn func(*testing.T, *config.Config) }{ - { - name: "with all possible values", + "with all possible values": { giveEnv: map[string]string{ - "__GHOST_PATH": "./templates/ghost.html", - "__GHOST_NAME": "ghost", + "__FOO_TPL_PATH": "./testdata/foo-tpl.html", + "__FOO_TPL_NAME": "Foo Template", }, giveYaml: []byte(` templates: - - path: ${__GHOST_PATH} - name: ${__GHOST_NAME:-default_value} # name is optional - - path: ./templates/l7-light.html - - name: Foo + - path: ${__FOO_TPL_PATH} + name: ${__FOO_TPL_NAME:-default_value} # name is optional + - path: ./testdata/bar-tpl.html + - name: Baz content: | - Some content + Some content {{ code }} New line +formats: + json: + content: | + {"code": "{{code}}"} + Avada_Kedavra: + content: "{{ message }}" + pages: 400: message: Bad Request @@ -211,33 +51,68 @@ pages: wantErr: false, checkResultFn: func(t *testing.T, cfg *config.Config) { assert.Len(t, cfg.Templates, 3) - assert.Equal(t, "./templates/ghost.html", cfg.Templates[0].Path) - assert.Equal(t, "ghost", cfg.Templates[0].Name) - assert.Equal(t, "", cfg.Templates[0].Content) - assert.Equal(t, "./templates/l7-light.html", cfg.Templates[1].Path) - assert.Equal(t, "", cfg.Templates[1].Name) - assert.Equal(t, "", cfg.Templates[1].Content) - assert.Equal(t, "", cfg.Templates[2].Path) - assert.Equal(t, "Foo", cfg.Templates[2].Name) - assert.Equal(t, "Some content\nNew line\n", cfg.Templates[2].Content) + + tpl, found := cfg.Template("Foo Template") + assert.True(t, found) + assert.Equal(t, "Foo Template", tpl.Name()) + assert.Equal(t, "foo {{ code }}\n", string(tpl.Content())) + + tpl, found = cfg.Template("bar-tpl") + assert.True(t, found) + assert.Equal(t, "bar-tpl", tpl.Name()) + assert.Equal(t, "bar {{ code }}\n", string(tpl.Content())) + + tpl, found = cfg.Template("Baz") + assert.True(t, found) + assert.Equal(t, "Baz", tpl.Name()) + assert.Equal(t, "Some content {{ code }}\nNew line\n", string(tpl.Content())) + + tpl, found = cfg.Template("NonExists") + assert.False(t, found) + assert.Equal(t, "", tpl.Name()) + assert.Equal(t, "", string(tpl.Content())) + + assert.Len(t, cfg.Formats, 2) + + format, found := cfg.Formats["json"] + assert.True(t, found) + assert.Equal(t, `{"code": "{{code}}"}`, string(format.Content())) + + format, found = cfg.Formats["Avada_Kedavra"] + assert.True(t, found) + assert.Equal(t, "{{ message }}", string(format.Content())) assert.Len(t, cfg.Pages, 2) - assert.Equal(t, "Bad Request", cfg.Pages["400"].Message) - assert.Equal(t, "The server did not understand the request", cfg.Pages["400"].Description) - assert.Equal(t, "Unauthorized", cfg.Pages["401"].Message) - assert.Equal(t, "The requested page needs a username and a password", cfg.Pages["401"].Description) + + errPage, found := cfg.Pages["400"] + assert.True(t, found) + assert.Equal(t, "400", errPage.Code()) + assert.Equal(t, "Bad Request", errPage.Message()) + assert.Equal(t, "The server did not understand the request", errPage.Description()) + + errPage, found = cfg.Pages["401"] + assert.True(t, found) + assert.Equal(t, "401", errPage.Code()) + assert.Equal(t, "Unauthorized", errPage.Message()) + assert.Equal(t, "The requested page needs a username and a password", errPage.Description()) + + errPage, found = cfg.Pages["666"] + assert.False(t, found) + assert.Equal(t, "", errPage.Message()) + assert.Equal(t, "", errPage.Code()) + assert.Equal(t, "", errPage.Description()) }, }, - { - name: "broken yaml", + "broken yaml": { giveYaml: []byte(`foo bar`), wantErr: true, }, } - for _, tt := range cases { - tt := tt - t.Run(tt.name, func(t *testing.T) { + for name, tt := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + if tt.giveEnv != nil { for key, value := range tt.giveEnv { assert.NoError(t, os.Setenv(key, value)) @@ -263,45 +138,54 @@ pages: } func TestFromYamlFile(t *testing.T) { - var cases = []struct { //nolint:maligned - name string + var cases = map[string]struct { //nolint:maligned giveYamlFilePath string wantErr bool checkResultFn func(*testing.T, *config.Config) }{ - { - name: "with all possible values", + "with all possible values": { giveYamlFilePath: "./testdata/simple.yml", wantErr: false, checkResultFn: func(t *testing.T, cfg *config.Config) { assert.Len(t, cfg.Templates, 2) - assert.Equal(t, "./templates/ghost.html", cfg.Templates[0].Path) - assert.Equal(t, "ghost", cfg.Templates[0].Name) - assert.Equal(t, "./templates/l7-light.html", cfg.Templates[1].Path) - assert.Equal(t, "", cfg.Templates[1].Name) + + tpl, found := cfg.Template("ghost") + assert.True(t, found) + assert.Equal(t, "ghost", tpl.Name()) + assert.Equal(t, "foo {{ code }}\n", string(tpl.Content())) + + tpl, found = cfg.Template("bar-tpl") + assert.True(t, found) + assert.Equal(t, "bar-tpl", tpl.Name()) + assert.Equal(t, "bar {{ code }}\n", string(tpl.Content())) assert.Len(t, cfg.Pages, 2) - assert.Equal(t, "Bad Request", cfg.Pages["400"].Message) - assert.Equal(t, "The server did not understand the request", cfg.Pages["400"].Description) - assert.Equal(t, "Unauthorized", cfg.Pages["401"].Message) - assert.Equal(t, "The requested page needs a username and a password", cfg.Pages["401"].Description) + + errPage, found := cfg.Pages["400"] + assert.True(t, found) + assert.Equal(t, "400", errPage.Code()) + assert.Equal(t, "Bad Request", errPage.Message()) + assert.Equal(t, "The server did not understand the request", errPage.Description()) + + errPage, found = cfg.Pages["401"] + assert.True(t, found) + assert.Equal(t, "401", errPage.Code()) + assert.Equal(t, "Unauthorized", errPage.Message()) + assert.Equal(t, "The requested page needs a username and a password", errPage.Description()) }, }, - { - name: "broken yaml", + "broken yaml": { giveYamlFilePath: "./testdata/broken.yml", wantErr: true, }, - { - name: "wrong file path", + "wrong file path": { giveYamlFilePath: "foo bar", wantErr: true, }, } - for _, tt := range cases { - tt := tt - t.Run(tt.name, func(t *testing.T) { + for name, tt := range cases { + t.Run(name, func(t *testing.T) { conf, err := config.FromYamlFile(tt.giveYamlFilePath) if tt.wantErr { diff --git a/internal/config/testdata/bar-tpl.html b/internal/config/testdata/bar-tpl.html new file mode 100644 index 0000000..ffc4022 --- /dev/null +++ b/internal/config/testdata/bar-tpl.html @@ -0,0 +1 @@ +bar {{ code }} diff --git a/internal/config/testdata/foo-tpl.html b/internal/config/testdata/foo-tpl.html new file mode 100644 index 0000000..db5ed80 --- /dev/null +++ b/internal/config/testdata/foo-tpl.html @@ -0,0 +1 @@ +foo {{ code }} diff --git a/internal/config/testdata/simple.yml b/internal/config/testdata/simple.yml index ce84547..0ed3e7f 100644 --- a/internal/config/testdata/simple.yml +++ b/internal/config/testdata/simple.yml @@ -1,7 +1,7 @@ templates: - - path: ./templates/ghost.html + - path: ./foo-tpl.html name: ghost # name is optional - - path: ./templates/l7-light.html + - path: ./bar-tpl.html pages: 400: diff --git a/internal/env/env.go b/internal/env/env.go index 1088e27..f834f86 100644 --- a/internal/env/env.go +++ b/internal/env/env.go @@ -12,6 +12,7 @@ const ( ConfigFilePath envVariable = "CONFIG_FILE" // path to the config file DefaultErrorPage envVariable = "DEFAULT_ERROR_PAGE" // default error page (code) DefaultHTTPCode envVariable = "DEFAULT_HTTP_CODE" // default HTTP response code + ShowDetails envVariable = "SHOW_DETAILS" // show request details in response ) // String returns environment variable name in the string representation. diff --git a/internal/env/env_test.go b/internal/env/env_test.go index 018896c..192b648 100644 --- a/internal/env/env_test.go +++ b/internal/env/env_test.go @@ -8,12 +8,15 @@ import ( ) func TestConstants(t *testing.T) { + t.Parallel() + assert.Equal(t, "LISTEN_ADDR", string(ListenAddr)) assert.Equal(t, "LISTEN_PORT", string(ListenPort)) assert.Equal(t, "TEMPLATE_NAME", string(TemplateName)) assert.Equal(t, "CONFIG_FILE", string(ConfigFilePath)) assert.Equal(t, "DEFAULT_ERROR_PAGE", string(DefaultErrorPage)) assert.Equal(t, "DEFAULT_HTTP_CODE", string(DefaultHTTPCode)) + assert.Equal(t, "SHOW_DETAILS", string(ShowDetails)) } func TestEnvVariable_Lookup(t *testing.T) { @@ -26,6 +29,7 @@ func TestEnvVariable_Lookup(t *testing.T) { {giveEnv: ConfigFilePath}, {giveEnv: DefaultErrorPage}, {giveEnv: DefaultHTTPCode}, + {giveEnv: ShowDetails}, } for _, tt := range cases { diff --git a/internal/http/common/middlewares_test.go b/internal/http/common/middlewares_test.go index f65e6b8..7b2a699 100644 --- a/internal/http/common/middlewares_test.go +++ b/internal/http/common/middlewares_test.go @@ -3,5 +3,7 @@ package common_test import "testing" func TestNothing2(t *testing.T) { + t.Parallel() + t.Skip("tests for this package have not been implemented yet") } diff --git a/internal/http/core/errorpage.go b/internal/http/core/errorpage.go new file mode 100644 index 0000000..851df0a --- /dev/null +++ b/internal/http/core/errorpage.go @@ -0,0 +1,107 @@ +package core + +import ( + "strconv" + + "github.com/tarampampam/error-pages/internal/config" + "github.com/tarampampam/error-pages/internal/tpl" + "github.com/valyala/fasthttp" +) + +type templatePicker interface { + // Pick the template name for responding. + Pick() string +} + +func RespondWithErrorPage( //nolint:funlen + ctx *fasthttp.RequestCtx, + cfg *config.Config, + p templatePicker, + pageCode string, + httpCode int, + showRequestDetails bool, +) { + ctx.Response.Header.Set("X-Robots-Tag", "noindex") // block Search indexing + + var ( + clientWant = ClientWantFormat(ctx) + json, canJSON = cfg.JSONFormat() + xml, canXML = cfg.XMLFormat() + props = tpl.Properties{Code: pageCode, ShowRequestDetails: showRequestDetails} + ) + + if showRequestDetails { + props.OriginalURI = string(ctx.Request.Header.Peek(OriginalURI)) + props.Namespace = string(ctx.Request.Header.Peek(Namespace)) + props.IngressName = string(ctx.Request.Header.Peek(IngressName)) + props.ServiceName = string(ctx.Request.Header.Peek(ServiceName)) + props.ServicePort = string(ctx.Request.Header.Peek(ServicePort)) + props.RequestID = string(ctx.Request.Header.Peek(RequestID)) + } + + if page, exists := cfg.Pages[pageCode]; exists { + props.Message = page.Message() + props.Description = page.Description() + } else if c, err := strconv.Atoi(pageCode); err == nil { + if s := fasthttp.StatusMessage(c); s != "Unknown Status Code" { // as a fallback + props.Message = s + } + } + + SetClientFormat(ctx, PlainTextContentType) // set default content type + + if props.Message == "" { + ctx.SetStatusCode(fasthttp.StatusNotFound) + _, _ = ctx.WriteString("requested pageCode (" + pageCode + ") not available") + + return + } + + switch { + case clientWant == JSONContentType && canJSON: // JSON + { + SetClientFormat(ctx, JSONContentType) + + if content, err := tpl.Render(json.Content(), props); err == nil { + ctx.SetStatusCode(httpCode) + _, _ = ctx.Write(content) + } else { + ctx.SetStatusCode(fasthttp.StatusInternalServerError) + _, _ = ctx.WriteString("cannot render JSON template: " + err.Error()) + } + } + + case clientWant == XMLContentType && canXML: // XML + { + SetClientFormat(ctx, XMLContentType) + + if content, err := tpl.Render(xml.Content(), props); err == nil { + ctx.SetStatusCode(httpCode) + _, _ = ctx.Write(content) + } else { + ctx.SetStatusCode(fasthttp.StatusInternalServerError) + _, _ = ctx.WriteString("cannot render XML template: " + err.Error()) + } + } + + default: // HTML + { + SetClientFormat(ctx, HTMLContentType) + + var templateName = p.Pick() + + if template, exists := cfg.Template(templateName); exists { + if content, err := tpl.Render(template.Content(), props); err == nil { + ctx.SetStatusCode(httpCode) + _, _ = ctx.Write(content) + } else { + ctx.SetStatusCode(fasthttp.StatusInternalServerError) + _, _ = ctx.WriteString("cannot render HTML template: " + err.Error()) + } + } else { + ctx.SetStatusCode(fasthttp.StatusInternalServerError) + _, _ = ctx.WriteString("template " + templateName + " not exists") + } + } + } +} diff --git a/internal/http/core/formats.go b/internal/http/core/formats.go new file mode 100644 index 0000000..c7e2b15 --- /dev/null +++ b/internal/http/core/formats.go @@ -0,0 +1,62 @@ +package core + +import ( + "bytes" + + "github.com/valyala/fasthttp" +) + +type ContentType = byte + +const ( + UnknownContentType ContentType = iota // should be first + JSONContentType + XMLContentType + HTMLContentType + PlainTextContentType +) + +func ClientWantFormat(ctx *fasthttp.RequestCtx) ContentType { + var ( + ct = bytes.ToLower(ctx.Request.Header.ContentType()) + f = bytes.ToLower(ctx.Request.Header.Peek(FormatHeader)) // for the Ingress support + ) + + switch { + case bytes.Contains(f, []byte("json")), + bytes.Contains(ct, []byte("application/json")), + bytes.Contains(ct, []byte("text/json")): + return JSONContentType + + case bytes.Contains(f, []byte("xml")), + bytes.Contains(ct, []byte("application/xml")), + bytes.Contains(ct, []byte("text/xml")): + return XMLContentType + + case bytes.Contains(f, []byte("html")), + bytes.Contains(ct, []byte("text/html")): + return HTMLContentType + + case bytes.Contains(f, []byte("plain")), + bytes.Contains(ct, []byte("text/plain")): + return PlainTextContentType + } + + return UnknownContentType +} + +func SetClientFormat(ctx *fasthttp.RequestCtx, t ContentType) { + switch t { + case JSONContentType: + ctx.SetContentType("application/json; charset=utf-8") + + case XMLContentType: + ctx.SetContentType("application/xml; charset=utf-8") + + case HTMLContentType: + ctx.SetContentType("text/html; charset=utf-8") + + case PlainTextContentType: + ctx.SetContentType("text/plain; charset=utf-8") + } +} diff --git a/internal/http/core/formats_test.go b/internal/http/core/formats_test.go new file mode 100644 index 0000000..f4a28d6 --- /dev/null +++ b/internal/http/core/formats_test.go @@ -0,0 +1,110 @@ +package core_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/tarampampam/error-pages/internal/http/core" + "github.com/valyala/fasthttp" +) + +func TestClientWantFormat(t *testing.T) { + t.Parallel() + + for name, tt := range map[string]struct { + giveContentTypeHeader string + giveFormatHeader string + giveReqCtx func() *fasthttp.RequestCtx + wantFormat core.ContentType + }{ + "content type - application/json": { + giveContentTypeHeader: "application/jsoN; charset=utf-8", wantFormat: core.JSONContentType, + }, + "content type - text/json": { + giveContentTypeHeader: "text/Json; charset=utf-8", wantFormat: core.JSONContentType, + }, + "format - json": { + giveFormatHeader: "jsOn", wantFormat: core.JSONContentType, + }, + + "content type - application/xml": { + giveContentTypeHeader: "application/xmL; charset=utf-8", wantFormat: core.XMLContentType, + }, + "content type - text/xml": { + giveContentTypeHeader: "text/Xml; charset=utf-8", wantFormat: core.XMLContentType, + }, + "format - xml": { + giveFormatHeader: "xMl", wantFormat: core.XMLContentType, + }, + + "content type - text/html": { + giveContentTypeHeader: "text/htMl; charset=utf-8", wantFormat: core.HTMLContentType, + }, + "format - html": { + giveFormatHeader: "HtmL", wantFormat: core.HTMLContentType, + }, + + "content type - text/plain": { + giveContentTypeHeader: "text/plaiN; charset=utf-8", wantFormat: core.PlainTextContentType, + }, + "format - plain": { + giveFormatHeader: "PLAIN", wantFormat: core.PlainTextContentType, + }, + + "unknown on empty": { + wantFormat: core.UnknownContentType, + }, + "unknown on foo/bar": { + giveContentTypeHeader: "foo/bar; charset=utf-8", + giveFormatHeader: "foo/bar; charset=utf-8", + wantFormat: core.UnknownContentType, + }, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + h := &fasthttp.RequestHeader{} + h.Set(fasthttp.HeaderContentType, tt.giveContentTypeHeader) + h.Set(core.FormatHeader, tt.giveFormatHeader) + + ctx := &fasthttp.RequestCtx{ + Request: fasthttp.Request{ + Header: *h, //nolint:govet + }, + } + + assert.Equal(t, tt.wantFormat, core.ClientWantFormat(ctx)) + }) + } +} + +func TestSetClientFormat(t *testing.T) { + t.Parallel() + + for name, tt := range map[string]struct { + giveContentType core.ContentType + wantHeaderValue string + }{ + "plain on unknown": {giveContentType: core.UnknownContentType, wantHeaderValue: "text/plain; charset=utf-8"}, + "json": {giveContentType: core.JSONContentType, wantHeaderValue: "application/json; charset=utf-8"}, + "xml": {giveContentType: core.XMLContentType, wantHeaderValue: "application/xml; charset=utf-8"}, + "html": {giveContentType: core.HTMLContentType, wantHeaderValue: "text/html; charset=utf-8"}, + "plain": {giveContentType: core.PlainTextContentType, wantHeaderValue: "text/plain; charset=utf-8"}, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + ctx := &fasthttp.RequestCtx{ + Response: fasthttp.Response{ + Header: fasthttp.ResponseHeader{}, + }, + } + + assert.Empty(t, "", ctx.Response.Header.Peek(fasthttp.HeaderContentType)) + + core.SetClientFormat(ctx, tt.giveContentType) + + assert.Equal(t, tt.wantHeaderValue, string(ctx.Response.Header.Peek(fasthttp.HeaderContentType))) + }) + } +} diff --git a/internal/http/core/headers.go b/internal/http/core/headers.go new file mode 100644 index 0000000..bbd9f64 --- /dev/null +++ b/internal/http/core/headers.go @@ -0,0 +1,27 @@ +package core + +const ( + // FormatHeader name of the header used to extract the format + FormatHeader = "X-Format" + + // CodeHeader name of the header used as source of the HTTP status code to return + CodeHeader = "X-Code" + + // OriginalURI name of the header with the original URL from NGINX + OriginalURI = "X-Original-URI" + + // Namespace name of the header that contains information about the Ingress namespace + Namespace = "X-Namespace" + + // IngressName name of the header that contains the matched Ingress + IngressName = "X-Ingress-Name" + + // ServiceName name of the header that contains the matched Service in the Ingress + ServiceName = "X-Service-Name" + + // ServicePort name of the header that contains the matched Service port in the Ingress + ServicePort = "X-Service-Port" + + // RequestID is a unique ID that identifies the request - same as for backend service + RequestID = "X-Request-ID" +) diff --git a/internal/http/handlers/errorpage/handler.go b/internal/http/handlers/errorpage/handler.go index a38fd37..02b89de 100644 --- a/internal/http/handlers/errorpage/handler.go +++ b/internal/http/handlers/errorpage/handler.go @@ -1,38 +1,26 @@ package errorpage import ( + "github.com/tarampampam/error-pages/internal/config" + "github.com/tarampampam/error-pages/internal/http/core" "github.com/valyala/fasthttp" ) -type ( - errorsPager interface { - // GetPage with passed template name and error code. - GetPage(templateName, code string) ([]byte, error) - } - - templatePicker interface { - // Pick the template name for responding. - Pick() string - } -) +type templatePicker interface { + // Pick the template name for responding. + Pick() string +} // NewHandler creates handler for error pages serving. -func NewHandler(e errorsPager, p templatePicker) fasthttp.RequestHandler { +func NewHandler(cfg *config.Config, p templatePicker, showRequestDetails bool) fasthttp.RequestHandler { return func(ctx *fasthttp.RequestCtx) { - ctx.SetContentType("text/plain; charset=utf-8") // default content type + core.SetClientFormat(ctx, core.PlainTextContentType) // default content type if code, ok := ctx.UserValue("code").(string); ok { - if content, err := e.GetPage(p.Pick(), code); err == nil { - ctx.SetStatusCode(fasthttp.StatusOK) - ctx.SetContentType("text/html; charset=utf-8") - _, _ = ctx.Write(content) - } else { - ctx.SetStatusCode(fasthttp.StatusNotFound) - _, _ = ctx.WriteString("requested code not available: " + err.Error()) // TODO customize the output? - } - } else { // will never happen + core.RespondWithErrorPage(ctx, cfg, p, code, fasthttp.StatusOK, showRequestDetails) + } else { // will never occur ctx.SetStatusCode(fasthttp.StatusInternalServerError) - _, _ = ctx.WriteString("cannot extract requested code from the request") // TODO customize the output? + _, _ = ctx.WriteString("cannot extract requested code from the request") } } } diff --git a/internal/http/handlers/errorpage/handler_test.go b/internal/http/handlers/errorpage/handler_test.go index 5c974ff..a262578 100644 --- a/internal/http/handlers/errorpage/handler_test.go +++ b/internal/http/handlers/errorpage/handler_test.go @@ -3,5 +3,7 @@ package errorpage_test import "testing" func TestNothing(t *testing.T) { + t.Parallel() + t.Skip("tests for this package have not been implemented yet") } diff --git a/internal/http/handlers/healthz/handler.go b/internal/http/handlers/healthz/handler.go index 0e55fac..a44a02b 100644 --- a/internal/http/handlers/healthz/handler.go +++ b/internal/http/handlers/healthz/handler.go @@ -19,5 +19,6 @@ func NewHandler(checker checker) fasthttp.RequestHandler { } ctx.SetStatusCode(fasthttp.StatusOK) + _, _ = ctx.WriteString("OK") } } diff --git a/internal/http/handlers/healthz/handler_test.go b/internal/http/handlers/healthz/handler_test.go index fbc25f2..585e1d2 100644 --- a/internal/http/handlers/healthz/handler_test.go +++ b/internal/http/handlers/healthz/handler_test.go @@ -3,5 +3,7 @@ package healthz_test import "testing" func TestNothing(t *testing.T) { + t.Parallel() + t.Skip("tests for this package have not been implemented yet") } diff --git a/internal/http/handlers/index/handler.go b/internal/http/handlers/index/handler.go index 652f627..3df5247 100644 --- a/internal/http/handlers/index/handler.go +++ b/internal/http/handlers/index/handler.go @@ -1,15 +1,14 @@ package index import ( + "strconv" + + "github.com/tarampampam/error-pages/internal/config" + "github.com/tarampampam/error-pages/internal/http/core" "github.com/valyala/fasthttp" ) type ( - errorsPager interface { - // GetPage with passed template name and error code. - GetPage(templateName, code string) ([]byte, error) - } - templatePicker interface { // Pick the template name for responding. Pick() string @@ -18,24 +17,33 @@ type ( // NewHandler creates handler for the index page serving. func NewHandler( - e errorsPager, + cfg *config.Config, p templatePicker, defaultPageCode string, defaultHTTPCode uint16, + showRequestDetails bool, ) fasthttp.RequestHandler { return func(ctx *fasthttp.RequestCtx) { - content, err := e.GetPage(p.Pick(), defaultPageCode) + pageCode, httpCode := defaultPageCode, int(defaultHTTPCode) - if err == nil { - ctx.SetContentType("text/html; charset=utf-8") - ctx.SetStatusCode(int(defaultHTTPCode)) - _, _ = ctx.Write(content) - - return + if returnCode, ok := extractCodeToReturn(ctx); ok { + pageCode, httpCode = strconv.Itoa(returnCode), returnCode } - ctx.SetContentType("text/plain; charset=utf-8") - ctx.SetStatusCode(fasthttp.StatusNotAcceptable) - _, _ = ctx.WriteString("default page code " + defaultPageCode + " is not available: " + err.Error()) + core.RespondWithErrorPage(ctx, cfg, p, pageCode, httpCode, showRequestDetails) } } + +func extractCodeToReturn(ctx *fasthttp.RequestCtx) (int, bool) { // for the Ingress support + var ch = ctx.Request.Header.Peek(core.CodeHeader) + + if len(ch) > 0 && len(ch) <= 3 { + if code, err := strconv.Atoi(string(ch)); err == nil { + if code > 0 && code <= 599 { + return code, true + } + } + } + + return 0, false +} diff --git a/internal/http/handlers/index/handler_test.go b/internal/http/handlers/index/handler_test.go index 4543537..4530471 100644 --- a/internal/http/handlers/index/handler_test.go +++ b/internal/http/handlers/index/handler_test.go @@ -3,5 +3,7 @@ package index_test import "testing" func TestNothing(t *testing.T) { + t.Parallel() + t.Skip("tests for this package have not been implemented yet") } diff --git a/internal/http/handlers/notfound/handler_test.go b/internal/http/handlers/notfound/handler_test.go index 3daed4d..e0b7c0b 100644 --- a/internal/http/handlers/notfound/handler_test.go +++ b/internal/http/handlers/notfound/handler_test.go @@ -3,5 +3,7 @@ package notfound_test import "testing" func TestNothing(t *testing.T) { + t.Parallel() + t.Skip("tests for this package have not been implemented yet") } diff --git a/internal/http/handlers/version/handler_test.go b/internal/http/handlers/version/handler_test.go index 72c102b..7014943 100644 --- a/internal/http/handlers/version/handler_test.go +++ b/internal/http/handlers/version/handler_test.go @@ -3,5 +3,7 @@ package version_test import "testing" func TestNothing(t *testing.T) { + t.Parallel() + t.Skip("tests for this package have not been implemented yet") } diff --git a/internal/http/server.go b/internal/http/server.go index 791599f..b8d97be 100644 --- a/internal/http/server.go +++ b/internal/http/server.go @@ -6,6 +6,7 @@ import ( "github.com/fasthttp/router" "github.com/tarampampam/error-pages/internal/checkers" + "github.com/tarampampam/error-pages/internal/config" "github.com/tarampampam/error-pages/internal/http/common" errorpageHandler "github.com/tarampampam/error-pages/internal/http/handlers/errorpage" healthzHandler "github.com/tarampampam/error-pages/internal/http/handlers/healthz" @@ -52,30 +53,27 @@ func (s *Server) Start(ip string, port uint16) error { return s.fast.ListenAndServe(ip + ":" + strconv.Itoa(int(port))) } -type ( - errorsPager interface { - // GetPage with passed template name and error code. - GetPage(templateName, code string) ([]byte, error) - } - - templatePicker interface { - // Pick the template name for responding. - Pick() string - } -) +type templatePicker interface { + // Pick the template name for responding. + Pick() string +} // Register server routes, middlewares, etc. // Router docs: func (s *Server) Register( - errorsPager errorsPager, + cfg *config.Config, templatePicker templatePicker, defaultPageCode string, defaultHTTPCode uint16, + showDetails bool, ) { - s.router.GET("/", indexHandler.NewHandler(errorsPager, templatePicker, defaultPageCode, defaultHTTPCode)) + s.router.GET("/", indexHandler.NewHandler(cfg, templatePicker, defaultPageCode, defaultHTTPCode, showDetails)) + s.router.GET("/{code}.html", errorpageHandler.NewHandler(cfg, templatePicker, showDetails)) s.router.GET("/version", versionHandler.NewHandler(version.Version())) - s.router.ANY("/health/live", healthzHandler.NewHandler(checkers.NewLiveChecker())) - s.router.GET("/{code}.html", errorpageHandler.NewHandler(errorsPager, templatePicker)) + + liveHandler := healthzHandler.NewHandler(checkers.NewLiveChecker()) + s.router.ANY("/healthz", liveHandler) + s.router.ANY("/health/live", liveHandler) // deprecated s.router.NotFound = notfoundHandler.NewHandler() } diff --git a/internal/http/server_test.go b/internal/http/server_test.go index 66c2992..5b4081f 100644 --- a/internal/http/server_test.go +++ b/internal/http/server_test.go @@ -3,5 +3,7 @@ package http import "testing" func TestNothing(t *testing.T) { + t.Parallel() + t.Skip("tests for this package have not been implemented yet") } diff --git a/internal/pick/picker.go b/internal/pick/picker.go new file mode 100644 index 0000000..be04aa7 --- /dev/null +++ b/internal/pick/picker.go @@ -0,0 +1,83 @@ +package pick + +import ( + "math/rand" + "sync" + "time" +) + +type pickMode = byte + +const ( + First pickMode = 1 + iota // Always pick the first element (index = 0) + RandomOnce // Pick random element once (any future Pick calls will return the same element) + RandomEveryTime // Always Pick the random element +) + +type picker struct { + mode pickMode + rand *rand.Rand // will be nil for the First pick mode + maxIdx uint32 + + mu sync.Mutex + lastIdx uint32 +} + +const unsetIdx uint32 = 4294967295 + +func NewPicker(maxIdx uint32, mode pickMode) *picker { + var p = &picker{ + maxIdx: maxIdx, + mode: mode, + lastIdx: unsetIdx, + } + + if mode != First { + p.rand = rand.New(rand.NewSource(time.Now().UnixNano())) //nolint:gosec + } + + return p +} + +// NextIndex returns an index for the next element (based on pickMode). +func (p *picker) NextIndex() uint32 { + if p.maxIdx == 0 { + return 0 + } + + switch p.mode { + case First: + return 0 + + case RandomOnce: + if p.lastIdx == unsetIdx { + p.mu.Lock() + defer p.mu.Unlock() + + p.lastIdx = uint32(p.rand.Intn(int(p.maxIdx))) + } + + return p.lastIdx + + case RandomEveryTime: + var idx = uint32(p.rand.Intn(int(p.maxIdx + 1))) + + p.mu.Lock() + defer p.mu.Unlock() + + if idx == p.lastIdx { + p.lastIdx++ + } else { + p.lastIdx = idx + } + + if p.lastIdx > p.maxIdx { // overflow? + p.lastIdx = 0 + } + + return p.lastIdx + + default: + panic("picker.NextIndex(): unsupported mode") + } +} diff --git a/internal/pick/picker_test.go b/internal/pick/picker_test.go new file mode 100644 index 0000000..abef747 --- /dev/null +++ b/internal/pick/picker_test.go @@ -0,0 +1,65 @@ +package pick_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/tarampampam/error-pages/internal/pick" +) + +func TestPicker_NextIndex_First(t *testing.T) { + t.Parallel() + + for i := uint32(0); i < 100; i++ { + p := pick.NewPicker(i, pick.First) + + for j := uint8(0); j < 100; j++ { + assert.Equal(t, uint32(0), p.NextIndex()) + } + } +} + +func TestPicker_NextIndex_RandomOnce(t *testing.T) { + t.Parallel() + + for i := uint8(0); i < 10; i++ { + assert.Equal(t, uint32(0), pick.NewPicker(0, pick.RandomOnce).NextIndex()) + } + + for i := uint8(10); i < 100; i++ { + p := pick.NewPicker(uint32(i), pick.RandomOnce) + + next := p.NextIndex() + assert.LessOrEqual(t, next, uint32(i)) + + for j := uint8(0); j < 100; j++ { + assert.Equal(t, next, p.NextIndex()) + } + } +} + +func TestPicker_NextIndex_RandomEveryTime(t *testing.T) { + t.Parallel() + + for i := uint8(0); i < 10; i++ { + assert.Equal(t, uint32(0), pick.NewPicker(0, pick.RandomEveryTime).NextIndex()) + } + + for i := uint8(1); i < 100; i++ { + p := pick.NewPicker(uint32(i), pick.RandomEveryTime) + + for j := uint8(0); j < 100; j++ { + one, two := p.NextIndex(), p.NextIndex() + + assert.LessOrEqual(t, one, uint32(i)) + assert.LessOrEqual(t, two, uint32(i)) + assert.NotEqual(t, one, two) + } + } +} + +func TestPicker_NextIndex_Unsupported(t *testing.T) { + t.Parallel() + + assert.Panics(t, func() { pick.NewPicker(1, 255).NextIndex() }) +} diff --git a/internal/pick/strings_slice.go b/internal/pick/strings_slice.go index 2226e1b..b6d0b93 100644 --- a/internal/pick/strings_slice.go +++ b/internal/pick/strings_slice.go @@ -1,64 +1,20 @@ package pick -import ( - "math/rand" - "time" -) - -type pickMode byte - -const ( - First pickMode = 1 + iota // Always pick the first element - RandomOnce // Pick random element once (any future Pick calls will return the same element) - RandomEveryTime // Always Pick the random element -) - type StringsSlice struct { - items []string - mode pickMode - lastUsedIdx int // -1 when unset, needed for RandomOnce mode - rnd *rand.Rand // will be nil for the First mode + s []string + p *picker } // NewStringsSlice creates new StringsSlice. func NewStringsSlice(items []string, mode pickMode) *StringsSlice { - var rnd *rand.Rand - - if mode != First { - rnd = rand.New(rand.NewSource(time.Now().UnixNano())) //nolint:gosec - } - - return &StringsSlice{ - items: items, - mode: mode, - lastUsedIdx: -1, - rnd: rnd, - } + return &StringsSlice{s: items, p: NewPicker(uint32(len(items)-1), mode)} } // Pick an element from the strings slice. func (s *StringsSlice) Pick() string { - if l := len(s.items); l == 0 { + if len(s.s) == 0 { return "" - } else if l == 1 { - return s.items[0] } - switch s.mode { - case First: - return s.items[0] - - case RandomOnce: - if s.lastUsedIdx == -1 { - s.lastUsedIdx = s.rnd.Intn(len(s.items)) - } - - return s.items[s.lastUsedIdx] - - case RandomEveryTime: - return s.items[s.rnd.Intn(len(s.items))] - - default: - panic("pick: unsupported mode") - } + return s.s[s.p.NextIndex()] } diff --git a/internal/pick/strings_slice_test.go b/internal/pick/strings_slice_test.go index 7b15d34..4b92ff7 100644 --- a/internal/pick/strings_slice_test.go +++ b/internal/pick/strings_slice_test.go @@ -1,102 +1,57 @@ package pick_test import ( - "math/rand" "testing" - "time" "github.com/stretchr/testify/assert" "github.com/tarampampam/error-pages/internal/pick" ) -func TestStringsSlice_Pick_First(t *testing.T) { - for name, items := range map[string][]string{ - "0 item": {}, - "1 item": {"foo"}, - "3 items": {"foo", "bar", "baz"}, - } { - t.Run(name, func(t *testing.T) { - p := pick.NewStringsSlice(items, pick.First) +func TestStringsSlice_Pick(t *testing.T) { + t.Parallel() - for i := 0; i < 100; i++ { - if len(items) == 0 { - assert.Equal(t, "", p.Pick()) - } else { - assert.Equal(t, "foo", p.Pick()) - } - } - }) - } -} + t.Run("first", func(t *testing.T) { + t.Parallel() -func TestStringsSlice_Pick_RandomOnce(t *testing.T) { - p := pick.NewStringsSlice([]string{}, pick.RandomOnce) - assert.Equal(t, "", p.Pick()) - - p = pick.NewStringsSlice([]string{"foo"}, pick.RandomOnce) - assert.Equal(t, "foo", p.Pick()) - - dataSet := randomStringsSlice(t, 2048) // if this test will fail - Increase this value - p = pick.NewStringsSlice(dataSet, pick.RandomOnce) - picked := p.Pick() - - assert.NotEqual(t, dataSet[0], p.Pick()) - - for i := 0; i < 32; i++ { - assert.Equal(t, picked, p.Pick()) - } -} - -func TestStringsSlice_Pick_RandomEveryTime(t *testing.T) { - p := pick.NewStringsSlice([]string{}, pick.RandomEveryTime) - assert.Equal(t, "", p.Pick()) - - p = pick.NewStringsSlice([]string{"foo"}, pick.RandomEveryTime) - assert.Equal(t, "foo", p.Pick()) - - dataSet := randomStringsSlice(t, 2048) // if this test will fail - Increase this value - p = pick.NewStringsSlice(dataSet, pick.RandomEveryTime) - - lastPicked := p.Pick() - - for i := 0; i < 32; i++ { - picked := p.Pick() - - assert.NotEqual(t, lastPicked, picked) - lastPicked = picked - } -} - -func TestStringsSlice_Pick_UnsupportedMode(t *testing.T) { - p := pick.NewStringsSlice([]string{}, 255) - assert.Equal(t, "", p.Pick()) - - p = pick.NewStringsSlice([]string{"foo"}, 255) - assert.Equal(t, "foo", p.Pick()) - - p = pick.NewStringsSlice([]string{"foo", "bar"}, 255) - - assert.Panics(t, func() { p.Pick() }) -} - -func randomStringsSlice(t *testing.T, itemsCount int) []string { - t.Helper() - - var ( - rnd = rand.New(rand.NewSource(time.Now().UnixNano())) //nolint:gosec - items = make([]string, itemsCount) - letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-+=") - ) - - for i := 0; i < len(items); i++ { - b := make([]rune, 32) - - for j := range b { - b[j] = letters[rnd.Intn(len(letters))] + for i := uint8(0); i < 100; i++ { + assert.Equal(t, "", pick.NewStringsSlice([]string{}, pick.First).Pick()) } - items[i] = string(b) - } + p := pick.NewStringsSlice([]string{"foo", "bar", "baz"}, pick.First) - return items + for i := uint8(0); i < 100; i++ { + assert.Equal(t, "foo", p.Pick()) + } + }) + + t.Run("random once", func(t *testing.T) { + t.Parallel() + + for i := uint8(0); i < 100; i++ { + assert.Equal(t, "", pick.NewStringsSlice([]string{}, pick.RandomOnce).Pick()) + } + + var ( + p = pick.NewStringsSlice([]string{"foo", "bar", "baz"}, pick.RandomOnce) + picked = p.Pick() + ) + + for i := uint8(0); i < 100; i++ { + assert.Equal(t, picked, p.Pick()) + } + }) + + t.Run("random every time", func(t *testing.T) { + t.Parallel() + + for i := uint8(0); i < 100; i++ { + assert.Equal(t, "", pick.NewStringsSlice([]string{}, pick.RandomEveryTime).Pick()) + } + + for i := uint8(0); i < 100; i++ { + p := pick.NewStringsSlice([]string{"foo", "bar", "baz"}, pick.RandomEveryTime) + + assert.NotEqual(t, p.Pick(), p.Pick()) + } + }) } diff --git a/internal/tpl/error_pages.go b/internal/tpl/error_pages.go deleted file mode 100644 index c15d93a..0000000 --- a/internal/tpl/error_pages.go +++ /dev/null @@ -1,136 +0,0 @@ -package tpl - -import ( - "bytes" - "sync" - - "github.com/pkg/errors" -) - -type ( - // ErrorPages is a error page templates generator. - ErrorPages struct { - mu sync.RWMutex - templates map[string][]byte // map[template_name]raw_content - pages map[string]*pageProperties // map[page_code]props - state map[string]map[string][]byte // map[template_name]map[page_code]content - } - - pageProperties struct { - message, description string - } -) - -var ( - ErrUnknownTemplate = errors.New("unknown template") // unknown template - ErrUnknownPageCode = errors.New("unknown page code") // unknown page code -) - -// NewErrorPages creates ErrorPages templates generator. -func NewErrorPages() ErrorPages { - return ErrorPages{ - templates: make(map[string][]byte), - pages: make(map[string]*pageProperties), - state: make(map[string]map[string][]byte), - } -} - -// AddTemplate to the generator. Template can contain the special placeholders for the error code, message and -// description: -// {{ code }} - for the code -// {{ message }} - for the message -// {{ description }} - for the description -func (e *ErrorPages) AddTemplate(templateName string, content []byte) { - e.mu.Lock() - defer e.mu.Unlock() - - e.templates[templateName] = content - e.state[templateName] = make(map[string][]byte) - - for code, props := range e.pages { // update the state - e.state[templateName][code] = e.makeReplaces(content, code, props.message, props.description) - } -} - -// AddPage with the passed code, message and description. This page will ba available for the all templates. -func (e *ErrorPages) AddPage(code, message, description string) { - e.mu.Lock() - defer e.mu.Unlock() - - e.pages[code] = &pageProperties{message, description} - - for templateName, content := range e.templates { // update the state - e.state[templateName][code] = e.makeReplaces(content, code, message, description) - } -} - -// GetPage with passed template name and error code. -func (e *ErrorPages) GetPage(templateName, code string) (content []byte, err error) { - e.mu.RLock() - defer e.mu.RUnlock() - - if pages, templateExists := e.state[templateName]; templateExists { - if c, pageExists := pages[code]; pageExists { - content = c - } else { - err = ErrUnknownPageCode - } - } else { - err = ErrUnknownTemplate - } - - return -} - -// IteratePages will call the passed function for each page and template. -func (e *ErrorPages) IteratePages(fn func(template, code string, content []byte) error) error { - e.mu.RLock() - defer e.mu.RUnlock() - - for tplName, codes := range e.state { - for code, content := range codes { - if err := fn(tplName, code, content); err != nil { - return err - } - } - } - - return nil -} - -const ( - tknCode byte = iota + 1 - tknMessage - tknDescription -) - -var tknSets = map[byte][][]byte{ //nolint:gochecknoglobals - tknCode: {[]byte("{{code}}"), []byte("{{ code }}")}, - tknMessage: {[]byte("{{message}}"), []byte("{{ message }}")}, - tknDescription: {[]byte("{{description}}"), []byte("{{ description }}")}, -} - -func (e *ErrorPages) makeReplaces(where []byte, code, message, description string) []byte { - for tkn, set := range tknSets { - var replaceWith []byte - - switch tkn { - case tknCode: - replaceWith = []byte(code) - case tknMessage: - replaceWith = []byte(message) - case tknDescription: - replaceWith = []byte(description) - default: - panic("tpl: unsupported token") // this is like a fuse, will never occur during normal usage - } - - if len(replaceWith) > 0 { - for i := 0; i < len(set); i++ { - where = bytes.ReplaceAll(where, set[i], replaceWith) - } - } - } - - return where -} diff --git a/internal/tpl/error_pages_test.go b/internal/tpl/error_pages_test.go deleted file mode 100644 index 810c506..0000000 --- a/internal/tpl/error_pages_test.go +++ /dev/null @@ -1,132 +0,0 @@ -package tpl_test - -import ( - "errors" - "sync" - "testing" - - "github.com/tarampampam/error-pages/internal/tpl" - - "github.com/stretchr/testify/assert" -) - -func TestErrorPages_GetPage(t *testing.T) { - e := tpl.NewErrorPages() - - e.AddTemplate("foo", []byte("{{code}}: {{ message }} {{description}}")) - e.AddPage("200", "ok", "all is ok") - e.AddTemplate("bar", []byte("{{ code }} _ {{message}} ({{ description }})")) - e.AddPage("201", "lorem", "ipsum") - - content, err := e.GetPage("foo", "200") - assert.NoError(t, err) - assert.Equal(t, "200: ok all is ok", string(content)) - - content, err = e.GetPage("foo", "201") - assert.NoError(t, err) - assert.Equal(t, "201: lorem ipsum", string(content)) - - content, err = e.GetPage("bar", "200") - assert.NoError(t, err) - assert.Equal(t, "200 _ ok (all is ok)", string(content)) - - content, err = e.GetPage("bar", "201") - assert.NoError(t, err) - assert.Equal(t, "201 _ lorem (ipsum)", string(content)) - - content, err = e.GetPage("foo", "666") - assert.ErrorIs(t, err, tpl.ErrUnknownPageCode) - assert.Nil(t, content) - - content, err = e.GetPage("baz", "200") - assert.ErrorIs(t, err, tpl.ErrUnknownTemplate) - assert.Nil(t, content) -} - -func TestErrorPages_GetPage_Concurrent(t *testing.T) { - e := tpl.NewErrorPages() - - init := func() { - e.AddTemplate("foo", []byte("{{ code }}: {{ message }} {{ description }}")) - e.AddPage("200", "ok", "all is ok") - e.AddPage("201", "lorem", "ipsum") - } - - var wg sync.WaitGroup - - init() - - for i := 0; i < 1234; i++ { - wg.Add(2) - - go func() { - defer wg.Done() - - init() // make re-initialization - }() - - go func() { - defer wg.Done() - - content, err := e.GetPage("foo", "200") - assert.NoError(t, err) - assert.Equal(t, "200: ok all is ok", string(content)) - - content, err = e.GetPage("foo", "201") - assert.NoError(t, err) - assert.Equal(t, "201: lorem ipsum", string(content)) - - content, err = e.GetPage("foo", "666") - assert.Error(t, err) - assert.Nil(t, content) - - content, err = e.GetPage("bar", "200") - assert.Error(t, err) - assert.Nil(t, content) - }() - } - - wg.Wait() -} - -func TestErrorPages_IteratePages(t *testing.T) { - e := tpl.NewErrorPages() - - e.AddTemplate("foo", []byte("{{ code }}: {{ message }} {{ description }}")) - e.AddTemplate("bar", []byte("{{ code }}: {{ message }} {{ description }}")) - e.AddPage("200", "ok", "all is ok") - e.AddPage("400", "Bad Request", "") - - visited := make(map[string]map[string]bool) // map[template]codes - - assert.NoError(t, e.IteratePages(func(template, code string, content []byte) error { - if _, ok := visited[template]; !ok { - visited[template] = make(map[string]bool) - } - - visited[template][code] = true - - assert.NotNil(t, content) - - return nil - })) - - assert.Len(t, visited, 2) - assert.Len(t, visited["foo"], 2) - assert.True(t, visited["foo"]["200"]) - assert.True(t, visited["foo"]["400"]) - assert.Len(t, visited["bar"], 2) - assert.True(t, visited["bar"]["200"]) - assert.True(t, visited["bar"]["400"]) -} - -func TestErrorPages_IteratePages_WillReturnTheError(t *testing.T) { - e := tpl.NewErrorPages() - - e.AddTemplate("foo", []byte("{{ code }}: {{ message }} {{ description }}")) - e.AddPage("200", "ok", "all is ok") - - assert.EqualError(t, e.IteratePages(func(template, code string, content []byte) error { - return errors.New("foo error") - }), "foo error") -} diff --git a/internal/tpl/properties.go b/internal/tpl/properties.go new file mode 100644 index 0000000..69b098a --- /dev/null +++ b/internal/tpl/properties.go @@ -0,0 +1,33 @@ +package tpl + +import "reflect" + +type Properties struct { // only string properties with a "token" tag, please + Code string `token:"code"` + Message string `token:"message"` + Description string `token:"description"` + OriginalURI string `token:"original_uri"` + Namespace string `token:"namespace"` + IngressName string `token:"ingress_name"` + ServiceName string `token:"service_name"` + ServicePort string `token:"service_port"` + RequestID string `token:"request_id"` + ShowRequestDetails bool +} + +// Replaces return a map with strings for the replacing, where the map key is a token. +func (p *Properties) Replaces() map[string]string { + var replaces = make(map[string]string, reflect.ValueOf(*p).NumField()) + + for i, v := 0, reflect.ValueOf(*p); i < v.NumField(); i++ { + if keyword, tagExists := v.Type().Field(i).Tag.Lookup("token"); tagExists { + if sv, isString := v.Field(i).Interface().(string); isString && len(sv) > 0 { + replaces[keyword] = sv + } else { + replaces[keyword] = "" + } + } + } + + return replaces +} diff --git a/internal/tpl/properties_test.go b/internal/tpl/properties_test.go new file mode 100644 index 0000000..4db033f --- /dev/null +++ b/internal/tpl/properties_test.go @@ -0,0 +1,44 @@ +package tpl_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/tarampampam/error-pages/internal/tpl" +) + +func TestProperties_Replaces(t *testing.T) { + t.Parallel() + + props := tpl.Properties{ + Code: "foo", + Message: "bar", + Description: "baz", + OriginalURI: "aaa", + Namespace: "bbb", + IngressName: "ccc", + ServiceName: "ddd", + ServicePort: "eee", + RequestID: "fff", + } + + r := props.Replaces() + + assert.Equal(t, "foo", r["code"]) + assert.Equal(t, "bar", r["message"]) + assert.Equal(t, "baz", r["description"]) + assert.Equal(t, "aaa", r["original_uri"]) + assert.Equal(t, "bbb", r["namespace"]) + assert.Equal(t, "ccc", r["ingress_name"]) + assert.Equal(t, "ddd", r["service_name"]) + assert.Equal(t, "eee", r["service_port"]) + assert.Equal(t, "fff", r["request_id"]) + + props.Code, props.Message, props.Description = "", "", "" + + r = props.Replaces() + + assert.Equal(t, "", r["code"]) + assert.Equal(t, "", r["message"]) + assert.Equal(t, "", r["description"]) +} diff --git a/internal/tpl/render.go b/internal/tpl/render.go new file mode 100644 index 0000000..aab9520 --- /dev/null +++ b/internal/tpl/render.go @@ -0,0 +1,66 @@ +package tpl + +import ( + "bytes" + "encoding/json" + "os" + "strconv" + "text/template" + "time" + + "github.com/tarampampam/error-pages/internal/version" +) + +var tplFnMap = template.FuncMap{ //nolint:gochecknoglobals // these functions can be used in templates + "now": time.Now, + "hostname": os.Hostname, + "json": func(v interface{}) string { b, _ := json.Marshal(v); return string(b) }, //nolint:nlreturn + "version": version.Version, + "int": func(v interface{}) int { + if s, ok := v.(string); ok { + if i, err := strconv.Atoi(s); err == nil { + return i + } + } else if i, ok := v.(int); ok { + return i + } + + return 0 + }, +} + +func Render(content []byte, props Properties) ([]byte, error) { + if len(content) == 0 { + return content, nil + } + + var funcMap = template.FuncMap{ + "show_details": func() bool { return props.ShowRequestDetails }, + "hide_details": func() bool { return !props.ShowRequestDetails }, + } + + // make a copy of template functions map + for s, i := range tplFnMap { + funcMap[s] = i + } + + // and allow the direct calling of Properties tokens, e.g. `{{ code | json }}` + for what, with := range props.Replaces() { + var n, s = what, with + + funcMap[n] = func() string { return s } + } + + t, err := template.New("").Funcs(funcMap).Parse(string(content)) + if err != nil { + return nil, err + } + + var buf bytes.Buffer + + if err = t.Execute(&buf, props); err != nil { + return nil, err + } + + return buf.Bytes(), nil +} diff --git a/internal/tpl/render_test.go b/internal/tpl/render_test.go new file mode 100644 index 0000000..82b7702 --- /dev/null +++ b/internal/tpl/render_test.go @@ -0,0 +1,80 @@ +package tpl_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/tarampampam/error-pages/internal/tpl" +) + +func Test_Render(t *testing.T) { + t.Parallel() + + for name, tt := range map[string]struct { + giveContent string + giveProps tpl.Properties + wantContent string + wantError bool + }{ + "common case": { + giveContent: "{{code}}: {{ message }} {{description}}", + giveProps: tpl.Properties{Code: "404", Message: "Not found", Description: "Blah"}, + wantContent: "404: Not found Blah", + }, + "html markup": { + giveContent: "{{code}}: {{ message }} {{description}}", + giveProps: tpl.Properties{Code: "201", Message: "lorem ipsum"}, + wantContent: "201: lorem ipsum ", + }, + "with line breakers": { + giveContent: "\t {{code}}: {{ message }} {{description}}\n", + giveProps: tpl.Properties{}, + wantContent: "\t : \n", + }, + "golang template": { + giveContent: "\t {{code}} {{ .Code }}{{ if .Message }} Yeah {{end}}", + giveProps: tpl.Properties{Code: "201", Message: "lorem ipsum"}, + wantContent: "\t 201 201 Yeah ", + }, + "wrong golang template": { + giveContent: "{{ if foo() }} Test {{ end }}", + giveProps: tpl.Properties{}, + wantError: true, + }, + + "json common case": { + giveContent: `{"code": {{code | json}}, "message": {"here":[ {{ message | json }} ]}, "desc": "{{description}}"}`, + giveProps: tpl.Properties{Code: `404'"{`, Message: "Not found\t\r\n"}, + wantContent: `{"code": "404'\"{", "message": {"here":[ "Not found\t\r\n" ]}, "desc": ""}`, + }, + "json golang template": { + giveContent: `{"code": "{{code}}", "message": {"here":[ "{{ if .Message }} Yeah {{end}}" ]}}`, + giveProps: tpl.Properties{Code: "201", Message: "lorem ipsum"}, + wantContent: `{"code": "201", "message": {"here":[ " Yeah " ]}}`, + }, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + content, err := tpl.Render([]byte(tt.giveContent), tt.giveProps) + + if tt.wantError == true { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.wantContent, string(content)) + } + }) + } +} + +func BenchmarkRenderHTML(b *testing.B) { + b.ReportAllocs() + + for i := 0; i < b.N; i++ { + _, _ = tpl.Render( + []byte("{{code}}: {{ message }} {{description}}"), + tpl.Properties{Code: "404", Message: "Not found", Description: "Blah"}, + ) + } +} diff --git a/internal/version/version_test.go b/internal/version/version_test.go index bed3e5b..ade2466 100644 --- a/internal/version/version_test.go +++ b/internal/version/version_test.go @@ -5,6 +5,8 @@ import ( ) func TestVersion(t *testing.T) { + t.Parallel() + for give, want := range map[string]string{ // without changes "vvv": "vvv", diff --git a/schemas/config/1.0.schema.json b/schemas/config/1.0.schema.json new file mode 100644 index 0000000..4f0ee3a --- /dev/null +++ b/schemas/config/1.0.schema.json @@ -0,0 +1,108 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Error-Pages config file schema", + "description": "Error-Pages config file schema.", + "type": "object", + "properties": { + "templates": { + "type": "array", + "description": "Templates list", + "items": { + "type": "object", + "description": "Template properties", + "properties": { + "path": { + "type": "string", + "description": "Path to the template file", + "examples": [ + "./templates/ghost.html", + "/opt/tpl/ghost.htm" + ] + }, + "name": { + "type": "string", + "description": "Template name (optional, if path is defined)", + "examples": [ + "ghost" + ] + }, + "content": { + "type": "string", + "description": "Template content, if path is not defined", + "examples": [ + "{{ code }}: {{ message }}" + ] + } + }, + "additionalProperties": false + } + }, + "formats": { + "type": "object", + "description": "Responses, based on requested content-type format", + "properties": { + "json": { + "type": "object", + "description": "JSON format", + "properties": { + "content": { + "type": "string", + "description": "JSON response body (template tags are allowed here)", + "examples": [ + "{\"error\": true, \"code\": {{ code | json }}, \"message\": {{ message | json }}}" + ] + } + }, + "additionalProperties": false + }, + "xml": { + "type": "object", + "description": "XML format", + "properties": { + "content": { + "type": "string", + "description": "XML response body (template tags are allowed here)", + "examples": [ + "{{ code }}{{ message }}" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "pages": { + "type": "object", + "description": "Error pages (codes)", + "patternProperties": { + "^[a-zA-Z0-9_-]+$": { + "type": "object", + "description": "Error page (code)", + "properties": { + "message": { + "type": "string", + "description": "Error page message (title)", + "examples": [ + "Bad Request" + ] + }, + "description": { + "type": "string", + "description": "Error page description", + "examples": [ + "The server did not understand the request" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false, + "required": [ + "templates" + ] +} diff --git a/schemas/config/readme.md b/schemas/config/readme.md new file mode 100644 index 0000000..d97bb81 --- /dev/null +++ b/schemas/config/readme.md @@ -0,0 +1,13 @@ +# Config file schemas + +These schemas describe Error Pages configuration file and used by: + +- + +Schemas naming agreement: `..schema.json`. + +## Contributing guide + +If you want to modify the existing schema - your changes **MUST** be backward compatible. If your changes break backward compatibility - you **MUST** create a new schema file with a fresh version and "register" it in a [schemas catalog][schemas_catalog]. + +[schemas_catalog]:https://github.com/SchemaStore/schemastore/blob/master/src/api/json/catalog.json diff --git a/schemas/readme.md b/schemas/readme.md new file mode 100644 index 0000000..870bc14 --- /dev/null +++ b/schemas/readme.md @@ -0,0 +1,15 @@ +# Schemas + +This directory contains public schemas for the most important parts of application. + +**Do not rename or remove this directory or any file or directory inside.** + +- You can validate existing config file using the following command: + + ```bash + $ docker run --rm -v "$(pwd):/src" -w "/src" node:16-alpine sh -c \ + "npm install -g ajv-cli && \ + ajv validate --all-errors --verbose \ + -s ./schemas/config/1.0.schema.json \ + -d ./error-pages.y*ml" + ``` diff --git a/templates/cats.html b/templates/cats.html index 297499b..b1dd80c 100644 --- a/templates/cats.html +++ b/templates/cats.html @@ -7,14 +7,16 @@ - + {{ message }} -
- +
+ +
{{ message }} + {{ if show_details }} +
+ + {{- if original_uri }} + + + {{ end -}} + {{- if namespace }} + + + {{ end -}} + {{- if ingress_name }} + + + {{ end -}} + {{- if service_name }} + + + {{ end -}} + {{- if service_port }} + + + {{ end -}} + {{- if request_id }} + + + {{ end -}} + + + + +
Original URI{{ original_uri }}
Namespace{{ namespace }}
Ingress name{{ ingress_name }}
Service name{{ service_name }}
Service port{{ service_port }}
Request ID{{ request_id }}
Timestamp{{ now.Unix }}
+
+ {{ end }}
+
diff --git a/templates/readme.md b/templates/readme.md new file mode 100644 index 0000000..99cb7ab --- /dev/null +++ b/templates/readme.md @@ -0,0 +1 @@ +# TODO: Write docs diff --git a/templates/shuffle.html b/templates/shuffle.html index 1e3dfd8..b703a37 100644 --- a/templates/shuffle.html +++ b/templates/shuffle.html @@ -15,27 +15,111 @@ margin: 0; background-color: #222; color: #aaa; - font-family: 'Hack', monospace + font-family: 'Hack', monospace; + font-size: 0; } .full-height { - height: 100vh + height: 100vh; } .flex-center { align-items: center; display: flex; - justify-content: center + justify-content: center; } #error_text { - font-size: 2em + font-size: 32px; } + + /* {{ if show_details }} */ + #details table { + width: 100%; + border-collapse: collapse; + box-sizing: border-box; + margin-top: 20px; + } + + #details.hidden td { + opacity: 0; + font-size: 0; + color: #222; + } + + #details td { + font-size: 11px; + color: #999; + padding-top: .5em; + transition: opacity 1.4s, font-size .3s, color 1.2s; + opacity: 1; + } + + #details td.name { + text-align: right; + padding-right: .3em; + width: 50%; + } + + #details td.value { + text-align: left; + padding-left: .3em; + font-family: 'Lucida Console', 'Courier New', monospace; + } + /* {{ end }} */
- {{ code }}: {{ message }} +
+ {{ code }}: {{ message }} + {{ if show_details }} + + {{ end }} +