Allow to set default error page (#30)

This commit is contained in:
Paramtamtam 2021-10-06 22:38:00 +05:00 committed by GitHub
parent 6b17d3eb7d
commit bb17027cc9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 721 additions and 562 deletions

View File

@ -200,10 +200,11 @@ jobs: # Docs: <https://git.io/JvxXE>
- 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: curl --fail http://127.0.0.1:8080/
- 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/) -eq 404
- run: test $(curl --write-out %{http_code} --silent --output /dev/null http://127.0.0.1:8080/foobar) -eq 404
- name: Stop the container
if: always()

View File

@ -4,6 +4,18 @@ 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
### Added
- `referer` field in access log records
- Flag `--default-error-page` for the `serve` subcommand (`404` is used by default, environment name `DEFAULT_ERROR_PAGE`)
### Changed
- The source code has been refactored
- The index page (`/`) now returns the error page with a code, declared using `--default-error-page` flag (HTTP code 200, when a page code exists)
## v2.0.0
### Changed

View File

@ -64,7 +64,8 @@ USER appuser:appuser
WORKDIR /opt
ENV LISTEN_PORT="8080" \
TEMPLATE_NAME="ghost"
TEMPLATE_NAME="ghost" \
DEFAULT_ERROR_PAGE="404"
# Docs: <https://docs.docker.com/engine/reference/builder/#healthcheck>
HEALTHCHECK --interval=7s --timeout=2s CMD [ \

View File

@ -30,8 +30,6 @@ 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:
[![image stats](https://dockeri.co/image/tarampampam/error-pages)][link_docker_hub]
Registry | Image
-------------------------------------- | -----
[Docker Hub][link_docker_hub] | `tarampampam/error-pages`
@ -56,13 +54,25 @@ $ docker run --rm -it \
</p>
</details>
## 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)
> 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**.
### HTTP server
<details>
<summary><strong>HTTP server</strong></summary>
As mentioned above - our application can be run as an HTTP server. It only needs to specify the path to the configuration file (it does not need statically generated error pages). The server uses [FastHTTP][fasthttp] and stores all necessary data in memory - so it does not use the file system and very fast. Oh yes, the image with the app also contains a configured **healthcheck** and **logs in JSON** format :)
@ -86,8 +96,10 @@ To see the help run the following command:
```bash
$ docker run --rm tarampampam/error-pages serve --help
```
</details>
### Generator
<details>
<summary><strong>Generator</strong></summary>
Create a config file (`error-pages.yml`) with the following content:
@ -158,13 +170,15 @@ To see the usage help run the following command:
```bash
$ docker run --rm tarampampam/error-pages build --help
```
</details>
### Static error pages
<details>
<summary><strong>Static error pages</strong></summary>
You may want to use the generated error pages somewhere else, and you can simply extract them from the docker image to your local directory for this purpose:
```bash
$ docker create --name error-pages tarampampam/error-pages:2.0.0-rc2
$ docker create --name error-pages tarampampam/error-pages
$ docker cp error-pages:/opt/html ./out
$ docker rm -f error-pages
$ ls ./out
@ -187,7 +201,35 @@ $ tree
...
```
### Custom error pages for your image with [nginx][link_nginx]
Or inside another docker image:
```dockerfile
FROM alpine:latest
COPY --from=tarampampam/error-pages /opt/html /error-pages
RUN ls -l /error-pages
```
```bash
$ docker build --rm .
...
Step 3/3 : RUN ls -l /error-pages
---> Running in 30095dc344a9
total 12
drwxr-xr-x 2 root root 326 Sep 29 15:44 ghost
drwxr-xr-x 2 root root 326 Sep 29 15:44 hacker-terminal
-rw-r--r-- 1 root root 11241 Sep 29 15:44 index.html
drwxr-xr-x 2 root root 326 Sep 29 15:44 l7-dark
drwxr-xr-x 2 root root 326 Sep 29 15:44 l7-light
drwxr-xr-x 2 root root 326 Sep 29 15:44 noise
drwxr-xr-x 2 root root 326 Sep 29 15:44 shuffle
```
</details>
<details>
<summary><strong>Custom error pages for your image with nginx</strong></summary>
You can build your own docker image with `nginx` and our error pages:
@ -234,19 +276,7 @@ $ docker build --tag your-nginx:local -f ./Dockerfile .
```
> More info about `error_page` directive can be [found here](http://nginx.org/en/docs/http/ngx_http_core_module.html#error_page).
## 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)
> Note: `noise` template highly uses the CPU, be careful
</details>
## Custom error pages for [Traefik][link_traefik]

View File

@ -8,7 +8,7 @@ import (
"syscall"
)
// OSSignals allows to subscribe for system signals.
// OSSignals allows subscribing for system signals.
type OSSignals struct {
ctx context.Context
ch chan os.Signal
@ -22,7 +22,7 @@ func NewOSSignals(ctx context.Context) OSSignals {
}
}
// Subscribe for some of system signals (call Stop for stopping).
// Subscribe for some system signals (call Stop for stopping).
func (oss *OSSignals) Subscribe(onSignal func(os.Signal), signals ...os.Signal) {
if len(signals) == 0 {
signals = []os.Signal{os.Interrupt, syscall.SIGINT, syscall.SIGTERM} // default signals

View File

@ -19,7 +19,7 @@ type historyItem struct {
}
// NewCommand creates `build` command.
func NewCommand(log *zap.Logger, configFile *string) *cobra.Command { //nolint:funlen,gocognit
func NewCommand(log *zap.Logger, configFile *string) *cobra.Command { //nolint:funlen,gocognit,gocyclo
var (
generateIndex bool
cfg *config.Config
@ -52,33 +52,34 @@ func NewCommand(log *zap.Logger, configFile *string) *cobra.Command { //nolint:f
return errors.New("wrong arguments count")
}
log.Info("loading templates")
errorPages := tpl.NewErrorPages()
templates, err := cfg.LoadTemplates()
if err != nil {
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
} else if len(templates) == 0 {
return errors.New("no loaded templates")
}
log.Debug("the output directory preparing", zap.String("Path", args[0]))
if err = createDirectory(args[0]); err != nil {
if err := createDirectory(args[0]); err != nil {
return errors.Wrap(err, "cannot prepare output directory")
}
codes := make(map[string]tpl.Annotator)
for code, desc := range cfg.Pages {
codes[code] = tpl.Annotator{Message: desc.Message, Description: desc.Description}
}
history := make(map[string][]historyItem, len(templates))
history, startedAt := make(map[string][]historyItem), time.Now()
log.Info("saving the error pages")
startedAt := time.Now()
if err = tpl.NewErrors(templates, codes).VisitAll(func(template, code string, content []byte) error {
if err := errorPages.IteratePages(func(template, code string, content []byte) error {
if e := createDirectory(path.Join(args[0], template)); e != nil {
return e
}
@ -90,18 +91,18 @@ func NewCommand(log *zap.Logger, configFile *string) *cobra.Command { //nolint:f
}
if _, ok := history[template]; !ok {
history[template] = make([]historyItem, 0, len(codes))
history[template] = make([]historyItem, 0, len(cfg.Pages))
}
history[template] = append(history[template], historyItem{
Code: code,
Message: codes[code].Message,
Message: cfg.Pages[code].Message,
Path: path.Join(template, fileName),
})
return nil
}); err != nil {
return nil
return err
}
log.Debug("saved", zap.Duration("duration", time.Since(startedAt)))
@ -110,7 +111,7 @@ func NewCommand(log *zap.Logger, configFile *string) *cobra.Command { //nolint:f
log.Info("index file generation")
startedAt = time.Now()
if err = writeIndexFile(path.Join(args[0], "index.html"), history); err != nil {
if err := writeIndexFile(path.Join(args[0], "index.html"), history); err != nil {
return err
}

View File

@ -4,16 +4,16 @@ import (
"context"
"errors"
"os"
"sort"
"time"
"github.com/tarampampam/error-pages/internal/http/handlers/errorpage"
"github.com/tarampampam/error-pages/internal/tpl"
"go.uber.org/zap"
"github.com/spf13/cobra"
"github.com/tarampampam/error-pages/internal/breaker"
"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"
)
// NewCommand creates `serve` command.
@ -56,8 +56,6 @@ func NewCommand(ctx context.Context, log *zap.Logger, configFile *string) *cobra
return cmd
}
const serverShutdownTimeout = 15 * time.Second
// run current command.
func run(parentCtx context.Context, log *zap.Logger, f flags, cfg *config.Config) error { //nolint:funlen
var (
@ -77,35 +75,78 @@ func run(parentCtx context.Context, log *zap.Logger, f flags, cfg *config.Config
oss.Stop() // stop system signals listening
}()
// load templates content
templates, loadingErr := cfg.LoadTemplates()
if loadingErr != nil {
return loadingErr
} else if len(templates) == 0 {
return errors.New("no loaded templates")
}
var (
errorPages = tpl.NewErrorPages()
templateNames = make([]string, 0) // slice with all possible template names
)
if f.template.name != "" && f.template.name != errorpage.UseRandom && f.template.name != errorpage.UseRandomOnEachRequest { //nolint:lll
if _, found := templates[f.template.name]; !found {
return errors.New("requested nonexistent template: " + f.template.name) // requested unknown template
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
}
// burn the error codes map
codes := make(map[string]tpl.Annotator)
for code, desc := range cfg.Pages {
codes[code] = tpl.Annotator{Message: desc.Message, Description: desc.Description}
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")
picker = pick.NewStringsSlice(templateNames, pick.RandomOnce)
case useRandomTemplateOnEachRequest:
log.Info("A random template on EACH request will be used")
picker = pick.NewStringsSlice(templateNames, pick.RandomEveryTime)
case "":
log.Info("The first template (ordered by name) will be used")
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 {
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.
if err := server.Register(f.template.name, templates, codes); err != nil {
return err
}
server.Register(&errorPages, picker, f.defaultErrorPage)
startingErrCh := make(chan error, 1) // channel for server starting error
startedAt, startingErrCh := time.Now(), make(chan error, 1) // channel for server starting error
// start HTTP server in separate goroutine
go func(errCh chan<- error) {
@ -114,7 +155,7 @@ func run(parentCtx context.Context, log *zap.Logger, f flags, cfg *config.Config
log.Info("Server starting",
zap.String("addr", f.listen.ip),
zap.Uint16("port", f.listen.port),
zap.String("template name", f.template.name),
zap.String("default error page", f.defaultErrorPage),
)
if err := server.Start(f.listen.ip, f.listen.port); err != nil {
@ -128,20 +169,12 @@ func run(parentCtx context.Context, log *zap.Logger, f flags, cfg *config.Config
return err
case <-ctx.Done(): // ..or context cancellation
log.Info("Gracefully server stopping")
stoppedAt := time.Now()
log.Info("Gracefully server stopping", zap.Duration("uptime", time.Since(startedAt)))
// stop the server using created context above
if err := server.Stop(serverShutdownTimeout); err != nil {
if errors.Is(err, context.DeadlineExceeded) {
log.Error("Server stopping timeout exceeded", zap.Duration("timeout", serverShutdownTimeout))
}
if err := server.Stop(); err != nil {
return err
}
log.Debug("Server stopped", zap.Duration("stopping duration", time.Since(stoppedAt)))
}
return nil

View File

@ -6,8 +6,6 @@ import (
"strconv"
"strings"
"github.com/tarampampam/error-pages/internal/http/handlers/errorpage"
"github.com/spf13/pflag"
"github.com/tarampampam/error-pages/internal/env"
)
@ -20,12 +18,19 @@ type flags struct {
template struct {
name string
}
defaultErrorPage string
}
const (
listenFlagName = "listen"
portFlagName = "port"
templateNameFlagName = "template-name"
listenFlagName = "listen"
portFlagName = "port"
templateNameFlagName = "template-name"
defaultErrorPageFlagName = "default-error-page"
)
const (
useRandomTemplate = "random"
useRandomTemplateOnEachRequest = "i-said-random"
)
func (f *flags) init(flagSet *pflag.FlagSet) {
@ -46,10 +51,16 @@ func (f *flags) init(flagSet *pflag.FlagSet) {
templateNameFlagName, "t",
"",
fmt.Sprintf(
"template name (set \"%s\" to use the randomized or \"%s\" to use the randomized template on each request) [$%s]", //nolint:lll
errorpage.UseRandom, errorpage.UseRandomOnEachRequest, env.TemplateName,
"template name (set \"%s\" to use a randomized or \"%s\" to use a randomized template on each request) [$%s]", //nolint:lll
useRandomTemplate, useRandomTemplateOnEachRequest, env.TemplateName,
),
)
flagSet.StringVarP(
&f.defaultErrorPage,
defaultErrorPageFlagName, "",
"404",
fmt.Sprintf("default error page [$%s]", env.DefaultErrorPage),
)
}
func (f *flags) overrideUsingEnv(flagSet *pflag.FlagSet) (lastErr error) {
@ -75,6 +86,11 @@ func (f *flags) overrideUsingEnv(flagSet *pflag.FlagSet) (lastErr error) {
if envVar, exists := env.TemplateName.Lookup(); exists {
f.template.name = strings.TrimSpace(envVar)
}
case defaultErrorPageFlagName:
if envVar, exists := env.DefaultErrorPage.Lookup(); exists {
f.defaultErrorPage = strings.TrimSpace(envVar)
}
}
}
})

9
internal/env/env.go vendored
View File

@ -6,10 +6,11 @@ import "os"
type envVariable string
const (
ListenAddr envVariable = "LISTEN_ADDR" // IP address for listening
ListenPort envVariable = "LISTEN_PORT" // port number for listening
TemplateName envVariable = "TEMPLATE_NAME" // template name
ConfigFilePath envVariable = "CONFIG_FILE" // path to the config file
ListenAddr envVariable = "LISTEN_ADDR" // IP address for listening
ListenPort envVariable = "LISTEN_PORT" // port number for listening
TemplateName envVariable = "TEMPLATE_NAME" // template name
ConfigFilePath envVariable = "CONFIG_FILE" // path to the config file
DefaultErrorPage envVariable = "DEFAULT_ERROR_PAGE" // default error page (code)
)
// String returns environment variable name in the string representation.

View File

@ -12,6 +12,7 @@ func TestConstants(t *testing.T) {
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))
}
func TestEnvVariable_Lookup(t *testing.T) {
@ -22,6 +23,7 @@ func TestEnvVariable_Lookup(t *testing.T) {
{giveEnv: ListenPort},
{giveEnv: TemplateName},
{giveEnv: ConfigFilePath},
{giveEnv: DefaultErrorPage},
}
for _, tt := range cases {

View File

@ -1,35 +0,0 @@
package common
import (
"strings"
"github.com/valyala/fasthttp"
)
const internalErrorPattern = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow" />
<title>Internal error occurred</title>
<style>
html,body {background-color: #0e0e0e;color:#fff;font-family:'Nunito',sans-serif;height:100%;margin:0}
.message {height:100%;align-items:center;display:flex;justify-content:center;position:relative;font-size:1.4em}
img {padding-right: .4em}
</style>
</head>
<body>
<div class="message">
<img src="https://hsto.org/webt/fs/sx/gt/fssxgtssfg689qxboqvjil5yz8g.png" alt="logo" height="32">
<p>{{ message }}</p>
</div>
</body>
</html>`
func HandleInternalHTTPError(ctx *fasthttp.RequestCtx, statusCode int, message string) {
ctx.SetStatusCode(statusCode)
ctx.SetContentType("text/html; charset=UTF-8")
_, _ = ctx.WriteString(strings.ReplaceAll(internalErrorPattern, "{{ message }}", message))
}

View File

@ -25,6 +25,7 @@ func LogRequest(h fasthttp.RequestHandler, log *zap.Logger) fasthttp.RequestHand
zap.String("useragent", ua),
zap.String("method", string(ctx.Method())),
zap.String("url", string(ctx.RequestURI())),
zap.String("referer", string(ctx.Referer())),
zap.Int("status_code", ctx.Response.StatusCode()),
zap.Bool("connection_close", ctx.Response.ConnectionClose()),
zap.Duration("duration", time.Since(startedAt)),

View File

@ -1,85 +1,38 @@
package errorpage
import (
"math/rand"
"sort"
"time"
"github.com/pkg/errors"
"github.com/tarampampam/error-pages/internal/http/common"
"github.com/tarampampam/error-pages/internal/tpl"
"github.com/valyala/fasthttp"
)
const (
UseRandom = "random"
UseRandomOnEachRequest = "i-said-random"
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
}
)
// NewHandler creates handler for error pages serving.
func NewHandler(
templateName string,
templates map[string][]byte,
codes map[string]tpl.Annotator,
) (fasthttp.RequestHandler, error) {
if len(templates) == 0 {
return nil, errors.New("empty templates map")
}
var (
rnd = rand.New(rand.NewSource(time.Now().UnixNano())) //nolint:gosec
templateNames = templateTames(templates)
)
if templateName == "" { // on empty template name
templateName = templateNames[0] // pick the first
} else if templateName == UseRandom { // on "random" template name
templateName = templateNames[rnd.Intn(len(templateNames))] // pick the randomized
}
if _, found := templates[templateName]; !found && templateName != UseRandomOnEachRequest {
return nil, errors.New("wrong template name passed")
}
var pages = tpl.NewErrors(templates, codes)
func NewHandler(e errorsPager, p templatePicker) fasthttp.RequestHandler {
return func(ctx *fasthttp.RequestCtx) {
var useTemplate = templateName // default
if templateName == UseRandomOnEachRequest {
useTemplate = templateNames[rnd.Intn(len(templateNames))] // pick the randomized
}
ctx.SetContentType("text/plain; charset=utf-8") // default content type
if code, ok := ctx.UserValue("code").(string); ok {
if content, err := pages.Get(useTemplate, code); err == nil {
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 {
common.HandleInternalHTTPError(
ctx,
fasthttp.StatusNotFound,
"requested code not available: "+err.Error(),
)
ctx.SetStatusCode(fasthttp.StatusNotFound)
_, _ = ctx.WriteString("requested code not available: " + err.Error()) // TODO customize the output?
}
} else { // will never happen
common.HandleInternalHTTPError(
ctx,
fasthttp.StatusInternalServerError,
"cannot extract requested code from the request",
)
ctx.SetStatusCode(fasthttp.StatusInternalServerError)
_, _ = ctx.WriteString("cannot extract requested code from the request") // TODO customize the output?
}
}, nil
}
func templateTames(templates map[string][]byte) []string {
var templateNames = make([]string, 0, len(templates))
for name := range templates {
templateNames = append(templateNames, name)
}
sort.Strings(templateNames)
return templateNames
}

View File

@ -0,0 +1,36 @@
package index
import (
"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
}
)
// NewHandler creates handler for the index page serving.
func NewHandler(e errorsPager, p templatePicker, defaultPageCode string) fasthttp.RequestHandler {
return func(ctx *fasthttp.RequestCtx) {
content, err := e.GetPage(p.Pick(), defaultPageCode)
if err == nil {
ctx.SetContentType("text/html; charset=utf-8")
ctx.SetStatusCode(fasthttp.StatusOK)
_, _ = ctx.Write(content)
return
}
ctx.SetContentType("text/plain; charset=utf-8")
ctx.SetStatusCode(fasthttp.StatusNotAcceptable)
_, _ = ctx.WriteString("default page code " + defaultPageCode + " is not available: " + err.Error())
}
}

View File

@ -1,4 +1,4 @@
package common_test
package index_test
import "testing"

View File

@ -0,0 +1,14 @@
package notfound
import (
"github.com/valyala/fasthttp"
)
// NewHandler creates handler missing requests handling.
func NewHandler() fasthttp.RequestHandler {
return func(ctx *fasthttp.RequestCtx) {
ctx.SetContentType("text/plain; charset=utf-8")
ctx.SetStatusCode(fasthttp.StatusNotFound)
_, _ = ctx.WriteString("Wrong request URL. Error pages are available at the following URLs: /{code}.html")
}
}

View File

@ -0,0 +1,7 @@
package notfound_test
import "testing"
func TestNothing(t *testing.T) {
t.Skip("tests for this package have not been implemented yet")
}

View File

@ -1,7 +1,6 @@
package http
import (
"context"
"strconv"
"time"
@ -10,8 +9,9 @@ import (
"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"
indexHandler "github.com/tarampampam/error-pages/internal/http/handlers/index"
notfoundHandler "github.com/tarampampam/error-pages/internal/http/handlers/notfound"
versionHandler "github.com/tarampampam/error-pages/internal/http/handlers/version"
"github.com/tarampampam/error-pages/internal/tpl"
"github.com/tarampampam/error-pages/internal/version"
"github.com/valyala/fasthttp"
"go.uber.org/zap"
@ -23,9 +23,9 @@ type Server struct {
}
const (
defaultWriteTimeout = time.Second * 7
defaultReadTimeout = time.Second * 7
defaultIdleTimeout = time.Second * 15
defaultWriteTimeout = time.Second * 4
defaultReadTimeout = time.Second * 4
defaultIdleTimeout = time.Second * 6
)
func NewServer(log *zap.Logger) Server {
@ -52,55 +52,28 @@ 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
}
)
// Register server routes, middlewares, etc.
// Router docs: <https://github.com/fasthttp/router>
func (s *Server) Register(
templateName string,
templates map[string][]byte,
codes map[string]tpl.Annotator,
) error {
s.router.GET("/", func(ctx *fasthttp.RequestCtx) {
common.HandleInternalHTTPError(
ctx,
fasthttp.StatusNotFound,
"Hi there! Error pages are available at the following URLs: /{code}.html",
)
})
s.router.NotFound = func(ctx *fasthttp.RequestCtx) {
common.HandleInternalHTTPError(
ctx,
fasthttp.StatusNotFound,
"Wrong request URL. Error pages are available at the following URLs: /{code}.html",
)
}
func (s *Server) Register(errorsPager errorsPager, templatePicker templatePicker, defaultPageCode string) {
s.router.GET("/", indexHandler.NewHandler(errorsPager, templatePicker, defaultPageCode))
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))
if h, err := errorpageHandler.NewHandler(templateName, templates, codes); err != nil {
return err
} else {
s.router.GET("/{code}.html", h)
}
return nil
s.router.NotFound = notfoundHandler.NewHandler()
}
// Stop server.
func (s *Server) Stop(timeout time.Duration) error {
ctx, cancel := context.WithTimeout(context.Background(), timeout) // TODO replace with simple time.After
defer cancel()
ch := make(chan error, 1) // channel for server stopping error
go func() { defer close(ch); ch <- s.fast.Shutdown() }()
select {
case err := <-ch:
return err
case <-ctx.Done():
return ctx.Err()
}
}
func (s *Server) Stop() error { return s.fast.Shutdown() }

View File

@ -0,0 +1,64 @@
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
}
// 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,
}
}
// Pick an element from the strings slice.
func (s *StringsSlice) Pick() string {
if l := len(s.items); l == 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")
}
}

View File

@ -0,0 +1,102 @@
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)
for i := 0; i < 100; i++ {
if len(items) == 0 {
assert.Equal(t, "", p.Pick())
} else {
assert.Equal(t, "foo", p.Pick())
}
}
})
}
}
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))]
}
items[i] = string(b)
}
return items
}

136
internal/tpl/error_pages.go Normal file
View File

@ -0,0 +1,136 @@
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

@ -0,0 +1,132 @@
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

@ -1,102 +0,0 @@
package tpl
import (
"errors"
"sync"
)
// Annotator allows to annotate error code.
type Annotator struct {
Message string
Description string
}
// Errors is a "cached storage" for the rendered error pages for the different templates and codes.
type Errors struct {
templates map[string][]byte
codes map[string]Annotator
cacheMu sync.RWMutex
cache map[string]map[string][]byte // map[template]map[code]content
}
// NewErrors creates new Errors.
func NewErrors(templates map[string][]byte, codes map[string]Annotator) *Errors {
return &Errors{
templates: templates,
codes: codes,
cache: make(map[string]map[string][]byte),
}
}
func (e *Errors) existsInCache(template, code string) ([]byte, bool) {
e.cacheMu.RLock()
defer e.cacheMu.RUnlock()
if codes, tplOk := e.cache[template]; tplOk {
if content, codeOk := codes[code]; codeOk {
return content, true
}
}
return nil, false
}
func (e *Errors) putInCache(template, code string) error {
if _, ok := e.templates[template]; !ok {
return errors.New("template \"" + template + "\" does not exists")
}
if _, ok := e.codes[code]; !ok {
return errors.New("code \"" + code + "\" does not exists")
}
e.cacheMu.Lock()
defer e.cacheMu.Unlock()
if _, ok := e.cache[template]; !ok {
e.cache[template] = make(map[string][]byte)
}
e.cache[template][code] = Replace(e.templates[template], Replaces{
Code: code,
Message: e.codes[code].Message,
Description: e.codes[code].Description,
})
return nil
}
// Get the rendered error page content.
func (e *Errors) Get(template, code string) ([]byte, error) {
if content, ok := e.existsInCache(template, code); ok {
return content, nil
}
if err := e.putInCache(template, code); err != nil {
return nil, err
}
e.cacheMu.RLock()
defer e.cacheMu.RUnlock()
return e.cache[template][code], nil
}
// VisitAll allows to iterate all possible error pages and templates.
func (e *Errors) VisitAll(fn func(template, code string, content []byte) error) error {
for tpl := range e.templates {
for code := range e.codes {
content, err := e.Get(tpl, code)
if err != nil {
return err // will never happen
}
if err = fn(tpl, code, content); err != nil {
return err
}
}
}
return nil
}

View File

@ -1,106 +0,0 @@
package tpl_test
import (
"errors"
"sync"
"testing"
"github.com/stretchr/testify/assert"
"github.com/tarampampam/error-pages/internal/tpl"
)
func TestErrors_Get(t *testing.T) {
e := tpl.NewErrors(
map[string][]byte{"foo": []byte("{{ code }}: {{ message }} {{ description }}")},
map[string]tpl.Annotator{"200": {"ok", "all is ok"}},
)
content, err := e.Get("foo", "200")
assert.NoError(t, err)
assert.Equal(t, "200: ok all is ok", string(content))
content, err = e.Get("foo", "666")
assert.EqualError(t, err, "code \"666\" does not exists")
assert.Nil(t, content)
content, err = e.Get("bar", "200")
assert.EqualError(t, err, "template \"bar\" does not exists")
assert.Nil(t, content)
}
func TestErrors_GetConcurrent(t *testing.T) {
e := tpl.NewErrors(
map[string][]byte{"foo": []byte("{{ code }}: {{ message }} {{ description }}")},
map[string]tpl.Annotator{"200": {"ok", "all is ok"}},
)
var wg sync.WaitGroup
for i := 0; i < 1234; i++ {
wg.Add(1)
go func() {
defer wg.Done()
content, err := e.Get("foo", "200")
assert.NoError(t, err)
assert.Equal(t, "200: ok all is ok", string(content))
content, err = e.Get("foo", "666")
assert.Error(t, err)
assert.Nil(t, content)
}()
}
wg.Wait()
}
func TestErrors_VisitAll(t *testing.T) {
e := tpl.NewErrors(
map[string][]byte{
"foo": []byte("{{ code }}: {{ message }} {{ description }}"),
"bar": []byte("{{ code }}: {{ message }} {{ description }}"),
},
map[string]tpl.Annotator{
"200": {"ok", "all is ok"},
"400": {"Bad Request", "The server did not understand the request"},
},
)
visited := make(map[string]map[string]bool) // map[template]codes
assert.NoError(t, e.VisitAll(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 TestErrors_VisitAllWillReturnTheError(t *testing.T) {
e := tpl.NewErrors(
map[string][]byte{
"foo": []byte("{{ code }}: {{ message }} {{ description }}"),
},
map[string]tpl.Annotator{
"200": {"ok", "all is ok"},
},
)
assert.EqualError(t, e.VisitAll(func(template, code string, content []byte) error {
return errors.New("foo error")
}), "foo error")
}

View File

@ -1,47 +0,0 @@
package tpl
import "bytes"
type Replaces struct {
Code string
Message string
Description string
}
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 }}")},
}
// Replace found tokens in the incoming slice with passed tokens.
func Replace(in []byte, re Replaces) []byte {
for tkn, set := range tknSets {
var replaceWith []byte
switch tkn {
case tknCode:
replaceWith = []byte(re.Code)
case tknMessage:
replaceWith = []byte(re.Message)
case tknDescription:
replaceWith = []byte(re.Description)
default:
panic("tpl: unsupported token")
}
if len(replaceWith) > 0 {
for i := 0; i < len(set); i++ {
in = bytes.ReplaceAll(in, set[i], replaceWith)
}
}
}
return in
}

View File

@ -1,66 +0,0 @@
package tpl_test
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
"github.com/tarampampam/error-pages/internal/tpl"
)
func ExampleReplace() {
var in = []byte("{{ code }}: {{message}} ({{ description }})")
fmt.Println(string(tpl.Replace(in, tpl.Replaces{
Code: "400",
Message: "Bad Request",
Description: "The server did not understand the request",
})))
// Output:
// 400: Bad Request (The server did not understand the request)
}
func TestReplace(t *testing.T) {
for name, tt := range map[string]struct {
giveIn []byte
giveRe tpl.Replaces
wantResult []byte
}{
"common": {
giveIn: []byte("-- {{ code }} {{code}} __ {{message}} {{ description }} "),
giveRe: tpl.Replaces{
Code: "123",
Message: "message",
Description: "desc",
},
wantResult: []byte("-- 123 123 __ message desc "),
},
"alpha and underline in the code": {
giveIn: []byte("\t{{ code }}\t"),
giveRe: tpl.Replaces{
Code: " qwertyuiopasdfghjklzxcvbnmQWERTYUIOPASDFGHJKLZXCVBNM_ ",
},
wantResult: []byte("\t qwertyuiopasdfghjklzxcvbnmQWERTYUIOPASDFGHJKLZXCVBNM_ \t"),
},
} {
tt := tt
t.Run(name, func(t *testing.T) {
assert.Equal(t, tt.wantResult, tpl.Replace(tt.giveIn, tt.giveRe))
})
}
}
func BenchmarkReplace(b *testing.B) {
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
tpl.Replace([]byte("-- {{ code }} {{code}} __ {{message}} {{ description }} "), tpl.Replaces{
Code: "123",
Message: "message",
Description: "desc",
})
}
}