Go templates support, XML, JSON, Ingress (#49)

This commit is contained in:
Paramtamtam 2022-01-27 17:29:49 +05:00 committed by GitHub
parent f75bf15552
commit bed576f26c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
76 changed files with 2200 additions and 986 deletions

View File

@ -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
View File

@ -3,7 +3,7 @@
/.vscode
## Binaries
error-pages
/error-pages
## Temp dirs & trash
/temp

View File

@ -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

View File

@ -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"]

View File

@ -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

View File

@ -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).

View 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")
}

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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
}

View File

@ -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{})),

View File

@ -8,5 +8,7 @@ import (
)
func TestLiveChecker_Check(t *testing.T) {
t.Parallel()
assert.NoError(t, checkers.NewLiveChecker().Check())
}

View File

@ -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
}

View File

@ -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")
}

View 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)
}

View 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>

View File

@ -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)

View File

@ -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{})

View File

@ -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 {

View File

@ -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")
}

View File

@ -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
}
}
}
}
})

View File

@ -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{})

View File

@ -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)
}

View File

@ -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
View File

@ -0,0 +1 @@
<html><body>bar {{ code }}</body></html>

1
internal/config/testdata/foo-tpl.html vendored Normal file
View File

@ -0,0 +1 @@
<html><body>foo {{ code }}</body></html>

View File

@ -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
View File

@ -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.

View File

@ -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 {

View File

@ -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")
}

View 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")
}
}
}
}

View 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")
}
}

View 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)))
})
}
}

View 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"
)

View File

@ -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")
}
}
}

View File

@ -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")
}

View File

@ -19,5 +19,6 @@ func NewHandler(checker checker) fasthttp.RequestHandler {
}
ctx.SetStatusCode(fasthttp.StatusOK)
_, _ = ctx.WriteString("OK")
}
}

View File

@ -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")
}

View File

@ -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
}

View File

@ -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")
}

View File

@ -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")
}

View File

@ -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")
}

View File

@ -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()
}

View File

@ -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
View 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")
}
}

View 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() })
}

View File

@ -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()]
}

View File

@ -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())
}
})
}

View File

@ -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
}

View File

@ -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")
}

View 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
}

View 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
View 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
}

View 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"},
)
}
}

View File

@ -5,6 +5,8 @@ import (
)
func TestVersion(t *testing.T) {
t.Parallel()
for give, want := range map[string]string{
// without changes
"vvv": "vvv",

View 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
View 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
View 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"
```

View File

@ -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 }}

View File

@ -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>
<!--

View File

@ -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>
<!--

View File

@ -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>

View File

@ -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>

View File

@ -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
View File

@ -0,0 +1 @@
# TODO: Write docs

View File

@ -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
View 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"

View 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"

View 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"

View 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
View 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
View 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
View 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
View 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
View 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"