error-pages/internal/cli/serve/command.go
2024-06-27 19:29:54 +04:00

339 lines
10 KiB
Go

package serve
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/urfave/cli/v3"
"gh.tarampamp.am/error-pages/internal/cli/shared"
"gh.tarampamp.am/error-pages/internal/config"
appHttp "gh.tarampamp.am/error-pages/internal/http"
"gh.tarampamp.am/error-pages/internal/logger"
)
type command struct {
c *cli.Command
opt struct {
http struct { // our HTTP server
addr string
port uint16
// readBufferSize uint
}
}
}
// NewCommand creates `serve` command.
func NewCommand(log *logger.Logger) *cli.Command { //nolint:funlen,gocognit,gocyclo
var (
cmd command
cfg = config.New()
env, trim = cli.EnvVars, cli.StringConfig{TrimSpace: true}
)
var (
addrFlag = shared.ListenAddrFlag
portFlag = shared.ListenPortFlag
addTplFlag = shared.AddTemplatesFlag
disableTplFlag = shared.DisableTemplateNamesFlag
addCodeFlag = shared.AddHTTPCodesFlag
jsonFormatFlag = cli.StringFlag{
Name: "json-format",
Usage: "override the default error page response in JSON format (Go templates are supported)",
Sources: env("RESPONSE_JSON_FORMAT"),
OnlyOnce: true,
Config: trim,
}
xmlFormatFlag = cli.StringFlag{
Name: "xml-format",
Usage: "override the default error page response in XML format (Go templates are supported)",
Sources: env("RESPONSE_XML_FORMAT"),
OnlyOnce: true,
Config: trim,
}
plainTextFormatFlag = cli.StringFlag{
Name: "plaintext-format",
Usage: "override the default error page response in plain text format (Go templates are supported)",
Sources: env("RESPONSE_PLAINTEXT_FORMAT"),
OnlyOnce: true,
Config: trim,
}
templateNameFlag = cli.StringFlag{
Name: "template-name",
Aliases: []string{"t"},
Value: cfg.TemplateName,
Usage: "name of the template to use for rendering error pages",
Sources: env("TEMPLATE_NAME"),
OnlyOnce: true,
Config: trim,
}
disableL10nFlag = cli.BoolFlag{
Name: "disable-l10n",
Usage: "disable localization of error pages (if the template supports localization)",
Value: cfg.L10n.Disable,
Sources: env("DISABLE_L10N"),
OnlyOnce: true,
}
defaultCodeToRenderFlag = cli.UintFlag{
Name: "default-error-page",
Usage: "the code of the default (index page, when a code is not specified) error page to render",
Value: uint64(cfg.DefaultCodeToRender),
Sources: env("DEFAULT_ERROR_PAGE"),
Validator: func(code uint64) error {
if code > 999 { //nolint:mnd
return fmt.Errorf("wrong HTTP code [%d] for the default error page", code)
}
return nil
},
OnlyOnce: true,
}
sendSameHTTPCodeFlag = cli.BoolFlag{
Name: "send-same-http-code",
Usage: "the HTTP response should have the same status code as the requested error page (by default, " +
"every response with an error page will have a status code of 200)",
Value: cfg.RespondWithSameHTTPCode,
Sources: env("SEND_SAME_HTTP_CODE"),
OnlyOnce: true,
}
showDetailsFlag = cli.BoolFlag{
Name: "show-details",
Usage: "show request details in the error page response (if supported by the template)",
Value: cfg.ShowDetails,
Sources: env("SHOW_DETAILS"),
OnlyOnce: true,
}
proxyHeadersListFlag = cli.StringFlag{
Name: "proxy-headers",
Usage: "listed here HTTP headers will be proxied from the original request to the error page response " +
"(comma-separated list)",
Value: strings.Join(cfg.ProxyHeaders, ","),
Sources: env("PROXY_HTTP_HEADERS"),
Validator: func(s string) error {
for _, raw := range strings.Split(s, ",") {
if clean := strings.TrimSpace(raw); strings.ContainsRune(clean, ' ') {
return fmt.Errorf("whitespaces in the HTTP headers are not allowed: %s", clean)
}
}
return nil
},
OnlyOnce: true,
Config: trim,
}
rotationModeFlag = cli.StringFlag{
Name: "rotation-mode",
Value: config.RotationModeDisabled.String(),
Usage: "templates automatic rotation mode (" + strings.Join(config.RotationModeStrings(), "/") + ")",
Sources: env("TEMPLATES_ROTATION_MODE"),
OnlyOnce: true,
Config: trim,
Validator: func(s string) error {
if _, err := config.ParseRotationMode(s); err != nil {
return err
}
return nil
},
}
)
cmd.c = &cli.Command{
Name: "serve",
Aliases: []string{"s", "server", "http"},
Usage: "Start HTTP server",
Suggest: true,
Action: func(ctx context.Context, c *cli.Command) error {
cmd.opt.http.addr = c.String(addrFlag.Name)
cmd.opt.http.port = uint16(c.Uint(portFlag.Name))
cfg.L10n.Disable = c.Bool(disableL10nFlag.Name)
cfg.DefaultCodeToRender = uint16(c.Uint(defaultCodeToRenderFlag.Name))
cfg.RespondWithSameHTTPCode = c.Bool(sendSameHTTPCodeFlag.Name)
cfg.RotationMode, _ = config.ParseRotationMode(c.String(rotationModeFlag.Name))
cfg.ShowDetails = c.Bool(showDetailsFlag.Name)
{ // override default JSON, XML, and PlainText formats
if c.IsSet(jsonFormatFlag.Name) {
cfg.Formats.JSON = strings.TrimSpace(c.String(jsonFormatFlag.Name))
}
if c.IsSet(xmlFormatFlag.Name) {
cfg.Formats.XML = strings.TrimSpace(c.String(xmlFormatFlag.Name))
}
if c.IsSet(plainTextFormatFlag.Name) {
cfg.Formats.PlainText = strings.TrimSpace(c.String(plainTextFormatFlag.Name))
}
}
// add templates from files to the configuration
if add := c.StringSlice(addTplFlag.Name); len(add) > 0 {
for _, templatePath := range add {
if addedName, err := cfg.Templates.AddFromFile(templatePath); err != nil {
return fmt.Errorf("cannot add template from file %s: %w", templatePath, err)
} else {
log.Info("Template added",
logger.String("name", addedName),
logger.String("path", templatePath),
)
}
}
}
// set the list of HTTP headers we need to proxy from the incoming request to the error page response
if c.IsSet(proxyHeadersListFlag.Name) {
var m = make(map[string]struct{}) // map is used to avoid duplicates
for _, header := range strings.Split(c.String(proxyHeadersListFlag.Name), ",") {
m[http.CanonicalHeaderKey(strings.TrimSpace(header))] = struct{}{}
}
clear(cfg.ProxyHeaders) // clear the list before adding new headers
for header := range m {
cfg.ProxyHeaders = append(cfg.ProxyHeaders, header)
}
}
// add custom HTTP codes to the configuration
if add := c.StringMap(addCodeFlag.Name); len(add) > 0 {
for code, msgAndDesc := range add {
var (
parts = strings.SplitN(msgAndDesc, "/", 2) //nolint:mnd
desc config.CodeDescription
)
if len(parts) > 0 {
desc.Message = strings.TrimSpace(parts[0])
}
if len(parts) > 1 {
desc.Description = strings.TrimSpace(parts[1])
}
cfg.Codes[code] = desc
log.Info("HTTP code added",
logger.String("code", code),
logger.String("message", desc.Message),
logger.String("description", desc.Description),
)
}
}
// disable templates specified by the user
if disable := c.StringSlice(disableTplFlag.Name); len(disable) > 0 {
for _, templateName := range disable {
if ok := cfg.Templates.Remove(templateName); ok {
log.Info("Template disabled", logger.String("name", templateName))
}
}
}
// if the rotation mode is set to random-on-startup, pick a random template (ignore the user-provided
// template name)
if cfg.RotationMode == config.RotationModeRandomOnStartup {
cfg.TemplateName = cfg.Templates.RandomName()
} else { // otherwise, use the user-provided template name
cfg.TemplateName = c.String(templateNameFlag.Name)
if !cfg.Templates.Has(cfg.TemplateName) {
return fmt.Errorf(
"template '%s' not found and cannot be used (available templates: %s)",
cfg.TemplateName,
cfg.Templates.Names(),
)
}
}
log.Debug("Configuration",
logger.Strings("loaded templates", cfg.Templates.Names()...),
logger.Strings("described HTTP codes", cfg.Codes.Codes()...),
logger.String("JSON format", cfg.Formats.JSON),
logger.String("XML format", cfg.Formats.XML),
logger.String("plain text format", cfg.Formats.PlainText),
logger.String("template name", cfg.TemplateName),
logger.Bool("disable localization", cfg.L10n.Disable),
logger.Uint16("default code to render", cfg.DefaultCodeToRender),
logger.Bool("respond with the same HTTP code", cfg.RespondWithSameHTTPCode),
logger.String("rotation mode", cfg.RotationMode.String()),
logger.Bool("show details", cfg.ShowDetails),
logger.Strings("proxy HTTP headers", cfg.ProxyHeaders...),
)
return cmd.Run(ctx, log, &cfg)
},
Flags: []cli.Flag{
&addrFlag,
&portFlag,
&addTplFlag,
&disableTplFlag,
&addCodeFlag,
&jsonFormatFlag,
&xmlFormatFlag,
&plainTextFormatFlag,
&templateNameFlag,
&disableL10nFlag,
&defaultCodeToRenderFlag,
&sendSameHTTPCodeFlag,
&showDetailsFlag,
&proxyHeadersListFlag,
&rotationModeFlag,
},
}
return cmd.c
}
// Run current command.
func (cmd *command) Run(ctx context.Context, log *logger.Logger, cfg *config.Config) error {
var srv = appHttp.NewServer(ctx, log)
if err := srv.Register(cfg); err != nil {
return err
}
var startingErrCh = make(chan error, 1) // channel for server starting error
defer close(startingErrCh)
// start HTTP server in separate goroutine
go func(errCh chan<- error) {
var now = time.Now()
defer func() {
log.Info("HTTP server stopped", logger.Duration("uptime", time.Since(now).Round(time.Millisecond)))
}()
log.Info("HTTP server starting",
logger.String("addr", cmd.opt.http.addr),
logger.Uint16("port", cmd.opt.http.port),
)
if err := srv.Start(cmd.opt.http.addr, cmd.opt.http.port); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- err
}
}(startingErrCh)
// and wait for...
select {
case err := <-startingErrCh: // ..server starting error
return err
case <-ctx.Done(): // ..or context cancellation
const shutdownTimeout = 5 * time.Second
log.Info("HTTP server stopping", logger.Duration("with timeout", shutdownTimeout))
if err := srv.Stop(shutdownTimeout); err != nil { //nolint:contextcheck
return err
}
}
return nil
}