mirror of
https://github.com/tarampampam/error-pages.git
synced 2024-08-30 18:22:40 +00:00
Go templates support, XML, JSON, Ingress (#49)
This commit is contained in:
parent
f75bf15552
commit
bed576f26c
39
.github/workflows/tests.yml
vendored
39
.github/workflows/tests.yml
vendored
@ -27,9 +27,24 @@ jobs: # Docs: <https://git.io/JvxXE>
|
||||
- name: Run linter
|
||||
uses: golangci/golangci-lint-action@v2 # Action page: <https://github.com/golangci/golangci-lint-action>
|
||||
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: <https://www.npmjs.com/package/ajv-cli>
|
||||
|
||||
- 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: <https://git.io/JvxXE>
|
||||
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: <https://git.io/JvxXE>
|
||||
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: <https://git.io/JvxXE>
|
||||
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: <https://git.io/JvxXE>
|
||||
- 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()
|
||||
|
2
.gitignore
vendored
2
.gitignore
vendored
@ -3,7 +3,7 @@
|
||||
/.vscode
|
||||
|
||||
## Binaries
|
||||
error-pages
|
||||
/error-pages
|
||||
|
||||
## Temp dirs & trash
|
||||
/temp
|
||||
|
19
CHANGELOG.md
19
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
|
||||
|
@ -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: <https://docs.docker.com/engine/reference/builder/#healthcheck>
|
||||
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"]
|
||||
|
||||
|
7
Makefile
7
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
|
||||
|
38
README.md
38
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**.
|
||||
|
||||
<details>
|
||||
<summary><strong>HTTP server</strong></summary>
|
||||
@ -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).
|
||||
|
||||
|
41
cmd/error-pages/main_test.go
Normal file
41
cmd/error-pages/main_test.go
Normal file
@ -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")
|
||||
}
|
@ -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: <https://hub.docker.com/r/golangci/golangci-lint>
|
||||
image: golangci/golangci-lint:v1.44-alpine # Image page: <https://hub.docker.com/r/golangci/golangci-lint>
|
||||
environment:
|
||||
GOLANGCI_LINT_CACHE: /tmp/golint # <https://github.com/golangci/golangci-lint/blob/v1.42.0/internal/cache/default.go#L68>
|
||||
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
|
||||
|
@ -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: |
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<error>
|
||||
<code>{{ code }}</code>
|
||||
<message>{{ message }}</message>
|
||||
<description>{{ description }}</description>{{ if show_details }}
|
||||
<details>
|
||||
<originalURI>{{ original_uri }}</originalURI>
|
||||
<namespace>{{ namespace }}</namespace>
|
||||
<ingressName>{{ ingress_name }}</ingressName>
|
||||
<serviceName>{{ service_name }}</serviceName>
|
||||
<servicePort>{{ service_port }}</servicePort>
|
||||
<requestID>{{ request_id }}</requestID>
|
||||
<timestamp>{{ now.Unix }}</timestamp>
|
||||
</details>{{ end }}
|
||||
</error>
|
||||
|
||||
pages:
|
||||
400:
|
||||
message: Bad Request
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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{})),
|
||||
|
@ -8,5 +8,7 @@ import (
|
||||
)
|
||||
|
||||
func TestLiveChecker_Check(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.NoError(t, checkers.NewLiveChecker().Check())
|
||||
}
|
||||
|
@ -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(`<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
|
||||
<title>Error pages list</title>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/5.1.1/css/bootstrap.min.css"
|
||||
integrity="sha512-6KY5s6UI5J7SVYuZB4S/CZMyPylqyyNZco376NM2Z8Sb8OxEdp02e1jkKk/wZxIEmjQ6DRCEBhni+gpr9c4tvA=="
|
||||
crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||
</head>
|
||||
<body class="bg-light">
|
||||
<div class="container">
|
||||
<main>
|
||||
<div class="py-5 text-center">
|
||||
<img class="d-block mx-auto mb-4" src="https://hsto.org/webt/rm/9y/ww/rm9ywwx3gjv9agwkcmllhsuyo7k.png"
|
||||
alt="" width="94">
|
||||
<h2>Error pages index</h2>
|
||||
</div>
|
||||
{{- range $template, $item := . -}}
|
||||
<h2 class="mb-3">Template name: <Code>{{ $template }}</Code></h2>
|
||||
<ul class="mb-5">
|
||||
{{ range $item -}}
|
||||
<li><a href="{{ .Path }}"><strong>{{ .Code }}</strong>: {{ .Message }}</a></li>
|
||||
{{ end -}}
|
||||
</ul>
|
||||
{{ end }}
|
||||
</main>
|
||||
</div>
|
||||
<footer class="footer">
|
||||
<div class="container text-center text-muted mt-3 mb-3">
|
||||
For online documentation and support please refer to the
|
||||
<a href="https://github.com/tarampampam/error-pages">project repository</a>.
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>`)
|
||||
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
|
||||
}
|
||||
|
@ -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")
|
||||
}
|
||||
|
59
internal/cli/build/history.go
Normal file
59
internal/cli/build/history.go
Normal file
@ -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)
|
||||
}
|
35
internal/cli/build/index.tpl.html
Normal file
35
internal/cli/build/index.tpl.html
Normal file
@ -0,0 +1,35 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
|
||||
<title>Error pages list</title>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/5.1.3/css/bootstrap.min.css"
|
||||
integrity="sha512-GQGU0fMMi238uA+a/bdWJfpUGKUkBdgfFdgBm72SUQ6BeyWjoY/ton0tEjH+OSH9iP4Dfh+7HM0I9f5eR0L/4w=="
|
||||
crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||
</head>
|
||||
<body class="bg-light">
|
||||
<div class="container">
|
||||
<main>
|
||||
<div class="py-5 text-center">
|
||||
<img class="d-block mx-auto mb-4" src="https://hsto.org/webt/rm/9y/ww/rm9ywwx3gjv9agwkcmllhsuyo7k.png"
|
||||
alt="" width="94">
|
||||
<h2>Error pages index</h2>
|
||||
</div>
|
||||
{{- range $template, $item := . -}}
|
||||
<h2 class="mb-3">Template name: <Code>{{ $template }}</Code></h2>
|
||||
<ul class="mb-5">
|
||||
{{ range $item -}}
|
||||
<li><a href="{{ .Path }}"><strong>{{ .Code }}</strong>: {{ .Message }}</a></li>
|
||||
{{ end -}}
|
||||
</ul>
|
||||
{{ end }}
|
||||
</main>
|
||||
</div>
|
||||
<footer class="footer">
|
||||
<div class="container text-center text-muted mt-3 mb-3">
|
||||
For online documentation and support please refer to the
|
||||
<a href="https://github.com/tarampampam/error-pages">project repository</a>.
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
@ -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)
|
||||
|
@ -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{})
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -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")
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
@ -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{})
|
||||
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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, "<html><body>foo {{ code }}</body></html>\n", string(tpl.Content()))
|
||||
|
||||
tpl, found = cfg.Template("bar-tpl")
|
||||
assert.True(t, found)
|
||||
assert.Equal(t, "bar-tpl", tpl.Name())
|
||||
assert.Equal(t, "<html><body>bar {{ code }}</body></html>\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, "<html><body>foo {{ code }}</body></html>\n", string(tpl.Content()))
|
||||
|
||||
tpl, found = cfg.Template("bar-tpl")
|
||||
assert.True(t, found)
|
||||
assert.Equal(t, "bar-tpl", tpl.Name())
|
||||
assert.Equal(t, "<html><body>bar {{ code }}</body></html>\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 {
|
||||
|
1
internal/config/testdata/bar-tpl.html
vendored
Normal file
1
internal/config/testdata/bar-tpl.html
vendored
Normal file
@ -0,0 +1 @@
|
||||
<html><body>bar {{ code }}</body></html>
|
1
internal/config/testdata/foo-tpl.html
vendored
Normal file
1
internal/config/testdata/foo-tpl.html
vendored
Normal file
@ -0,0 +1 @@
|
||||
<html><body>foo {{ code }}</body></html>
|
4
internal/config/testdata/simple.yml
vendored
4
internal/config/testdata/simple.yml
vendored
@ -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:
|
||||
|
1
internal/env/env.go
vendored
1
internal/env/env.go
vendored
@ -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.
|
||||
|
4
internal/env/env_test.go
vendored
4
internal/env/env_test.go
vendored
@ -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 {
|
||||
|
@ -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")
|
||||
}
|
||||
|
107
internal/http/core/errorpage.go
Normal file
107
internal/http/core/errorpage.go
Normal file
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
62
internal/http/core/formats.go
Normal file
62
internal/http/core/formats.go
Normal file
@ -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")
|
||||
}
|
||||
}
|
110
internal/http/core/formats_test.go
Normal file
110
internal/http/core/formats_test.go
Normal file
@ -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)))
|
||||
})
|
||||
}
|
||||
}
|
27
internal/http/core/headers.go
Normal file
27
internal/http/core/headers.go
Normal file
@ -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"
|
||||
)
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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")
|
||||
}
|
||||
|
@ -19,5 +19,6 @@ func NewHandler(checker checker) fasthttp.RequestHandler {
|
||||
}
|
||||
|
||||
ctx.SetStatusCode(fasthttp.StatusOK)
|
||||
_, _ = ctx.WriteString("OK")
|
||||
}
|
||||
}
|
||||
|
@ -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")
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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")
|
||||
}
|
||||
|
@ -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")
|
||||
}
|
||||
|
@ -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")
|
||||
}
|
||||
|
@ -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: <https://github.com/fasthttp/router>
|
||||
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()
|
||||
}
|
||||
|
@ -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")
|
||||
}
|
||||
|
83
internal/pick/picker.go
Normal file
83
internal/pick/picker.go
Normal file
@ -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")
|
||||
}
|
||||
}
|
65
internal/pick/picker_test.go
Normal file
65
internal/pick/picker_test.go
Normal file
@ -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() })
|
||||
}
|
@ -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()]
|
||||
}
|
||||
|
@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
@ -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
|
||||
}
|
@ -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")
|
||||
}
|
33
internal/tpl/properties.go
Normal file
33
internal/tpl/properties.go
Normal file
@ -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
|
||||
}
|
44
internal/tpl/properties_test.go
Normal file
44
internal/tpl/properties_test.go
Normal file
@ -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"])
|
||||
}
|
66
internal/tpl/render.go
Normal file
66
internal/tpl/render.go
Normal file
@ -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
|
||||
}
|
80
internal/tpl/render_test.go
Normal file
80
internal/tpl/render_test.go
Normal file
@ -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: "<!-- comment --><html><body>{{code}}: {{ message }} {{description}}</body></html>",
|
||||
giveProps: tpl.Properties{Code: "201", Message: "lorem ipsum"},
|
||||
wantContent: "<!-- comment --><html><body>201: lorem ipsum </body></html>",
|
||||
},
|
||||
"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"},
|
||||
)
|
||||
}
|
||||
}
|
@ -5,6 +5,8 @@ import (
|
||||
)
|
||||
|
||||
func TestVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for give, want := range map[string]string{
|
||||
// without changes
|
||||
"vvv": "vvv",
|
||||
|
108
schemas/config/1.0.schema.json
Normal file
108
schemas/config/1.0.schema.json
Normal file
@ -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": [
|
||||
"<html><body>{{ code }}: {{ message }}</body></html>"
|
||||
]
|
||||
}
|
||||
},
|
||||
"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": [
|
||||
"<?xml version=\"1.0\" encoding=\"utf-8\"?><error><code>{{ code }}</code><message>{{ message }}</message></error>"
|
||||
]
|
||||
}
|
||||
},
|
||||
"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"
|
||||
]
|
||||
}
|
13
schemas/config/readme.md
Normal file
13
schemas/config/readme.md
Normal file
@ -0,0 +1,13 @@
|
||||
# Config file schemas
|
||||
|
||||
These schemas describe Error Pages configuration file and used by:
|
||||
|
||||
- <https://github.com/SchemaStore/schemastore>
|
||||
|
||||
Schemas naming agreement: `<version_major>.<version_minor>.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
|
15
schemas/readme.md
Normal file
15
schemas/readme.md
Normal file
@ -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"
|
||||
```
|
@ -7,14 +7,16 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<meta name="robots" content="noindex, nofollow"/>
|
||||
<title>{{ message }}</title>
|
||||
<style>
|
||||
html, body {
|
||||
margin: 0;
|
||||
height: 100vh;
|
||||
background-color: #000;
|
||||
color: #aaa;
|
||||
overflow: hidden;
|
||||
color: #ddd;
|
||||
font-family: 'Open Sans', sans-serif;
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
.centered {
|
||||
@ -28,13 +30,71 @@
|
||||
max-width: 750px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* {{ if show_details }} */
|
||||
.details table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.details td {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.details td.name {
|
||||
text-align: right;
|
||||
padding-right: .6em;
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.details td.value {
|
||||
text-align: left;
|
||||
padding-left: .6em;
|
||||
font-family: 'Lucida Console', 'Courier New', monospace;
|
||||
}
|
||||
/* {{ end }} */
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="centered">
|
||||
<!-- Pictures provider: <https://http.cat/> -->
|
||||
<div class="centered">
|
||||
<!-- Pictures provider: <https://http.cat/> -->
|
||||
<div>
|
||||
<img src="https://http.cat/{{ code }}.jpg" alt="{{ message }}">
|
||||
{{ if show_details }}
|
||||
<div class="details">
|
||||
<table>
|
||||
{{- if original_uri }}<tr>
|
||||
<td class="name">Original URI</td>
|
||||
<td class="value">{{ original_uri }}</td>
|
||||
</tr>{{ end -}}
|
||||
{{- if namespace }}<tr>
|
||||
<td class="name">Namespace</td>
|
||||
<td class="value">{{ namespace }}</td>
|
||||
</tr>{{ end -}}
|
||||
{{- if ingress_name }}<tr>
|
||||
<td class="name">Ingress name</td>
|
||||
<td class="value">{{ ingress_name }}</td>
|
||||
</tr>{{ end -}}
|
||||
{{- if service_name }}<tr>
|
||||
<td class="name">Service name</td>
|
||||
<td class="value">{{ service_name }}</td>
|
||||
</tr>{{ end -}}
|
||||
{{- if service_port }}<tr>
|
||||
<td class="name">Service port</td>
|
||||
<td class="value">{{ service_port }}</td>
|
||||
</tr>{{ end -}}
|
||||
{{- if request_id }}<tr>
|
||||
<td class="name">Request ID</td>
|
||||
<td class="value">{{ request_id }}</td>
|
||||
</tr>{{ end -}}
|
||||
<tr>
|
||||
<td class="name">Timestamp</td>
|
||||
<td class="value">{{ now.Unix }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
<!--
|
||||
Error {{ code }}: {{ message }}
|
||||
|
@ -11,38 +11,82 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
|
||||
<link href="https://fonts.googleapis.com/css?family=Open+Sans" rel="stylesheet" />
|
||||
<style>
|
||||
html,body {background-color:#1a1a1a;color:#fff;font-family:'Open Sans',sans-serif}
|
||||
.wrap {top:50%;left:50%;width:310px;height:260px;margin-left:-155px;margin-top:-110px;position:absolute;text-align:center}
|
||||
html,body {background-color:#1a1a1a;color:#fff;font-family:'Open Sans',sans-serif;height:100vh;margin:0;font-size:0}
|
||||
.container {height:100vh;align-items:center;display:flex;justify-content:center;position:relative}
|
||||
.wrap {text-align:center}
|
||||
.ghost {animation:float 3s ease-out infinite}
|
||||
@keyframes float { 50% {transform:translate(0,20px)}}
|
||||
.shadowFrame {width:130px;margin: 10px auto 0 auto}
|
||||
.shadow {animation:shrink 3s ease-out infinite;transform-origin:center center}
|
||||
@keyframes shrink {0%{width:90%;margin:0 5%} 50% {width:60%;margin:0 18%} 100% {width:90%;margin:0 5%}}
|
||||
h3 {font-size:1.05em;text-transform: uppercase;margin:0.3em auto}
|
||||
.description {font-size:0.8em;color:#aaa}
|
||||
h3 {font-size:17px;text-transform: uppercase;margin:0.3em auto}
|
||||
.description {font-size:13px;color:#aaa}
|
||||
/* {{ if show_details }} */
|
||||
.details {color:#999;width:100%}
|
||||
.details table {width:100%}
|
||||
.details td {white-space:nowrap;font-size:11px}
|
||||
.details .name {text-align:right;padding-right:.6em;width:50%}
|
||||
.details .value {text-align:left;padding-left:.6em;font-family:'Lucida Console','Courier New',monospace}
|
||||
/* {{ end }} */
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<svg class="ghost" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="127.433px" height="132.743px" viewBox="0 0 127.433 132.743" enable-background="new 0 0 127.433 132.743" xml:space="preserve">
|
||||
<path fill="#FFF6F4" d="M116.223,125.064c1.032-1.183,1.323-2.73,1.391-3.747V54.76c0,0-4.625-34.875-36.125-44.375 s-66,6.625-72.125,44l-0.781,63.219c0.062,4.197,1.105,6.177,1.808,7.006c1.94,1.811,5.408,3.465,10.099-0.6 c7.5-6.5,8.375-10,12.75-6.875s5.875,9.75,13.625,9.25s12.75-9,13.75-9.625s4.375-1.875,7,1.25s5.375,8.25,12.875,7.875 s12.625-8.375,12.625-8.375s2.25-3.875,7.25,0.375s7.625,9.75,14.375,8.125C114.739,126.01,115.412,125.902,116.223,125.064z"></path>
|
||||
<circle fill="#1a1a1a" cx="86.238" cy="57.885" r="6.667"></circle>
|
||||
<circle fill="#1a1a1a" cx="40.072" cy="57.885" r="6.667"></circle>
|
||||
<path fill="#1a1a1a" d="M71.916,62.782c0.05-1.108-0.809-2.046-1.917-2.095c-0.673-0.03-1.28,0.279-1.667,0.771 c-0.758,0.766-2.483,2.235-4.696,2.358c-1.696,0.094-3.438-0.625-5.191-2.137c-0.003-0.003-0.007-0.006-0.011-0.009l0.002,0.005 c-0.332-0.294-0.757-0.488-1.235-0.509c-1.108-0.049-2.046,0.809-2.095,1.917c-0.032,0.724,0.327,1.37,0.887,1.749 c-0.001,0-0.002-0.001-0.003-0.001c2.221,1.871,4.536,2.88,6.912,2.986c0.333,0.014,0.67,0.012,1.007-0.01 c3.163-0.191,5.572-1.942,6.888-3.166l0.452-0.453c0.021-0.019,0.04-0.041,0.06-0.061l0.034-0.034 c-0.007,0.007-0.015,0.014-0.021,0.02C71.666,63.771,71.892,63.307,71.916,62.782z"></path>
|
||||
<circle fill="#FCEFED" stroke="#FEEBE6" stroke-miterlimit="10" cx="18.614" cy="99.426" r="3.292"></circle>
|
||||
<circle fill="#FCEFED" stroke="#FEEBE6" stroke-miterlimit="10" cx="95.364" cy="28.676" r="3.291"></circle>
|
||||
<circle fill="#FCEFED" stroke="#FEEBE6" stroke-miterlimit="10" cx="24.739" cy="93.551" r="2.667"></circle>
|
||||
<circle fill="#FCEFED" stroke="#FEEBE6" stroke-miterlimit="10" cx="101.489" cy="33.051" r="2.666"></circle>
|
||||
<circle fill="#FCEFED" stroke="#FEEBE6" stroke-miterlimit="10" cx="18.738" cy="87.717" r="2.833"></circle>
|
||||
<path fill="#FCEFED" stroke="#FEEBE6" stroke-miterlimit="10" d="M116.279,55.814c-0.021-0.286-2.323-28.744-30.221-41.012 c-7.806-3.433-15.777-5.173-23.691-5.173c-16.889,0-30.283,7.783-37.187,15.067c-9.229,9.736-13.84,26.712-14.191,30.259 l-0.748,62.332c0.149,2.133,1.389,6.167,5.019,6.167c1.891,0,4.074-1.083,6.672-3.311c4.96-4.251,7.424-6.295,9.226-6.295 c1.339,0,2.712,1.213,5.102,3.762c4.121,4.396,7.461,6.355,10.833,6.355c2.713,0,5.311-1.296,7.942-3.962 c3.104-3.145,5.701-5.239,8.285-5.239c2.116,0,4.441,1.421,7.317,4.473c2.638,2.8,5.674,4.219,9.022,4.219 c4.835,0,8.991-2.959,11.27-5.728l0.086-0.104c1.809-2.2,3.237-3.938,5.312-3.938c2.208,0,5.271,1.942,9.359,5.936 c0.54,0.743,3.552,4.674,6.86,4.674c1.37,0,2.559-0.65,3.531-1.932l0.203-0.268L116.279,55.814z M114.281,121.405 c-0.526,0.599-1.096,0.891-1.734,0.891c-2.053,0-4.51-2.82-5.283-3.907l-0.116-0.136c-4.638-4.541-7.975-6.566-10.82-6.566 c-3.021,0-4.884,2.267-6.857,4.667l-0.086,0.104c-1.896,2.307-5.582,4.999-9.725,4.999c-2.775,0-5.322-1.208-7.567-3.59 c-3.325-3.528-6.03-5.102-8.772-5.102c-3.278,0-6.251,2.332-9.708,5.835c-2.236,2.265-4.368,3.366-6.518,3.366 c-2.772,0-5.664-1.765-9.374-5.723c-2.488-2.654-4.29-4.395-6.561-4.395c-2.515,0-5.045,2.077-10.527,6.777 c-2.727,2.337-4.426,2.828-5.37,2.828c-2.662,0-3.017-4.225-3.021-4.225l0.745-62.163c0.332-3.321,4.767-19.625,13.647-28.995 c3.893-4.106,10.387-8.632,18.602-11.504c-0.458,0.503-0.744,1.165-0.744,1.898c0,1.565,1.269,2.833,2.833,2.833 c1.564,0,2.833-1.269,2.833-2.833c0-1.355-0.954-2.485-2.226-2.764c4.419-1.285,9.269-2.074,14.437-2.074 c7.636,0,15.336,1.684,22.887,5.004c26.766,11.771,29.011,39.047,29.027,39.251V121.405z"></path>
|
||||
</svg>
|
||||
<p class="shadowFrame">
|
||||
<svg version="1.1" class="shadow" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="61px" y="20px" width="122.436px" height="39.744px" viewBox="0 0 122.436 39.744" enable-background="new 0 0 122.436 39.744" xml:space="preserve">
|
||||
<ellipse fill="#262626" cx="61.128" cy="19.872" rx="49.25" ry="8.916"></ellipse>
|
||||
</svg>
|
||||
</p>
|
||||
<h3>Error {{ code }}</h3>
|
||||
<p class="description">{{ description }}</p>
|
||||
<div class="container">
|
||||
<div class="wrap">
|
||||
<svg class="ghost" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="127.433px" height="132.743px" viewBox="0 0 127.433 132.743" enable-background="new 0 0 127.433 132.743" xml:space="preserve">
|
||||
<path fill="#FFF6F4" d="M116.223,125.064c1.032-1.183,1.323-2.73,1.391-3.747V54.76c0,0-4.625-34.875-36.125-44.375 s-66,6.625-72.125,44l-0.781,63.219c0.062,4.197,1.105,6.177,1.808,7.006c1.94,1.811,5.408,3.465,10.099-0.6 c7.5-6.5,8.375-10,12.75-6.875s5.875,9.75,13.625,9.25s12.75-9,13.75-9.625s4.375-1.875,7,1.25s5.375,8.25,12.875,7.875 s12.625-8.375,12.625-8.375s2.25-3.875,7.25,0.375s7.625,9.75,14.375,8.125C114.739,126.01,115.412,125.902,116.223,125.064z"></path>
|
||||
<circle fill="#1a1a1a" cx="86.238" cy="57.885" r="6.667"></circle>
|
||||
<circle fill="#1a1a1a" cx="40.072" cy="57.885" r="6.667"></circle>
|
||||
<path fill="#1a1a1a" d="M71.916,62.782c0.05-1.108-0.809-2.046-1.917-2.095c-0.673-0.03-1.28,0.279-1.667,0.771 c-0.758,0.766-2.483,2.235-4.696,2.358c-1.696,0.094-3.438-0.625-5.191-2.137c-0.003-0.003-0.007-0.006-0.011-0.009l0.002,0.005 c-0.332-0.294-0.757-0.488-1.235-0.509c-1.108-0.049-2.046,0.809-2.095,1.917c-0.032,0.724,0.327,1.37,0.887,1.749 c-0.001,0-0.002-0.001-0.003-0.001c2.221,1.871,4.536,2.88,6.912,2.986c0.333,0.014,0.67,0.012,1.007-0.01 c3.163-0.191,5.572-1.942,6.888-3.166l0.452-0.453c0.021-0.019,0.04-0.041,0.06-0.061l0.034-0.034 c-0.007,0.007-0.015,0.014-0.021,0.02C71.666,63.771,71.892,63.307,71.916,62.782z"></path>
|
||||
<circle fill="#FCEFED" stroke="#FEEBE6" stroke-miterlimit="10" cx="18.614" cy="99.426" r="3.292"></circle>
|
||||
<circle fill="#FCEFED" stroke="#FEEBE6" stroke-miterlimit="10" cx="95.364" cy="28.676" r="3.291"></circle>
|
||||
<circle fill="#FCEFED" stroke="#FEEBE6" stroke-miterlimit="10" cx="24.739" cy="93.551" r="2.667"></circle>
|
||||
<circle fill="#FCEFED" stroke="#FEEBE6" stroke-miterlimit="10" cx="101.489" cy="33.051" r="2.666"></circle>
|
||||
<circle fill="#FCEFED" stroke="#FEEBE6" stroke-miterlimit="10" cx="18.738" cy="87.717" r="2.833"></circle>
|
||||
<path fill="#FCEFED" stroke="#FEEBE6" stroke-miterlimit="10" d="M116.279,55.814c-0.021-0.286-2.323-28.744-30.221-41.012 c-7.806-3.433-15.777-5.173-23.691-5.173c-16.889,0-30.283,7.783-37.187,15.067c-9.229,9.736-13.84,26.712-14.191,30.259 l-0.748,62.332c0.149,2.133,1.389,6.167,5.019,6.167c1.891,0,4.074-1.083,6.672-3.311c4.96-4.251,7.424-6.295,9.226-6.295 c1.339,0,2.712,1.213,5.102,3.762c4.121,4.396,7.461,6.355,10.833,6.355c2.713,0,5.311-1.296,7.942-3.962 c3.104-3.145,5.701-5.239,8.285-5.239c2.116,0,4.441,1.421,7.317,4.473c2.638,2.8,5.674,4.219,9.022,4.219 c4.835,0,8.991-2.959,11.27-5.728l0.086-0.104c1.809-2.2,3.237-3.938,5.312-3.938c2.208,0,5.271,1.942,9.359,5.936 c0.54,0.743,3.552,4.674,6.86,4.674c1.37,0,2.559-0.65,3.531-1.932l0.203-0.268L116.279,55.814z M114.281,121.405 c-0.526,0.599-1.096,0.891-1.734,0.891c-2.053,0-4.51-2.82-5.283-3.907l-0.116-0.136c-4.638-4.541-7.975-6.566-10.82-6.566 c-3.021,0-4.884,2.267-6.857,4.667l-0.086,0.104c-1.896,2.307-5.582,4.999-9.725,4.999c-2.775,0-5.322-1.208-7.567-3.59 c-3.325-3.528-6.03-5.102-8.772-5.102c-3.278,0-6.251,2.332-9.708,5.835c-2.236,2.265-4.368,3.366-6.518,3.366 c-2.772,0-5.664-1.765-9.374-5.723c-2.488-2.654-4.29-4.395-6.561-4.395c-2.515,0-5.045,2.077-10.527,6.777 c-2.727,2.337-4.426,2.828-5.37,2.828c-2.662,0-3.017-4.225-3.021-4.225l0.745-62.163c0.332-3.321,4.767-19.625,13.647-28.995 c3.893-4.106,10.387-8.632,18.602-11.504c-0.458,0.503-0.744,1.165-0.744,1.898c0,1.565,1.269,2.833,2.833,2.833 c1.564,0,2.833-1.269,2.833-2.833c0-1.355-0.954-2.485-2.226-2.764c4.419-1.285,9.269-2.074,14.437-2.074 c7.636,0,15.336,1.684,22.887,5.004c26.766,11.771,29.011,39.047,29.027,39.251V121.405z"></path>
|
||||
</svg>
|
||||
<p class="shadowFrame">
|
||||
<svg version="1.1" class="shadow" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="61px" y="20px" width="122.436px" height="39.744px" viewBox="0 0 122.436 39.744" enable-background="new 0 0 122.436 39.744" xml:space="preserve">
|
||||
<ellipse fill="#262626" cx="61.128" cy="19.872" rx="49.25" ry="8.916"></ellipse>
|
||||
</svg>
|
||||
</p>
|
||||
<h3>Error {{ code }}</h3>
|
||||
<p class="description">{{ description }}</p>
|
||||
{{ if show_details }}
|
||||
<div class="details">
|
||||
<table>
|
||||
{{- if original_uri }}<tr>
|
||||
<td class="name">Original URI</td>
|
||||
<td class="value">{{ original_uri }}</td>
|
||||
</tr>{{ end -}}
|
||||
{{- if namespace }}<tr>
|
||||
<td class="name">Namespace</td>
|
||||
<td class="value">{{ namespace }}</td>
|
||||
</tr>{{ end -}}
|
||||
{{- if ingress_name }}<tr>
|
||||
<td class="name">Ingress name</td>
|
||||
<td class="value">{{ ingress_name }}</td>
|
||||
</tr>{{ end -}}
|
||||
{{- if service_name }}<tr>
|
||||
<td class="name">Service name</td>
|
||||
<td class="value">{{ service_name }}</td>
|
||||
</tr>{{ end -}}
|
||||
{{- if service_port }}<tr>
|
||||
<td class="name">Service port</td>
|
||||
<td class="value">{{ service_port }}</td>
|
||||
</tr>{{ end -}}
|
||||
{{- if request_id }}<tr>
|
||||
<td class="name">Request ID</td>
|
||||
<td class="value">{{ request_id }}</td>
|
||||
</tr>{{ end -}}
|
||||
<tr>
|
||||
<td class="name">Timestamp</td>
|
||||
<td class="value">{{ now.Unix }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
<!--
|
||||
|
@ -18,6 +18,7 @@
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
@ -27,10 +28,9 @@
|
||||
background-repeat: no-repeat;
|
||||
background-size: cover;
|
||||
font-family: 'Inconsolata', Helvetica, sans-serif;
|
||||
font-size: 1.5rem;
|
||||
color: rgba(128, 255, 128, 0.8);
|
||||
text-shadow:
|
||||
0 0 1ex rgba(51, 255, 51, 1),
|
||||
0 0 11px rgba(51, 255, 51, 1),
|
||||
0 0 2px rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
@ -82,10 +82,18 @@
|
||||
height: 100%;
|
||||
width: 1000px;
|
||||
max-width: 100%;
|
||||
padding: 4rem;
|
||||
padding: 64px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 48px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.output {
|
||||
color: rgba(128, 255, 128, 0.8);
|
||||
text-shadow:
|
||||
@ -113,6 +121,25 @@
|
||||
.error_code {
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* {{ if show_details }} */
|
||||
.details p {
|
||||
margin-top: .5em;
|
||||
margin-bottom: .5em;
|
||||
}
|
||||
|
||||
.details * {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.details p::before {
|
||||
content: "$ ";
|
||||
}
|
||||
|
||||
.details code {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
/* {{ end }} */
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@ -121,6 +148,17 @@
|
||||
<h1>Error <span class="error_code">{{ code }}</span></h1>
|
||||
<p class="output">{{ description }}.</p>
|
||||
<p class="output">Good luck.</p>
|
||||
{{ if show_details }}
|
||||
<div class="details">
|
||||
{{- if original_uri }}<p class="output small">Original URI: <code>{{ original_uri }}</code></p>{{ end -}}
|
||||
{{- if namespace }}<p class="output small">Namespace: <code>{{ namespace }}</code></p>{{ end -}}
|
||||
{{- if ingress_name }}<p class="output small">Ingress name: <code>{{ ingress_name }}</code></p>{{ end -}}
|
||||
{{- if service_name }}<p class="output small">Service name: <code>{{ service_name }}</code></p>{{ end -}}
|
||||
{{- if service_port }}<p class="output small">Service port: <code>{{ service_port }}</code></p>{{ end -}}
|
||||
{{- if request_id }}<p class="output small">Request ID: <code>{{ request_id }}</code></p>{{ end -}}
|
||||
<p class="output small">Timestamp: <code>{{ now.Unix }}</code></p>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</body>
|
||||
<!--
|
||||
|
@ -12,21 +12,65 @@
|
||||
<link rel="dns-prefetch" href="//fonts.gstatic.com">
|
||||
<link href="https://fonts.googleapis.com/css?family=Nunito" rel="stylesheet">
|
||||
<style>
|
||||
html,body {background-color: #222526;color:#fff;font-family:'Nunito',sans-serif;font-weight:100;height:100vh;margin:0}
|
||||
html,body {background-color:#222526;color:#fff;font-family:'Nunito',sans-serif;font-weight:100;height:100vh;margin:0;font-size:0}
|
||||
.full-height {height:100vh}
|
||||
.flex-center {align-items:center;display:flex;justify-content:center}
|
||||
.position-ref {position:relative}
|
||||
.code {border-right:2px solid;font-size:26px;padding:0 10px 0 15px;text-align:center}
|
||||
.message {font-size:18px;text-align:center;padding:10px}
|
||||
/* {{ if show_details }} */
|
||||
.details table {width:100%;border-collapse:collapse;box-sizing:border-box;margin-top:20px}
|
||||
.details td {font-size:11px;color:#999}
|
||||
.details td.name {text-align:right;padding-right:.6em;width:50%;border-right:2px solid;border-color:#777}
|
||||
.details td.value {text-align:left;padding-left:.6em;font-family:'Lucida Console','Courier New',monospace}
|
||||
/* {{ end }} */
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="flex-center position-ref full-height">
|
||||
<div class="code">
|
||||
{{ code }}
|
||||
</div>
|
||||
<div class="message">
|
||||
{{ message }}
|
||||
<div>
|
||||
<div class="flex-center">
|
||||
<div class="code">
|
||||
{{ code }}
|
||||
</div>
|
||||
<div class="message">
|
||||
{{ message }}
|
||||
</div>
|
||||
</div>
|
||||
{{ if show_details }}
|
||||
<div class="details">
|
||||
<table>
|
||||
{{- if original_uri }}<tr>
|
||||
<td class="name">Original URI</td>
|
||||
<td class="value">{{ original_uri }}</td>
|
||||
</tr>{{ end -}}
|
||||
{{- if namespace }}<tr>
|
||||
<td class="name">Namespace</td>
|
||||
<td class="value">{{ namespace }}</td>
|
||||
</tr>{{ end -}}
|
||||
{{- if ingress_name }}<tr>
|
||||
<td class="name">Ingress name</td>
|
||||
<td class="value">{{ ingress_name }}</td>
|
||||
</tr>{{ end -}}
|
||||
{{- if service_name }}<tr>
|
||||
<td class="name">Service name</td>
|
||||
<td class="value">{{ service_name }}</td>
|
||||
</tr>{{ end -}}
|
||||
{{- if service_port }}<tr>
|
||||
<td class="name">Service port</td>
|
||||
<td class="value">{{ service_port }}</td>
|
||||
</tr>{{ end -}}
|
||||
{{- if request_id }}<tr>
|
||||
<td class="name">Request ID</td>
|
||||
<td class="value">{{ request_id }}</td>
|
||||
</tr>{{ end -}}
|
||||
<tr>
|
||||
<td class="name">Timestamp</td>
|
||||
<td class="value">{{ now.Unix }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
@ -12,21 +12,65 @@
|
||||
<link rel="dns-prefetch" href="//fonts.gstatic.com">
|
||||
<link href="https://fonts.googleapis.com/css?family=Nunito" rel="stylesheet">
|
||||
<style>
|
||||
html,body {background-color:#fff;color:#636b6f;font-family:'Nunito',sans-serif;font-weight:100;height:100vh;margin:0}
|
||||
html,body {background-color:#fff;color:#636b6f;font-family:'Nunito',sans-serif;font-weight:100;height:100vh;margin:0;font-size:0}
|
||||
.full-height {height:100vh}
|
||||
.flex-center {align-items:center;display:flex;justify-content:center}
|
||||
.position-ref {position:relative}
|
||||
.code {border-right:2px solid;font-size:26px;padding:0 10px 0 15px;text-align:center}
|
||||
.message {font-size:18px;text-align:center;padding:10px}
|
||||
/* {{ if show_details }} */
|
||||
.details table {width:100%;border-collapse:collapse;box-sizing:border-box;margin-top:20px}
|
||||
.details td {font-size:11px;color:#777}
|
||||
.details td.name {text-align:right;padding-right:.6em;width:50%;border-right:2px solid;border-color:#aaa}
|
||||
.details td.value {text-align:left;padding-left:.6em;font-family:'Lucida Console','Courier New',monospace}
|
||||
/* {{ end }} */
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="flex-center position-ref full-height">
|
||||
<div class="code">
|
||||
{{ code }}
|
||||
</div>
|
||||
<div class="message">
|
||||
{{ message }}
|
||||
<div>
|
||||
<div class="flex-center">
|
||||
<div class="code">
|
||||
{{ code }}
|
||||
</div>
|
||||
<div class="message">
|
||||
{{ message }}
|
||||
</div>
|
||||
</div>
|
||||
{{ if show_details }}
|
||||
<div class="details">
|
||||
<table>
|
||||
{{- if original_uri }}<tr>
|
||||
<td class="name">Original URI</td>
|
||||
<td class="value">{{ original_uri }}</td>
|
||||
</tr>{{ end -}}
|
||||
{{- if namespace }}<tr>
|
||||
<td class="name">Namespace</td>
|
||||
<td class="value">{{ namespace }}</td>
|
||||
</tr>{{ end -}}
|
||||
{{- if ingress_name }}<tr>
|
||||
<td class="name">Ingress name</td>
|
||||
<td class="value">{{ ingress_name }}</td>
|
||||
</tr>{{ end -}}
|
||||
{{- if service_name }}<tr>
|
||||
<td class="name">Service name</td>
|
||||
<td class="value">{{ service_name }}</td>
|
||||
</tr>{{ end -}}
|
||||
{{- if service_port }}<tr>
|
||||
<td class="name">Service port</td>
|
||||
<td class="value">{{ service_port }}</td>
|
||||
</tr>{{ end -}}
|
||||
{{- if request_id }}<tr>
|
||||
<td class="name">Request ID</td>
|
||||
<td class="value">{{ request_id }}</td>
|
||||
</tr>{{ end -}}
|
||||
<tr>
|
||||
<td class="name">Timestamp</td>
|
||||
<td class="value">{{ now.Unix }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
@ -2,6 +2,15 @@
|
||||
<!--
|
||||
Error {{ code }}: {{ message }}
|
||||
Description: {{ description }}
|
||||
{{ if show_details }}
|
||||
{{ if original_uri }}Original URI: {{ original_uri }}{{ end }}
|
||||
{{ if namespace }}Namespace: {{ namespace }}{{ end }}
|
||||
{{ if ingress_name }}Ingress name: {{ ingress_name }}{{ end }}
|
||||
{{ if service_name }}Service name: {{ service_name }}{{ end }}
|
||||
{{ if service_port }}Service port: {{ service_port }}{{ end }}
|
||||
{{ if request_id }}Request ID: {{ request_id }}{{ end }}
|
||||
Timestamp: {{ now.Unix }}
|
||||
{{ end }}
|
||||
-->
|
||||
<html lang="en">
|
||||
<head>
|
||||
|
1
templates/readme.md
Normal file
1
templates/readme.md
Normal file
@ -0,0 +1 @@
|
||||
# TODO: Write docs
|
@ -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 }} */
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="flex-center full-height">
|
||||
<span id="error_text">{{ code }}: {{ message }}</span>
|
||||
<div>
|
||||
<span id="error_text">{{ code }}: {{ message }}</span>
|
||||
{{ if show_details }}
|
||||
<div class="hidden" id="details">
|
||||
<table>
|
||||
{{- if original_uri }}
|
||||
<tr>
|
||||
<td class="name">Original URI:</td>
|
||||
<td class="value">{{ original_uri }}</td>
|
||||
</tr>
|
||||
{{ end -}}
|
||||
{{- if namespace }}
|
||||
<tr>
|
||||
<td class="name">Namespace:</td>
|
||||
<td class="value">{{ namespace }}</td>
|
||||
</tr>
|
||||
{{ end -}}
|
||||
{{- if ingress_name }}
|
||||
<tr>
|
||||
<td class="name">Ingress name:</td>
|
||||
<td class="value">{{ ingress_name }}</td>
|
||||
</tr>
|
||||
{{ end -}}
|
||||
{{- if service_name }}
|
||||
<tr>
|
||||
<td class="name">Service name:</td>
|
||||
<td class="value">{{ service_name }}</td>
|
||||
</tr>
|
||||
{{ end -}}
|
||||
{{- if service_port }}
|
||||
<tr>
|
||||
<td class="name">Service port:</td>
|
||||
<td class="value">{{ service_port }}</td>
|
||||
</tr>
|
||||
{{ end -}}
|
||||
{{- if request_id }}
|
||||
<tr>
|
||||
<td class="name">Request ID:</td>
|
||||
<td class="value">{{ request_id }}</td>
|
||||
</tr>
|
||||
{{ end -}}
|
||||
<tr>
|
||||
<td class="name">Timestamp:</td>
|
||||
<td class="value">{{ now.Unix }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
@ -58,7 +142,13 @@
|
||||
}
|
||||
|
||||
$errorText.innerText = newText;
|
||||
}, 800 / 60);
|
||||
}, 450 / 60);
|
||||
|
||||
// {{ if show_details }}
|
||||
window.setTimeout(function () {
|
||||
document.getElementById('details').classList.remove('hidden');
|
||||
}, 550);
|
||||
// {{ end }}
|
||||
|
||||
window.setTimeout(function () {
|
||||
let revealInterval = window.setInterval(function () {
|
||||
|
7
test/hurl/404.hurl
Normal file
7
test/hurl/404.hurl
Normal file
@ -0,0 +1,7 @@
|
||||
GET http://{{ host }}:{{ port }}/not-found
|
||||
|
||||
HTTP/* 404
|
||||
|
||||
[Asserts]
|
||||
header "Content-Type" contains "text/plain"
|
||||
body contains "Wrong request URL"
|
10
test/hurl/code_502_default.hurl
Normal file
10
test/hurl/code_502_default.hurl
Normal file
@ -0,0 +1,10 @@
|
||||
GET http://{{ host }}:{{ port }}/502.html
|
||||
|
||||
HTTP/* 200
|
||||
|
||||
[Asserts]
|
||||
header "Content-Type" contains "text/html"
|
||||
header "X-Robots-Tag" == "noindex"
|
||||
body contains "502"
|
||||
body contains "Bad Gateway"
|
||||
body contains "The server received an invalid response from the upstream server"
|
39
test/hurl/code_502_json.hurl
Normal file
39
test/hurl/code_502_json.hurl
Normal file
@ -0,0 +1,39 @@
|
||||
# The common request
|
||||
GET http://{{ host }}:{{ port }}/502.html
|
||||
Content-Type: application/json;charset=UTF-8
|
||||
X-Original-URI: foo
|
||||
X-Namespace: bar
|
||||
X-Ingress-Name: baz
|
||||
X-Service-Name: aaa
|
||||
X-Service-Port: bbb
|
||||
X-Request-ID: ccc
|
||||
|
||||
HTTP/* 200
|
||||
|
||||
[Asserts]
|
||||
header "Content-Type" contains "application/json"
|
||||
header "X-Robots-Tag" == "noindex"
|
||||
jsonpath "$.error" == true
|
||||
jsonpath "$.code" == "502"
|
||||
jsonpath "$.message" == "Bad Gateway"
|
||||
jsonpath "$.description" == "The server received an invalid response from the upstream server"
|
||||
jsonpath "$.details.original_uri" == "foo"
|
||||
jsonpath "$.details.namespace" == "bar"
|
||||
jsonpath "$.details.ingress_name" == "baz"
|
||||
jsonpath "$.details.service_name" == "aaa"
|
||||
jsonpath "$.details.service_port" == "bbb"
|
||||
jsonpath "$.details.request_id" == "ccc"
|
||||
jsonpath "$.details.timestamp" isInteger
|
||||
|
||||
# X-Format in the action
|
||||
GET http://{{ host }}:{{ port }}/502.html
|
||||
X-Format: text/json
|
||||
|
||||
HTTP/* 200
|
||||
|
||||
[Asserts]
|
||||
header "Content-Type" contains "application/json"
|
||||
header "X-Robots-Tag" == "noindex"
|
||||
jsonpath "$.error" == true
|
||||
jsonpath "$.code" == "502"
|
||||
jsonpath "$.message" == "Bad Gateway"
|
38
test/hurl/code_502_xml.hurl
Normal file
38
test/hurl/code_502_xml.hurl
Normal file
@ -0,0 +1,38 @@
|
||||
# The common request
|
||||
GET http://{{ host }}:{{ port }}/502.html
|
||||
Content-Type: application/xml;charset=UTF-8
|
||||
X-Original-URI: foo
|
||||
X-Namespace: bar
|
||||
X-Ingress-Name: baz
|
||||
X-Service-Name: aaa
|
||||
X-Service-Port: bbb
|
||||
X-Request-ID: ccc
|
||||
|
||||
HTTP/* 200
|
||||
|
||||
[Asserts]
|
||||
header "Content-Type" contains "application/xml"
|
||||
header "X-Robots-Tag" == "noindex"
|
||||
xpath "string(//error/code)" == "502"
|
||||
xpath "string(//error/message)" == "Bad Gateway"
|
||||
xpath "string(//error/description)" == "The server received an invalid response from the upstream server"
|
||||
xpath "string(//error/details/originalURI)" == "foo"
|
||||
xpath "string(//error/details/namespace)" == "bar"
|
||||
xpath "string(//error/details/ingressName)" == "baz"
|
||||
xpath "string(//error/details/serviceName)" == "aaa"
|
||||
xpath "string(//error/details/servicePort)" == "bbb"
|
||||
xpath "string(//error/details/requestID)" == "ccc"
|
||||
xpath "string(//error/details/timestamp)" exists
|
||||
|
||||
# X-Format in the action
|
||||
GET http://{{ host }}:{{ port }}/502.html
|
||||
X-Format: text/xml
|
||||
|
||||
HTTP/* 200
|
||||
|
||||
[Asserts]
|
||||
header "Content-Type" contains "application/xml"
|
||||
header "X-Robots-Tag" == "noindex"
|
||||
xpath "string(//error/code)" == "502"
|
||||
xpath "string(//error/message)" == "Bad Gateway"
|
||||
xpath "string(//error/description)" == "The server received an invalid response from the upstream server"
|
12
test/hurl/healthz.hurl
Normal file
12
test/hurl/healthz.hurl
Normal file
@ -0,0 +1,12 @@
|
||||
GET http://{{ host }}:{{ port }}/healthz
|
||||
|
||||
HTTP/* 200
|
||||
|
||||
```OK```
|
||||
|
||||
# Next endpoint marked as deprecated
|
||||
GET http://{{ host }}:{{ port }}/health/live
|
||||
|
||||
HTTP/* 200
|
||||
|
||||
```OK```
|
34
test/hurl/index.hurl
Normal file
34
test/hurl/index.hurl
Normal file
@ -0,0 +1,34 @@
|
||||
# HTML content
|
||||
GET http://{{ host }}:{{ port }}/
|
||||
|
||||
HTTP/* 404
|
||||
|
||||
[Asserts]
|
||||
header "Content-Type" contains "text/html"
|
||||
body contains "404"
|
||||
body contains "Not Found"
|
||||
|
||||
# JSON content
|
||||
GET http://{{ host }}:{{ port }}/
|
||||
Content-Type: text/json
|
||||
|
||||
HTTP/* 404
|
||||
|
||||
[Asserts]
|
||||
header "Content-Type" contains "application/json"
|
||||
header "X-Robots-Tag" == "noindex"
|
||||
jsonpath "$.error" == true
|
||||
jsonpath "$.code" == "404"
|
||||
jsonpath "$.message" == "Not Found"
|
||||
|
||||
# XML content
|
||||
GET http://{{ host }}:{{ port }}/
|
||||
Content-Type: application/xml
|
||||
|
||||
HTTP/* 404
|
||||
|
||||
[Asserts]
|
||||
header "Content-Type" contains "application/xml"
|
||||
header "X-Robots-Tag" == "noindex"
|
||||
xpath "string(//error/code)" == "404"
|
||||
xpath "string(//error/message)" == "Not Found"
|
27
test/hurl/readme.md
Normal file
27
test/hurl/readme.md
Normal file
@ -0,0 +1,27 @@
|
||||
# Hurl
|
||||
|
||||
Hurl is a command line tool that runs **HTTP requests** defined in a simple **plain text format**.
|
||||
|
||||
## How to use
|
||||
|
||||
It can perform requests, capture values and evaluate queries on headers and body response. Hurl is very versatile: it can be used for both fetching data and testing HTTP sessions.
|
||||
|
||||
```hurl
|
||||
# Get home:
|
||||
GET https://example.net
|
||||
|
||||
HTTP/1.1 200
|
||||
[Captures]
|
||||
csrf_token: xpath "string(//meta[@name='_csrf_token']/@content)"
|
||||
|
||||
# Do login!
|
||||
POST https://example.net/login?user=toto&password=1234
|
||||
X-CSRF-TOKEN: {{csrf_token}}
|
||||
|
||||
HTTP/1.1 302
|
||||
```
|
||||
|
||||
### Links:
|
||||
|
||||
- [Official website](https://hurl.dev/)
|
||||
- [GitHub](https://github.com/Orange-OpenSource/hurl)
|
8
test/hurl/version.hurl
Normal file
8
test/hurl/version.hurl
Normal file
@ -0,0 +1,8 @@
|
||||
GET http://{{ host }}:{{ port }}/version
|
||||
|
||||
HTTP/* 200
|
||||
|
||||
[Asserts]
|
||||
header "Content-Type" == "application/json"
|
||||
jsonpath "$.version" exists
|
||||
jsonpath "$.version" isString
|
37
test/hurl/x_code.hurl
Normal file
37
test/hurl/x_code.hurl
Normal file
@ -0,0 +1,37 @@
|
||||
# Common request to the index page
|
||||
GET http://{{ host }}:{{ port }}/
|
||||
X-Code: 410
|
||||
|
||||
HTTP/* 410
|
||||
|
||||
[Asserts]
|
||||
header "Content-Type" contains "text/html"
|
||||
header "X-Robots-Tag" == "noindex"
|
||||
body contains "410"
|
||||
body contains "Gone"
|
||||
|
||||
# X-Code with X-Format
|
||||
GET http://{{ host }}:{{ port }}/
|
||||
X-Code: 410
|
||||
X-Format: text/json
|
||||
|
||||
HTTP/* 410
|
||||
|
||||
[Asserts]
|
||||
header "Content-Type" contains "application/json"
|
||||
header "X-Robots-Tag" == "noindex"
|
||||
jsonpath "$.error" == true
|
||||
jsonpath "$.code" == "410"
|
||||
jsonpath "$.message" == "Gone"
|
||||
|
||||
# Error pages should ignore X-Code
|
||||
GET http://{{ host }}:{{ port }}/502.html
|
||||
X-Code: 410
|
||||
|
||||
HTTP/* 200
|
||||
|
||||
[Asserts]
|
||||
header "Content-Type" contains "text/html"
|
||||
header "X-Robots-Tag" == "noindex"
|
||||
body contains "502"
|
||||
body contains "Bad Gateway"
|
Loading…
Reference in New Issue
Block a user