v2: App rewritten in Go (#25)

This commit is contained in:
Paramtamtam
2021-09-29 20:38:50 +05:00
committed by GitHub
parent ce98410e51
commit 29f024ebcc
66 changed files with 3923 additions and 716 deletions

View File

@ -0,0 +1,198 @@
package build
import (
"bytes"
"os"
"path"
"text/template"
"time"
"github.com/pkg/errors"
"github.com/spf13/cobra"
"github.com/tarampampam/error-pages/internal/config"
"github.com/tarampampam/error-pages/internal/tpl"
"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
var (
generateIndex bool
cfg *config.Config
)
cmd := &cobra.Command{
Use: "build <output-directory>",
Aliases: []string{"b"},
Short: "Build the error pages",
Args: cobra.ExactArgs(1),
PreRunE: func(*cobra.Command, []string) 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 {
return err
} else {
if err = c.Validate(); err != nil {
return err
}
cfg = c
}
return nil
},
RunE: func(_ *cobra.Command, args []string) error {
if len(args) != 1 {
return errors.New("wrong arguments count")
}
log.Info("loading templates")
templates, err := cfg.LoadTemplates()
if err != nil {
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 {
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))
log.Info("saving the error pages")
startedAt := time.Now()
if err = tpl.NewErrors(templates, codes).VisitAll(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(codes))
}
history[template] = append(history[template], historyItem{
Code: code,
Message: codes[code].Message,
Path: path.Join(template, fileName),
})
return nil
}); err != nil {
return nil
}
log.Debug("saved", zap.Duration("duration", time.Since(startedAt)))
if generateIndex {
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
},
}
cmd.Flags().BoolVarP(
&generateIndex,
"index", "i",
false,
"generate index page",
)
return cmd
}
func createDirectory(path string) error {
stat, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
return os.MkdirAll(path, 0775) //nolint:gomnd
}
return err
}
if !stat.IsDir() {
return errors.New("is not a directory")
}
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

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

View File

@ -0,0 +1,57 @@
// Package healthcheck contains CLI `healthcheck` command implementation.
package healthcheck
import (
"fmt"
"strconv"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/tarampampam/error-pages/internal/env"
)
type checker interface {
Check(port uint16) error
}
const portFlagName = "port"
// NewCommand creates `healthcheck` command.
func NewCommand(checker checker) *cobra.Command {
var port uint16
cmd := &cobra.Command{
Use: "healthcheck",
Aliases: []string{"chk", "health", "check"},
Short: "Health checker for the HTTP server. Use case - docker healthcheck",
PreRunE: func(c *cobra.Command, _ []string) (lastErr error) {
c.Flags().VisitAll(func(flag *pflag.Flag) {
// flag was NOT defined using CLI (flags should have maximal priority)
if !flag.Changed && flag.Name == portFlagName {
if envPort, exists := env.ListenPort.Lookup(); exists && envPort != "" {
if p, err := strconv.ParseUint(envPort, 10, 16); err == nil { //nolint:gomnd
port = uint16(p)
} else {
lastErr = fmt.Errorf("wrong TCP port environment variable [%s] value", envPort)
}
}
}
})
return lastErr
},
RunE: func(*cobra.Command, []string) error {
return checker.Check(port)
},
}
cmd.Flags().Uint16VarP(
&port,
portFlagName,
"p",
8080, //nolint:gomnd // must be same as default serve `--port` flag value
fmt.Sprintf("TCP port number [$%s]", env.ListenPort),
)
return cmd
}

View File

@ -0,0 +1,94 @@
package healthcheck_test
import (
"errors"
"os"
"testing"
"github.com/kami-zh/go-capturer"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/tarampampam/error-pages/internal/cli/healthcheck"
)
type fakeChecker struct{ err error }
func (c *fakeChecker) Check(port uint16) error { return c.err }
func TestProperties(t *testing.T) {
cmd := healthcheck.NewCommand(&fakeChecker{err: nil})
assert.Equal(t, "healthcheck", cmd.Use)
assert.ElementsMatch(t, []string{"chk", "health", "check"}, cmd.Aliases)
assert.NotNil(t, cmd.RunE)
}
func TestCommandRun(t *testing.T) {
cmd := healthcheck.NewCommand(&fakeChecker{err: nil})
cmd.SetArgs([]string{})
output := capturer.CaptureOutput(func() {
assert.NoError(t, cmd.Execute())
})
assert.Empty(t, output)
}
func TestCommandRunFailed(t *testing.T) {
cmd := healthcheck.NewCommand(&fakeChecker{err: errors.New("foo err")})
cmd.SetArgs([]string{})
output := capturer.CaptureStderr(func() {
assert.Error(t, cmd.Execute())
})
assert.Contains(t, output, "foo err")
}
func TestPortFlagWrongArgument(t *testing.T) {
cmd := healthcheck.NewCommand(&fakeChecker{err: nil})
cmd.SetArgs([]string{"-p", "65536"}) // 65535 is max
var executed bool
cmd.RunE = func(*cobra.Command, []string) error {
executed = true
return nil
}
output := capturer.CaptureStderr(func() {
assert.Error(t, cmd.Execute())
})
assert.Contains(t, output, "invalid argument")
assert.Contains(t, output, "65536")
assert.Contains(t, output, "value out of range")
assert.False(t, executed)
}
func TestPortFlagWrongEnvValue(t *testing.T) {
cmd := healthcheck.NewCommand(&fakeChecker{err: nil})
cmd.SetArgs([]string{})
assert.NoError(t, os.Setenv("LISTEN_PORT", "65536")) // 65535 is max
defer func() { assert.NoError(t, os.Unsetenv("LISTEN_PORT")) }()
var executed bool
cmd.RunE = func(*cobra.Command, []string) error {
executed = true
return nil
}
output := capturer.CaptureStderr(func() {
assert.Error(t, cmd.Execute())
})
assert.Contains(t, output, "wrong TCP port")
assert.Contains(t, output, "environment variable")
assert.Contains(t, output, "65536")
assert.False(t, executed)
}

93
internal/cli/root.go Normal file
View File

@ -0,0 +1,93 @@
// Package cli contains CLI command handlers.
package cli
import (
"context"
"fmt"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/tarampampam/error-pages/internal/checkers"
buildCmd "github.com/tarampampam/error-pages/internal/cli/build"
healthcheckCmd "github.com/tarampampam/error-pages/internal/cli/healthcheck"
serveCmd "github.com/tarampampam/error-pages/internal/cli/serve"
versionCmd "github.com/tarampampam/error-pages/internal/cli/version"
"github.com/tarampampam/error-pages/internal/env"
"github.com/tarampampam/error-pages/internal/logger"
"github.com/tarampampam/error-pages/internal/version"
)
const configFileFlagName = "config-file"
// NewCommand creates root command.
func NewCommand(appName string) *cobra.Command { //nolint:funlen
var (
configFile string
verbose bool
debug bool
logJSON bool
)
ctx := context.Background() // main CLI context
// create "default" logger (will be overwritten later with customized)
log, err := logger.New(false, false, false)
if err != nil {
panic(err)
}
cmd := &cobra.Command{
Use: appName,
PersistentPreRunE: func(c *cobra.Command, _ []string) error {
_ = log.Sync() // sync previous logger instance
customizedLog, e := logger.New(verbose, debug, logJSON)
if e != nil {
return e
}
*log = *customizedLog // override "default" logger with customized
c.Flags().VisitAll(func(flag *pflag.Flag) {
// flag was NOT defined using CLI (flags should have maximal priority)
if !flag.Changed && flag.Name == configFileFlagName {
if envConfigFile, exists := env.ConfigFilePath.Lookup(); exists && envConfigFile != "" {
configFile = envConfigFile
}
}
})
return nil
},
PersistentPostRun: func(*cobra.Command, []string) {
// error ignoring reasons:
// - <https://github.com/uber-go/zap/issues/772>
// - <https://github.com/uber-go/zap/issues/328>
_ = log.Sync()
},
SilenceErrors: true,
SilenceUsage: true,
CompletionOptions: cobra.CompletionOptions{
DisableDefaultCmd: true,
},
}
cmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose output")
cmd.PersistentFlags().BoolVarP(&debug, "debug", "", false, "debug output")
cmd.PersistentFlags().BoolVarP(&logJSON, "log-json", "", false, "logs in JSON format")
cmd.PersistentFlags().StringVarP(
&configFile,
configFileFlagName, "c",
"./error-pages.yml",
fmt.Sprintf("path to the config file [$%s]", env.ConfigFilePath),
)
cmd.AddCommand(
versionCmd.NewCommand(version.Version()),
healthcheckCmd.NewCommand(checkers.NewHealthChecker(ctx)),
buildCmd.NewCommand(log, &configFile),
serveCmd.NewCommand(ctx, log, &configFile),
)
return cmd
}

84
internal/cli/root_test.go Normal file
View File

@ -0,0 +1,84 @@
package cli_test
import (
"testing"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/tarampampam/error-pages/internal/cli"
)
func TestSubcommands(t *testing.T) {
cmd := cli.NewCommand("unit test")
cases := []struct {
giveName string
}{
{giveName: "build"},
{giveName: "version"},
{giveName: "healthcheck"},
{giveName: "serve"},
}
// get all existing subcommands and put into the map
subcommands := make(map[string]*cobra.Command)
for _, sub := range cmd.Commands() {
subcommands[sub.Name()] = sub
}
for _, tt := range cases {
tt := tt
t.Run(tt.giveName, func(t *testing.T) {
if _, exists := subcommands[tt.giveName]; !exists {
assert.Failf(t, "command not found", "command [%s] was not found", tt.giveName)
}
})
}
}
func TestFlags(t *testing.T) {
cmd := cli.NewCommand("unit test")
cases := []struct {
giveName string
wantShorthand string
wantDefault string
}{
{giveName: "verbose", wantShorthand: "v", wantDefault: "false"},
{giveName: "debug", wantShorthand: "", wantDefault: "false"},
{giveName: "log-json", wantShorthand: "", wantDefault: "false"},
{giveName: "config-file", wantShorthand: "c", wantDefault: "./error-pages.yml"},
}
for _, tt := range cases {
tt := tt
t.Run(tt.giveName, func(t *testing.T) {
flag := cmd.Flag(tt.giveName)
if flag == nil {
assert.Failf(t, "flag not found", "flag [%s] was not found", tt.giveName)
return
}
assert.Equal(t, tt.wantShorthand, flag.Shorthand)
assert.Equal(t, tt.wantDefault, flag.DefValue)
})
}
}
func TestExecuting(t *testing.T) {
cmd := cli.NewCommand("unit test")
cmd.SetArgs([]string{})
var executed bool
if cmd.Run == nil { // override "Run" property for test (if it was not set)
cmd.Run = func(cmd *cobra.Command, args []string) {
executed = true
}
}
assert.NoError(t, cmd.Execute())
assert.True(t, executed)
}

View File

@ -0,0 +1,148 @@
package serve
import (
"context"
"errors"
"os"
"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"
)
// NewCommand creates `serve` command.
func NewCommand(ctx context.Context, log *zap.Logger, configFile *string) *cobra.Command {
var (
f flags
cfg *config.Config
)
cmd := &cobra.Command{
Use: "serve",
Aliases: []string{"s", "server"},
Short: "Start HTTP server",
PreRunE: func(cmd *cobra.Command, _ []string) 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 {
return err
}
if c, err := config.FromYamlFile(*configFile); err != nil {
return err
} else {
if err = c.Validate(); err != nil {
return err
}
cfg = c
}
return f.validate()
},
RunE: func(*cobra.Command, []string) error { return run(ctx, log, f, cfg) },
}
f.init(cmd.Flags())
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 (
ctx, cancel = context.WithCancel(parentCtx) // serve context creation
oss = breaker.NewOSSignals(ctx) // OS signals listener
)
// subscribe for system signals
oss.Subscribe(func(sig os.Signal) {
log.Warn("Stopping by OS signal..", zap.String("signal", sig.String()))
cancel()
})
defer func() {
cancel() // call the cancellation function after all
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")
}
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
}
}
// 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}
}
// 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
}
startingErrCh := make(chan error, 1) // channel for server starting error
// start HTTP server in separate goroutine
go func(errCh chan<- error) {
defer close(errCh)
log.Info("Server starting",
zap.String("addr", f.listen.ip),
zap.Uint16("port", f.listen.port),
zap.String("template name", f.template.name),
)
if err := server.Start(f.listen.ip, f.listen.port); err != nil {
errCh <- err
}
}(startingErrCh)
// and wait for...
select {
case err := <-startingErrCh: // ..server starting error
return err
case <-ctx.Done(): // ..or context cancellation
log.Info("Gracefully server stopping")
stoppedAt := time.Now()
// 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))
}
return err
}
log.Debug("Server stopped", zap.Duration("stopping duration", time.Since(stoppedAt)))
}
return nil
}

View File

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

View File

@ -0,0 +1,91 @@
package serve
import (
"fmt"
"net"
"strconv"
"strings"
"github.com/tarampampam/error-pages/internal/http/handlers/errorpage"
"github.com/spf13/pflag"
"github.com/tarampampam/error-pages/internal/env"
)
type flags struct {
listen struct {
ip string
port uint16
}
template struct {
name string
}
}
const (
listenFlagName = "listen"
portFlagName = "port"
templateNameFlagName = "template-name"
)
func (f *flags) init(flagSet *pflag.FlagSet) {
flagSet.StringVarP(
&f.listen.ip,
listenFlagName, "l",
"0.0.0.0",
fmt.Sprintf("IP address to listen on [$%s]", env.ListenAddr),
)
flagSet.Uint16VarP(
&f.listen.port,
portFlagName, "p",
8080, //nolint:gomnd // must be same as default healthcheck `--port` flag value
fmt.Sprintf("TCP port number [$%s]", env.ListenPort),
)
flagSet.StringVarP(
&f.template.name,
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,
),
)
}
func (f *flags) overrideUsingEnv(flagSet *pflag.FlagSet) (lastErr error) {
flagSet.VisitAll(func(flag *pflag.Flag) {
// flag was NOT defined using CLI (flags should have maximal priority)
if !flag.Changed { //nolint:nestif
switch flag.Name {
case listenFlagName:
if envVar, exists := env.ListenAddr.Lookup(); exists {
f.listen.ip = strings.TrimSpace(envVar)
}
case portFlagName:
if envVar, exists := env.ListenPort.Lookup(); exists {
if p, err := strconv.ParseUint(envVar, 10, 16); err == nil { //nolint:gomnd
f.listen.port = uint16(p)
} else {
lastErr = fmt.Errorf("wrong TCP port environment variable [%s] value", envVar)
}
}
case templateNameFlagName:
if envVar, exists := env.TemplateName.Lookup(); exists {
f.template.name = strings.TrimSpace(envVar)
}
}
}
})
return lastErr
}
func (f *flags) validate() error {
if net.ParseIP(f.listen.ip) == nil {
return fmt.Errorf("wrong IP address [%s] for listening", f.listen.ip)
}
return nil
}

View File

@ -0,0 +1,24 @@
// Package version contains CLI `version` command implementation.
package version
import (
"fmt"
"os"
"runtime"
"github.com/spf13/cobra"
)
// NewCommand creates `version` command.
func NewCommand(ver string) *cobra.Command {
return &cobra.Command{
Use: "version",
Aliases: []string{"v", "ver"},
Short: "Display application version",
RunE: func(*cobra.Command, []string) (err error) {
_, err = fmt.Fprintf(os.Stdout, "app version:\t%s (%s)\n", ver, runtime.Version())
return
},
}
}

View File

@ -0,0 +1,30 @@
package version_test
import (
"runtime"
"testing"
"github.com/kami-zh/go-capturer"
"github.com/stretchr/testify/assert"
"github.com/tarampampam/error-pages/internal/cli/version"
)
func TestProperties(t *testing.T) {
cmd := version.NewCommand("")
assert.Equal(t, "version", cmd.Use)
assert.ElementsMatch(t, []string{"v", "ver"}, cmd.Aliases)
assert.NotNil(t, cmd.RunE)
}
func TestCommandRun(t *testing.T) {
cmd := version.NewCommand("1.2.3@foobar")
cmd.SetArgs([]string{})
output := capturer.CaptureStdout(func() {
assert.NoError(t, cmd.Execute())
})
assert.Contains(t, output, "1.2.3@foobar")
assert.Contains(t, output, runtime.Version())
}