From 252618a975f219aa35cefaf754d8c88933824d39 Mon Sep 17 00:00:00 2001 From: Paramtamtam <7326800+tarampampam@users.noreply.github.com> Date: Sun, 29 Jan 2023 14:54:56 +0400 Subject: [PATCH] chore: Better CLI (#163) --- .github/workflows/tests.yml | 4 +- CHANGELOG.md | 10 + Dockerfile | 8 +- cmd/error-pages/main.go | 20 +- cmd/error-pages/main_test.go | 32 +- go.mod | 9 +- go.sum | 26 +- internal/cli/app.go | 103 ++++++ internal/cli/app_test.go | 19 + internal/cli/build/command.go | 85 +++-- internal/cli/build/command_test.go | 25 +- internal/cli/healthcheck/command.go | 56 +-- internal/cli/healthcheck/command_test.go | 71 +--- internal/cli/root.go | 93 ----- internal/cli/root_test.go | 84 ----- internal/cli/serve/command.go | 173 +++++++-- internal/cli/serve/flags.go | 452 +++++++++++------------ internal/cli/shared/flags.go | 31 ++ internal/cli/version/command.go | 24 -- internal/cli/version/command_test.go | 30 -- internal/env/env.go | 3 + internal/logger/format.go | 68 ++++ internal/logger/format_test.go | 62 ++++ internal/logger/level.go | 83 +++++ internal/logger/level_test.go | 84 +++++ internal/logger/logger.go | 39 +- internal/logger/logger_test.go | 75 ++-- 27 files changed, 1037 insertions(+), 732 deletions(-) create mode 100644 internal/cli/app.go create mode 100644 internal/cli/app_test.go delete mode 100644 internal/cli/root.go delete mode 100644 internal/cli/root_test.go create mode 100644 internal/cli/shared/flags.go delete mode 100644 internal/cli/version/command.go delete mode 100644 internal/cli/version/command_test.go create mode 100644 internal/logger/format.go create mode 100644 internal/logger/format_test.go create mode 100644 internal/logger/level.go create mode 100644 internal/logger/level_test.go diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f345804..e41cd5a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -129,7 +129,7 @@ jobs: # Docs: - name: Try to execute if: matrix.os == 'linux' - run: ./error-pages version && ./error-pages -h + run: ./error-pages --version && ./error-pages -h - uses: actions/upload-artifact@v3 with: @@ -155,7 +155,7 @@ jobs: # Docs: run: mv ./error-pages ./../error-pages && chmod +x ./../error-pages - name: Run generator - run: ./error-pages build ./out --verbose --index + run: ./error-pages --verbose build --index ./out - name: Test files creation run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d02a91..20b0e89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ 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 + +- `version` subcommand replaced by `--version` flag [#163] +- `--config-file` flag is not global anymore (use `error-pages (serve|build) --config-file ...` instead of `error-pages --config-file ... (serve|build) ...`) [#163] +- Flags `--verbose`, `--debug` and `--log-json` are deprecated, use `--log-level` and `--log-format` instead [#163] + +[#163]:https://github.com/tarampampam/error-pages/pull/163 + ## v2.19.0 ### Changed diff --git a/Dockerfile b/Dockerfile index 6eb4d6b..087e3fd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,7 +16,7 @@ ENV LDFLAGS="-s -w -X github.com/tarampampam/error-pages/internal/version.versio RUN set -x \ && go version \ && CGO_ENABLED=0 go build -trimpath -ldflags "$LDFLAGS" -o ./error-pages ./cmd/error-pages/ \ - && ./error-pages version \ + && ./error-pages --version \ && ./error-pages -h WORKDIR /tmp/rootfs @@ -38,7 +38,7 @@ WORKDIR /tmp/rootfs/opt # generate static error pages (for usage inside another docker images, for example) RUN set -x \ - && ./../bin/error-pages --config-file ./error-pages.yml build ./html --verbose --index \ + && ./../bin/error-pages --verbose build --config-file ./error-pages.yml --index ./html \ && ls -l ./html # use empty filesystem @@ -72,8 +72,8 @@ ENV LISTEN_PORT="8080" \ DISABLE_L10N="false" # Docs: -HEALTHCHECK --interval=7s --timeout=2s CMD ["/bin/error-pages", "healthcheck", "--log-json"] +HEALTHCHECK --interval=7s --timeout=2s CMD ["/bin/error-pages", "--log-json", "healthcheck"] ENTRYPOINT ["/bin/error-pages"] -CMD ["serve", "--log-json"] +CMD ["--log-json", "serve"] diff --git a/cmd/error-pages/main.go b/cmd/error-pages/main.go index 9a48bc0..0daddc3 100644 --- a/cmd/error-pages/main.go +++ b/cmd/error-pages/main.go @@ -1,13 +1,22 @@ package main import ( + crypto "crypto/rand" + "encoding/binary" + "math/rand" "os" "path/filepath" + "time" "github.com/fatih/color" + "go.uber.org/automaxprocs/maxprocs" + "github.com/tarampampam/error-pages/internal/cli" ) +// set GOMAXPROCS to match Linux container CPU quota. +var _, _ = maxprocs.Set(maxprocs.Min(1), maxprocs.Logger(func(_ string, _ ...any) {})) + // exitFn is a function for application exiting. var exitFn = os.Exit //nolint:gochecknoglobals @@ -17,9 +26,16 @@ func main() { exitFn(run()) } // run this CLI application. // Exit codes documentation: func run() int { - cmd := cli.NewCommand(filepath.Base(os.Args[0])) + var b [8]byte - if err := cmd.Execute(); err != nil { + // seed random number generator + if _, err := crypto.Read(b[:]); err == nil { + rand.Seed(int64(binary.LittleEndian.Uint64(b[:]))) // https://stackoverflow.com/a/54491783/2252921 + } else { + rand.Seed(time.Now().UnixNano()) // fallback + } + + if err := (cli.NewApp(filepath.Base(os.Args[0]))).Run(os.Args); err != nil { _, _ = color.New(color.FgHiRed, color.Bold).Fprintln(os.Stderr, err.Error()) return 1 diff --git a/cmd/error-pages/main_test.go b/cmd/error-pages/main_test.go index 168a7c1..3b7532a 100644 --- a/cmd/error-pages/main_test.go +++ b/cmd/error-pages/main_test.go @@ -6,36 +6,16 @@ import ( "github.com/kami-zh/go-capturer" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) -func Test_Main(t *testing.T) { +func Test_MainHelp(t *testing.T) { os.Args = []string{"", "--help"} - exitFn = func(code int) { assert.Equal(t, 0, code) } + exitFn = func(code int) { require.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") + assert.Contains(t, output, "USAGE:") + assert.Contains(t, output, "COMMANDS:") + assert.Contains(t, output, "GLOBAL OPTIONS:") } diff --git a/go.mod b/go.mod index 18d6f9d..f0731f0 100644 --- a/go.mod +++ b/go.mod @@ -10,10 +10,11 @@ require ( github.com/pkg/errors v0.9.1 github.com/prometheus/client_golang v1.14.0 github.com/prometheus/client_model v0.3.0 - github.com/spf13/cobra v1.6.1 - github.com/spf13/pflag v1.0.5 github.com/stretchr/testify v1.8.1 + github.com/urfave/cli/v2 v2.24.1 github.com/valyala/fasthttp v1.43.0 + go.uber.org/automaxprocs v1.5.1 + go.uber.org/goleak v1.1.11 go.uber.org/zap v1.24.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -22,9 +23,9 @@ require ( github.com/andybalholm/brotli v1.0.4 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.1.2 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/golang/protobuf v1.5.2 // indirect - github.com/inconshreveable/mousetrap v1.0.1 // indirect github.com/klauspost/compress v1.15.9 // indirect github.com/mattn/go-colorable v0.1.12 // indirect github.com/mattn/go-isatty v0.0.14 // indirect @@ -32,8 +33,10 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/common v0.37.0 // indirect github.com/prometheus/procfs v0.8.0 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/savsgio/gotils v0.0.0-20220530130905-52f3993e8d6d // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect go.uber.org/atomic v1.7.0 // indirect go.uber.org/multierr v1.6.0 // indirect golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10 // indirect diff --git a/go.sum b/go.sum index 93b3b33..c2bf550 100644 --- a/go.sum +++ b/go.sum @@ -56,6 +56,7 @@ github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5P github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -138,8 +139,6 @@ github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5m github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= -github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc= -github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= @@ -183,6 +182,7 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= @@ -210,16 +210,13 @@ github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1 github.com/prometheus/procfs v0.8.0 h1:ODq8ZFEaYeCaZOJlZZdJA2AbQR98dSHSM1KW/You5mo= github.com/prometheus/procfs v0.8.0/go.mod h1:z7EfXMXOkbkqb9IINtpCn86r/to3BnA0uaxHdg830/4= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/savsgio/gotils v0.0.0-20220530130905-52f3993e8d6d h1:Q+gqLBOPkFGHyCJxXMRqtUgUbTjI8/Ze8vu8GGyNFwo= github.com/savsgio/gotils v0.0.0-20220530130905-52f3993e8d6d/go.mod h1:Gy+0tqhJvgGlqnTF8CVGP0AaGRjwBtXs/a5PA0Y3+A4= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= -github.com/spf13/cobra v1.6.1 h1:o94oiPyS4KD1mPy2fmcYYHHfCxLqYjJOhGsCHFZtEzA= -github.com/spf13/cobra v1.6.1/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUqzrY= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= @@ -227,19 +224,25 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/urfave/cli/v2 v2.24.1 h1:/QYYr7g0EhwXEML8jO+8OYt5trPnLHS0p3mrgExJ5NU= +github.com/urfave/cli/v2 v2.24.1/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6fmdJLxc= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasthttp v1.42.0/go.mod h1:f6VbjjoI3z1NDOZOv17o6RvtRSWxC77seBFc2uWtgiY= github.com/valyala/fasthttp v1.43.0 h1:Gy4sb32C98fbzVWZlTM1oTMdLWGyvxR03VhM6cBIU4g= github.com/valyala/fasthttp v1.43.0/go.mod h1:f6VbjjoI3z1NDOZOv17o6RvtRSWxC77seBFc2uWtgiY= github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= +github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= @@ -247,7 +250,10 @@ go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/automaxprocs v1.5.1 h1:e1YG66Lrk73dn4qhg8WFSvhF0JuFQF0ERIp4rpuV8Qk= +go.uber.org/automaxprocs v1.5.1/go.mod h1:BF4eumQw0P9GtnuxxovUd06vwm1o18oMzFtK66vU6XU= go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI= +go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4= go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60= @@ -280,6 +286,7 @@ golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHl golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20200302205851-738671d3881b h1:Wh+f8QHJXR411sJR8/vRBTZ7YapZaRvUcLFFJhusH0k= golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= @@ -289,6 +296,7 @@ golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzB golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -317,6 +325,7 @@ golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/ golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= @@ -338,6 +347,7 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -372,7 +382,9 @@ golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -433,6 +445,8 @@ golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roY golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.1.5 h1:ouewzE6p+/VEB31YYnTbEJdi8pFqKp4P4n85vwo3DHA= +golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/cli/app.go b/internal/cli/app.go new file mode 100644 index 0000000..6649119 --- /dev/null +++ b/internal/cli/app.go @@ -0,0 +1,103 @@ +package cli + +import ( + "context" + "fmt" + "runtime" + "strings" + + "github.com/urfave/cli/v2" + + "github.com/tarampampam/error-pages/internal/checkers" + "github.com/tarampampam/error-pages/internal/cli/build" + "github.com/tarampampam/error-pages/internal/cli/healthcheck" + "github.com/tarampampam/error-pages/internal/cli/serve" + "github.com/tarampampam/error-pages/internal/env" + "github.com/tarampampam/error-pages/internal/logger" + "github.com/tarampampam/error-pages/internal/version" +) + +// NewApp creates new console application. +func NewApp(appName string) *cli.App { //nolint:funlen + const ( + logLevelFlagName = "log-level" + logFormatFlagName = "log-format" + verboseFlagName = "verbose" + debugFlagName = "debug" + logJSONFlagName = "log-json" + + defaultLogLevel = logger.InfoLevel + defaultLogFormat = logger.ConsoleFormat + ) + + // create "default" logger (will be overwritten later with customized) + var log, _ = logger.New(defaultLogLevel, defaultLogFormat) // error will never occurs + + return &cli.App{ + Usage: appName, + Before: func(c *cli.Context) (err error) { + _ = log.Sync() // sync previous logger instance + + var logLevel, logFormat = defaultLogLevel, defaultLogFormat //nolint:ineffassign + + if c.Bool(verboseFlagName) || c.Bool(debugFlagName) { + logLevel = logger.DebugLevel + } else { + // parse logging level + if logLevel, err = logger.ParseLevel(c.String(logLevelFlagName)); err != nil { + return err + } + } + + if c.Bool(logJSONFlagName) { + logFormat = logger.JSONFormat + } else { + // parse logging format + if logFormat, err = logger.ParseFormat(c.String(logFormatFlagName)); err != nil { + return err + } + } + + configured, err := logger.New(logLevel, logFormat) // create new logger instance + if err != nil { + return err + } + + *log = *configured // replace "default" logger with customized + + return nil + }, + Commands: []*cli.Command{ + healthcheck.NewCommand(checkers.NewHealthChecker(context.TODO())), + build.NewCommand(log), + serve.NewCommand(log), + }, + Version: fmt.Sprintf("%s (%s)", version.Version(), runtime.Version()), + Flags: []cli.Flag{ // global flags + &cli.BoolFlag{ // kept for backward compatibility + Name: verboseFlagName, + Usage: "verbose output (DEPRECATED FLAG)", + }, + &cli.BoolFlag{ // kept for backward compatibility + Name: debugFlagName, + Usage: "debug output (DEPRECATED FLAG)", + }, + &cli.BoolFlag{ // kept for backward compatibility + Name: logJSONFlagName, + Usage: "logs in JSON format (DEPRECATED FLAG)", + }, + &cli.StringFlag{ + Name: logLevelFlagName, + Value: defaultLogLevel.String(), + Usage: "logging level (`" + strings.Join(logger.LevelStrings(), "/") + "`)", + EnvVars: []string{env.LogLevel.String()}, + }, + &cli.StringFlag{ + Name: logFormatFlagName, + Value: defaultLogFormat.String(), + Usage: "logging format (`" + strings.Join(logger.FormatStrings(), "/") + "`)", + EnvVars: []string{env.LogFormat.String()}, + }, + }, + } +} diff --git a/internal/cli/app_test.go b/internal/cli/app_test.go new file mode 100644 index 0000000..d1c3031 --- /dev/null +++ b/internal/cli/app_test.go @@ -0,0 +1,19 @@ +package cli_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/tarampampam/error-pages/internal/cli" +) + +func TestNewCommand(t *testing.T) { + t.Parallel() + + app := cli.NewApp("app") + + assert.NotEmpty(t, app.Flags) + + assert.NoError(t, app.Run([]string{"", "--log-level", "debug", "--log-format", "json"})) +} diff --git a/internal/cli/build/command.go b/internal/cli/build/command.go index 4413cbc..1cd5303 100644 --- a/internal/cli/build/command.go +++ b/internal/cli/build/command.go @@ -5,60 +5,59 @@ import ( "path" "github.com/pkg/errors" - "github.com/spf13/cobra" + "github.com/urfave/cli/v2" + "go.uber.org/zap" + + "github.com/tarampampam/error-pages/internal/cli/shared" "github.com/tarampampam/error-pages/internal/config" "github.com/tarampampam/error-pages/internal/tpl" - "go.uber.org/zap" ) +type command struct { + c *cli.Command +} + // NewCommand creates `build` command. -func NewCommand(log *zap.Logger, configFile *string) *cobra.Command { - var ( - generateIndex bool - disableL10n bool - cfg *config.Config +func NewCommand(log *zap.Logger) *cli.Command { + var cmd = command{} + + const ( + generateIndexFlagName = "index" + disableL10nFlagName = "disable-l10n" ) - cmd := &cobra.Command{ - Use: "build ", - Aliases: []string{"b"}, - Short: "Build the error pages", - Args: cobra.ExactArgs(1), - PreRunE: func(*cobra.Command, []string) (err error) { - if configFile == nil { - return errors.New("path to the config file is required for this command") + cmd.c = &cli.Command{ + Name: "build", + Aliases: []string{"b"}, + Usage: "build ", + Description: "Build the error pages", + Action: func(c *cli.Context) error { + cfg, cfgErr := config.FromYamlFile(c.String(shared.ConfigFileFlag.Name)) + if cfgErr != nil { + return cfgErr } - if cfg, err = config.FromYamlFile(*configFile); err != nil { - return err - } - - return - }, - RunE: func(_ *cobra.Command, args []string) error { - if len(args) != 1 { + if c.Args().Len() != 1 { return errors.New("wrong arguments count") } - return run(log, cfg, args[0], generateIndex, disableL10n) + return cmd.Run(log, cfg, c.Args().First(), c.Bool(generateIndexFlagName), c.Bool(disableL10nFlagName)) + }, + Flags: []cli.Flag{ // global flags + &cli.BoolFlag{ + Name: generateIndexFlagName, + Aliases: []string{"i"}, + Usage: "generate index page", + }, + &cli.BoolFlag{ + Name: disableL10nFlagName, + Usage: "disable error pages localization", + }, + shared.ConfigFileFlag, }, } - cmd.Flags().BoolVarP( - &generateIndex, - "index", "i", - false, - "generate index page", - ) - - cmd.Flags().BoolVarP( - &disableL10n, - "disable-l10n", "", - false, - "disable error pages localization", - ) - - return cmd + return cmd.c } const ( @@ -68,14 +67,14 @@ const ( outDirPerm = os.FileMode(0775) ) -func run(log *zap.Logger, cfg *config.Config, outDirectoryPath string, generateIndex, disableL10n bool) error { //nolint:funlen,lll +func (cmd *command) Run(log *zap.Logger, cfg *config.Config, outDirectoryPath string, generateIndex, disableL10n bool) error { //nolint:funlen,lll 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 { + if err := cmd.createDirectory(outDirectoryPath, outDirPerm); err != nil { return errors.Wrap(err, "cannot prepare output directory") } @@ -86,7 +85,7 @@ func run(log *zap.Logger, cfg *config.Config, outDirectoryPath string, generateI 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 { + if err := cmd.createDirectory(path.Join(outDirectoryPath, template.Name()), outDirPerm); err != nil { return err } @@ -138,7 +137,7 @@ func run(log *zap.Logger, cfg *config.Config, outDirectoryPath string, generateI return nil } -func createDirectory(path string, perm os.FileMode) error { +func (cmd *command) createDirectory(path string, perm os.FileMode) error { stat, err := os.Stat(path) if err != nil { if os.IsNotExist(err) { diff --git a/internal/cli/build/command_test.go b/internal/cli/build/command_test.go index df9157a..310f969 100644 --- a/internal/cli/build/command_test.go +++ b/internal/cli/build/command_test.go @@ -1,7 +1,26 @@ package build_test -import "testing" +import ( + "flag" + "testing" -func TestNothing(t *testing.T) { - t.Skip("tests for this package have not been implemented yet") + "github.com/stretchr/testify/assert" + "github.com/urfave/cli/v2" + "go.uber.org/goleak" + "go.uber.org/zap" + + "github.com/tarampampam/error-pages/internal/cli/build" +) + +func TestNewCommand(t *testing.T) { + defer goleak.VerifyNone(t) + + cmd := build.NewCommand(zap.NewNop()) + + assert.NotEmpty(t, cmd.Flags) + + assert.Error(t, cmd.Run( + cli.NewContext(cli.NewApp(), &flag.FlagSet{}, nil), + "", + ), "should fail because of missing external services") } diff --git a/internal/cli/healthcheck/command.go b/internal/cli/healthcheck/command.go index d3ceb36..305c4fe 100644 --- a/internal/cli/healthcheck/command.go +++ b/internal/cli/healthcheck/command.go @@ -2,57 +2,35 @@ package healthcheck import ( - "fmt" - "strconv" + "errors" + "math" - "github.com/spf13/cobra" - "github.com/spf13/pflag" + "github.com/urfave/cli/v2" - "github.com/tarampampam/error-pages/internal/env" + "github.com/tarampampam/error-pages/internal/cli/shared" ) 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", +func NewCommand(checker checker) *cli.Command { + return &cli.Command{ + Name: "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 { - port = uint16(p) - } else { - lastErr = fmt.Errorf("wrong TCP port environment variable [%s] value", envPort) - } - } - } - }) + Usage: "Health checker for the HTTP server. Use case - docker healthcheck", + Action: func(c *cli.Context) error { + var port = c.Uint(shared.ListenPortFlag.Name) - return lastErr + if port <= 0 || port > math.MaxUint16 { + return errors.New("port value out of range") + } + + return checker.Check(uint16(port)) }, - RunE: func(*cobra.Command, []string) error { - return checker.Check(port) + Flags: []cli.Flag{ + shared.ListenPortFlag, }, } - - 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 } diff --git a/internal/cli/healthcheck/command_test.go b/internal/cli/healthcheck/command_test.go index 233aa90..155b1fb 100644 --- a/internal/cli/healthcheck/command_test.go +++ b/internal/cli/healthcheck/command_test.go @@ -2,12 +2,12 @@ package healthcheck_test import ( "errors" - "os" + "flag" "testing" - "github.com/kami-zh/go-capturer" - "github.com/spf13/cobra" "github.com/stretchr/testify/assert" + "github.com/urfave/cli/v2" + "github.com/tarampampam/error-pages/internal/cli/healthcheck" ) @@ -18,77 +18,30 @@ 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.Equal(t, "healthcheck", cmd.Name) assert.ElementsMatch(t, []string{"chk", "health", "check"}, cmd.Aliases) - assert.NotNil(t, cmd.RunE) + assert.NotNil(t, cmd.Action) } 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) + assert.NoError(t, cmd.Run(cli.NewContext(cli.NewApp(), &flag.FlagSet{}, nil))) } 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") + assert.ErrorContains(t, cmd.Run(cli.NewContext(cli.NewApp(), &flag.FlagSet{}, nil)), "foo err") } func TestPortFlagWrongArgument(t *testing.T) { cmd := healthcheck.NewCommand(&fakeChecker{err: nil}) - cmd.SetArgs([]string{"-p", "65536"}) // 65535 is max - var executed bool + err := cmd.Run( + cli.NewContext(cli.NewApp(), &flag.FlagSet{}, nil), + "", "-p", "65536", + ) - 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) + assert.ErrorContains(t, err, "port value out of range") } diff --git a/internal/cli/root.go b/internal/cli/root.go deleted file mode 100644 index c18b572..0000000 --- a/internal/cli/root.go +++ /dev/null @@ -1,93 +0,0 @@ -// 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: - // - - // - - _ = 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 -} diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go deleted file mode 100644 index e023fc6..0000000 --- a/internal/cli/root_test.go +++ /dev/null @@ -1,84 +0,0 @@ -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) -} diff --git a/internal/cli/serve/command.go b/internal/cli/serve/command.go index 686bb31..73f0391 100644 --- a/internal/cli/serve/command.go +++ b/internal/cli/serve/command.go @@ -3,53 +3,164 @@ package serve import ( "context" "errors" + "fmt" + "net" "os" + "strings" "time" - "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/urfave/cli/v2" "go.uber.org/zap" + + "github.com/tarampampam/error-pages/internal/breaker" + "github.com/tarampampam/error-pages/internal/cli/shared" + "github.com/tarampampam/error-pages/internal/config" + "github.com/tarampampam/error-pages/internal/env" + appHttp "github.com/tarampampam/error-pages/internal/http" + "github.com/tarampampam/error-pages/internal/options" + "github.com/tarampampam/error-pages/internal/pick" +) + +type command struct { + c *cli.Command +} + +const ( + templateNameFlagName = "template-name" + defaultErrorPageFlagName = "default-error-page" + defaultHTTPCodeFlagName = "default-http-code" + showDetailsFlagName = "show-details" + proxyHTTPHeadersFlagName = "proxy-headers" + disableL10nFlagName = "disable-l10n" +) + +const ( + useRandomTemplate = "random" + useRandomTemplateOnEachRequest = "i-said-random" + useRandomTemplateDaily = "random-daily" + useRandomTemplateHourly = "random-hourly" ) // NewCommand creates `serve` command. -func NewCommand(ctx context.Context, log *zap.Logger, configFile *string) *cobra.Command { - var ( - f flags - cfg *config.Config - ) +func NewCommand(log *zap.Logger) *cli.Command { //nolint:funlen + var cmd = command{} - cmd := &cobra.Command{ - Use: "serve", + cmd.c = &cli.Command{ + Name: "serve", Aliases: []string{"s", "server"}, - Short: "Start HTTP server", - PreRunE: func(cmd *cobra.Command, _ []string) (err error) { - if configFile == nil { + Usage: "Start HTTP server", + Action: func(c *cli.Context) error { + var cfg *config.Config + + if configPath := c.String(shared.ConfigFileFlag.Name); configPath == "" { // load config from file return errors.New("path to the config file is required for this command") - } - - if err = f.OverrideUsingEnv(cmd.Flags()); err != nil { + } else if loadedCfg, err := config.FromYamlFile(c.String(shared.ConfigFileFlag.Name)); err != nil { return err + } else { + cfg = loadedCfg } - if cfg, err = config.FromYamlFile(*configFile); err != nil { - return err + var ( + ip = c.String(shared.ListenAddrFlag.Name) + port = uint16(c.Uint(shared.ListenPortFlag.Name)) + o options.ErrorPage + ) + + if net.ParseIP(ip) == nil { + return fmt.Errorf("wrong IP address [%s] for listening", ip) } - return f.Validate() + { // fill options + o.Template.Name = c.String(templateNameFlagName) + o.L10n.Disabled = c.Bool(disableL10nFlagName) + o.Default.PageCode = c.String(defaultErrorPageFlagName) + o.Default.HTTPCode = uint16(c.Uint(defaultHTTPCodeFlagName)) + o.ShowDetails = c.Bool(showDetailsFlagName) + + if headers := c.String(proxyHTTPHeadersFlagName); headers != "" { //nolint:nestif + var m = make(map[string]struct{}) + + // make unique and ignore empty strings + for _, header := range strings.Split(headers, ",") { + if h := strings.TrimSpace(header); h != "" { + if strings.ContainsRune(h, ' ') { + return fmt.Errorf("whitespaces in the HTTP headers for proxying [%s] are not allowed", header) + } + + if _, ok := m[h]; !ok { + m[h] = struct{}{} + } + } + } + + // convert map into slice + o.ProxyHTTPHeaders = make([]string, 0, len(m)) + for h := range m { + o.ProxyHTTPHeaders = append(o.ProxyHTTPHeaders, h) + } + } + } + + if o.Default.HTTPCode > 599 { //nolint:gomnd + return fmt.Errorf("wrong default HTTP response code [%d]", o.Default.HTTPCode) + } + + return cmd.Run(c.Context, log, cfg, ip, port, o) + }, + Flags: []cli.Flag{ + shared.ConfigFileFlag, + shared.ListenPortFlag, + shared.ListenAddrFlag, + &cli.StringFlag{ + Name: templateNameFlagName, + Aliases: []string{"t"}, + Usage: fmt.Sprintf( + "template name (set \"%s\" to use a randomized or \"%s\" to use a randomized template on "+ + "each request or \"%s/%s\" daily/hourly randomized)", + useRandomTemplate, + useRandomTemplateOnEachRequest, + useRandomTemplateDaily, + useRandomTemplateHourly, + ), + EnvVars: []string{env.TemplateName.String()}, + }, + &cli.StringFlag{ + Name: defaultErrorPageFlagName, + Value: "404", + Usage: "default error page", + EnvVars: []string{env.DefaultErrorPage.String()}, + }, + &cli.UintFlag{ + Name: defaultHTTPCodeFlagName, + Value: 404, //nolint:gomnd + Usage: "default HTTP response code", + EnvVars: []string{env.DefaultHTTPCode.String()}, + }, + &cli.BoolFlag{ + Name: showDetailsFlagName, + Usage: "show request details in response", + EnvVars: []string{env.ShowDetails.String()}, + }, + &cli.StringFlag{ + Name: proxyHTTPHeadersFlagName, + Usage: "proxy HTTP request headers list (comma-separated)", + EnvVars: []string{env.ProxyHTTPHeaders.String()}, + }, + &cli.BoolFlag{ + Name: disableL10nFlagName, + Usage: "disable error pages localization", + EnvVars: []string{env.DisableL10n.String()}, + }, }, - RunE: func(*cobra.Command, []string) error { return run(ctx, log, cfg, f) }, } - f.Init(cmd.Flags()) - - return cmd + return cmd.c } -// run current command. -func run(parentCtx context.Context, log *zap.Logger, cfg *config.Config, f flags) error { //nolint:funlen +// Run current command. +func (cmd *command) Run( //nolint:funlen + parentCtx context.Context, log *zap.Logger, cfg *config.Config, ip string, port uint16, opt options.ErrorPage, +) error { var ( ctx, cancel = context.WithCancel(parentCtx) // serve context creation oss = breaker.NewOSSignals(ctx) // OS signals listener @@ -70,8 +181,6 @@ func run(parentCtx context.Context, log *zap.Logger, cfg *config.Config, f flags var ( templateNames = cfg.TemplateNames() picker interface{ Pick() string } - - opt = f.ToOptions() ) switch opt.Template.Name { @@ -124,8 +233,8 @@ func run(parentCtx context.Context, log *zap.Logger, cfg *config.Config, f flags defer close(errCh) log.Info("Server starting", - zap.String("addr", f.Listen.IP), - zap.Uint16("port", f.Listen.Port), + zap.String("addr", ip), + zap.Uint16("port", port), zap.String("default error page", opt.Default.PageCode), zap.Uint16("default HTTP response code", opt.Default.HTTPCode), zap.Strings("proxy headers", opt.ProxyHTTPHeaders), @@ -133,7 +242,7 @@ func run(parentCtx context.Context, log *zap.Logger, cfg *config.Config, f flags zap.Bool("localization disabled", opt.L10n.Disabled), ) - if err := server.Start(f.Listen.IP, f.Listen.Port); err != nil { + if err := server.Start(ip, port); err != nil { errCh <- err } }(startingErrCh) diff --git a/internal/cli/serve/flags.go b/internal/cli/serve/flags.go index 37c5292..d706261 100644 --- a/internal/cli/serve/flags.go +++ b/internal/cli/serve/flags.go @@ -1,236 +1,220 @@ package serve -import ( - "fmt" - "net" - "sort" - "strconv" - "strings" - - "github.com/spf13/pflag" - - "github.com/tarampampam/error-pages/internal/env" - "github.com/tarampampam/error-pages/internal/options" -) - -type flags struct { - Listen struct { - IP string - Port uint16 - } - template struct { - name string - } - l10n struct { - disabled bool - } - defaultErrorPage string - defaultHTTPCode uint16 - showDetails bool - proxyHTTPHeaders string // comma-separated -} - -const ( - listenFlagName = "listen" - portFlagName = "port" - templateNameFlagName = "template-name" - defaultErrorPageFlagName = "default-error-page" - defaultHTTPCodeFlagName = "default-http-code" - showDetailsFlagName = "show-details" - proxyHTTPHeadersFlagName = "proxy-headers" - disableL10nFlagName = "disable-l10n" -) - -const ( - useRandomTemplate = "random" - useRandomTemplateOnEachRequest = "i-said-random" - useRandomTemplateDaily = "random-daily" - useRandomTemplateHourly = "random-hourly" -) - -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 prt number [$%s]", env.ListenPort), - ) - flagSet.StringVarP( - &f.template.name, - templateNameFlagName, "t", - "", - fmt.Sprintf( - "template name (set \"%s\" to use a randomized or \"%s\" to use a randomized template on each request "+ - "or \"%s/%s\" daily/hourly randomized) [$%s]", - useRandomTemplate, - useRandomTemplateOnEachRequest, - useRandomTemplateDaily, - useRandomTemplateHourly, - env.TemplateName, - ), - ) - flagSet.StringVarP( - &f.defaultErrorPage, - defaultErrorPageFlagName, "", - "404", - fmt.Sprintf("default error page [$%s]", env.DefaultErrorPage), - ) - flagSet.Uint16VarP( - &f.defaultHTTPCode, - defaultHTTPCodeFlagName, "", - 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), - ) - flagSet.StringVarP( - &f.proxyHTTPHeaders, - proxyHTTPHeadersFlagName, "", - "", - fmt.Sprintf("proxy HTTP request headers list (comma-separated) [$%s]", env.ProxyHTTPHeaders), - ) - flagSet.BoolVarP( - &f.l10n.disabled, - disableL10nFlagName, "", - false, - fmt.Sprintf("disable error pages localization [$%s]", env.DisableL10n), - ) -} - -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 - 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 { - 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) - } - - case defaultErrorPageFlagName: - if envVar, exists := env.DefaultErrorPage.Lookup(); exists { - f.defaultErrorPage = strings.TrimSpace(envVar) - } - - case defaultHTTPCodeFlagName: - if envVar, exists := env.DefaultHTTPCode.Lookup(); exists { - if code, err := strconv.ParseUint(envVar, 10, 16); err == nil { - f.defaultHTTPCode = uint16(code) - } else { - 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 - } - } - - case proxyHTTPHeadersFlagName: - if envVar, exists := env.ProxyHTTPHeaders.Lookup(); exists { - f.proxyHTTPHeaders = strings.TrimSpace(envVar) - } - - case disableL10nFlagName: - if envVar, exists := env.DisableL10n.Lookup(); exists { - if b, err := strconv.ParseBool(envVar); err == nil { - f.l10n.disabled = b - } - } - } - } - }) - - 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) - } - - if f.defaultHTTPCode > 599 { //nolint:gomnd - return fmt.Errorf("wrong default HTTP response code [%d]", f.defaultHTTPCode) - } - - if strings.ContainsRune(f.proxyHTTPHeaders, ' ') { - return fmt.Errorf("whitespaces in the HTTP headers for proxying [%s] are not allowed", f.proxyHTTPHeaders) - } - - return nil -} - -// headersToProxy converts a comma-separated string with headers list into strings slice (with a sorting and without -// duplicates). -func (f *flags) headersToProxy() []string { - var raw = strings.Split(f.proxyHTTPHeaders, ",") - - if len(raw) == 0 { - return []string{} - } else if len(raw) == 1 { - if h := strings.TrimSpace(raw[0]); h != "" { - return []string{h} - } else { - return []string{} - } - } - - var m = make(map[string]struct{}, len(raw)) - - // make unique and ignore empty strings - for _, h := range raw { - if h = strings.TrimSpace(h); h != "" { - if _, ok := m[h]; !ok { - m[h] = struct{}{} - } - } - } - - // convert map into slice - var headers = make([]string, 0, len(m)) - for h := range m { - headers = append(headers, h) - } - - // make sort - sort.Strings(headers) - - return headers -} - -func (f *flags) ToOptions() (o options.ErrorPage) { - o.Default.PageCode = f.defaultErrorPage - o.Default.HTTPCode = f.defaultHTTPCode - o.L10n.Disabled = f.l10n.disabled - o.Template.Name = f.template.name - o.ShowDetails = f.showDetails - o.ProxyHTTPHeaders = f.headersToProxy() - - return o -} +// import ( +// "fmt" +// "net" +// "sort" +// "strconv" +// "strings" +// +// "github.com/spf13/pflag" +// +// "github.com/tarampampam/error-pages/internal/env" +// "github.com/tarampampam/error-pages/internal/options" +// ) +// +// type flags struct { +// Listen struct { +// IP string +// Port uint16 +// } +// template struct { +// name string +// } +// l10n struct { +// disabled bool +// } +// defaultErrorPage string +// defaultHTTPCode uint16 +// showDetails bool +// proxyHTTPHeaders string // comma-separated +// } +// +// +// +// 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 prt number [$%s]", env.ListenPort), +// ) +// flagSet.StringVarP( +// &f.template.name, +// templateNameFlagName, "t", +// "", +// fmt.Sprintf( +// "template name (set \"%s\" to use a randomized or \"%s\" to use a randomized template on each request "+ +// "or \"%s/%s\" daily/hourly randomized) [$%s]", +// useRandomTemplate, +// useRandomTemplateOnEachRequest, +// useRandomTemplateDaily, +// useRandomTemplateHourly, +// env.TemplateName, +// ), +// ) +// flagSet.StringVarP( +// &f.defaultErrorPage, +// defaultErrorPageFlagName, "", +// "404", +// fmt.Sprintf("default error page [$%s]", env.DefaultErrorPage), +// ) +// flagSet.Uint16VarP( +// &f.defaultHTTPCode, +// defaultHTTPCodeFlagName, "", +// 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), +// ) +// flagSet.StringVarP( +// &f.proxyHTTPHeaders, +// proxyHTTPHeadersFlagName, "", +// "", +// fmt.Sprintf("proxy HTTP request headers list (comma-separated) [$%s]", env.ProxyHTTPHeaders), +// ) +// flagSet.BoolVarP( +// &f.l10n.disabled, +// disableL10nFlagName, "", +// false, +// fmt.Sprintf("disable error pages localization [$%s]", env.DisableL10n), +// ) +// } +// +// 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 +// 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 { +// 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) +// } +// +// case defaultErrorPageFlagName: +// if envVar, exists := env.DefaultErrorPage.Lookup(); exists { +// f.defaultErrorPage = strings.TrimSpace(envVar) +// } +// +// case defaultHTTPCodeFlagName: +// if envVar, exists := env.DefaultHTTPCode.Lookup(); exists { +// if code, err := strconv.ParseUint(envVar, 10, 16); err == nil { +// f.defaultHTTPCode = uint16(code) +// } else { +// 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 +// } +// } +// +// case proxyHTTPHeadersFlagName: +// if envVar, exists := env.ProxyHTTPHeaders.Lookup(); exists { +// f.proxyHTTPHeaders = strings.TrimSpace(envVar) +// } +// +// case disableL10nFlagName: +// if envVar, exists := env.DisableL10n.Lookup(); exists { +// if b, err := strconv.ParseBool(envVar); err == nil { +// f.l10n.disabled = b +// } +// } +// } +// } +// }) +// +// 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) +// } +// +// if f.defaultHTTPCode > 599 { //nolint:gomnd +// return fmt.Errorf("wrong default HTTP response code [%d]", f.defaultHTTPCode) +// } +// +// if strings.ContainsRune(f.proxyHTTPHeaders, ' ') { +// return fmt.Errorf("whitespaces in the HTTP headers for proxying [%s] are not allowed", f.proxyHTTPHeaders) +// } +// +// return nil +// } +// +// // headersToProxy converts a comma-separated string with headers list into strings slice (with a sorting and without +// // duplicates). +// func (f *flags) headersToProxy() []string { +// var raw = strings.Split(f.proxyHTTPHeaders, ",") +// +// if len(raw) == 0 { +// return []string{} +// } else if len(raw) == 1 { +// if h := strings.TrimSpace(raw[0]); h != "" { +// return []string{h} +// } else { +// return []string{} +// } +// } +// +// var m = make(map[string]struct{}, len(raw)) +// +// // make unique and ignore empty strings +// for _, h := range raw { +// if h = strings.TrimSpace(h); h != "" { +// if _, ok := m[h]; !ok { +// m[h] = struct{}{} +// } +// } +// } +// +// // convert map into slice +// var headers = make([]string, 0, len(m)) +// for h := range m { +// headers = append(headers, h) +// } +// +// // make sort +// sort.Strings(headers) +// +// return headers +// } +// +// func (f *flags) ToOptions() (o options.ErrorPage) { +// o.Default.PageCode = f.defaultErrorPage +// o.Default.HTTPCode = f.defaultHTTPCode +// o.L10n.Disabled = f.l10n.disabled +// o.Template.Name = f.template.name +// o.ShowDetails = f.showDetails +// o.ProxyHTTPHeaders = f.headersToProxy() +// +// return o +// } diff --git a/internal/cli/shared/flags.go b/internal/cli/shared/flags.go new file mode 100644 index 0000000..5a52c81 --- /dev/null +++ b/internal/cli/shared/flags.go @@ -0,0 +1,31 @@ +package shared + +import ( + "github.com/urfave/cli/v2" + + "github.com/tarampampam/error-pages/internal/env" +) + +var ConfigFileFlag = &cli.StringFlag{ //nolint:gochecknoglobals + Name: "config-file", + Aliases: []string{"c"}, + Usage: "path to the config file (yaml)", + Value: "./error-pages.yml", + EnvVars: []string{env.ConfigFilePath.String()}, +} + +var ListenAddrFlag = &cli.StringFlag{ //nolint:gochecknoglobals + Name: "listen", + Aliases: []string{"l"}, + Usage: "IP address to Listen on", + Value: "0.0.0.0", + EnvVars: []string{env.ListenAddr.String()}, +} + +var ListenPortFlag = &cli.UintFlag{ //nolint:gochecknoglobals + Name: "port", + Aliases: []string{"p"}, + Usage: "TCP port number", + Value: 8080, //nolint:gomnd + EnvVars: []string{env.ListenPort.String()}, +} diff --git a/internal/cli/version/command.go b/internal/cli/version/command.go deleted file mode 100644 index e3b4b9d..0000000 --- a/internal/cli/version/command.go +++ /dev/null @@ -1,24 +0,0 @@ -// 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 - }, - } -} diff --git a/internal/cli/version/command_test.go b/internal/cli/version/command_test.go deleted file mode 100644 index 27db5bc..0000000 --- a/internal/cli/version/command_test.go +++ /dev/null @@ -1,30 +0,0 @@ -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()) -} diff --git a/internal/env/env.go b/internal/env/env.go index c1ee43b..4e0a863 100644 --- a/internal/env/env.go +++ b/internal/env/env.go @@ -6,6 +6,9 @@ import "os" type envVariable string const ( + LogLevel envVariable = "LOG_LEVEL" // logging level + LogFormat envVariable = "LOG_FORMAT" // logging format (json|console) + ListenAddr envVariable = "LISTEN_ADDR" // IP address for listening ListenPort envVariable = "LISTEN_PORT" // port number for listening TemplateName envVariable = "TEMPLATE_NAME" // template name diff --git a/internal/logger/format.go b/internal/logger/format.go new file mode 100644 index 0000000..cd7e114 --- /dev/null +++ b/internal/logger/format.go @@ -0,0 +1,68 @@ +package logger + +import ( + "fmt" + "strings" +) + +// A Format is a logging format. +type Format uint8 + +const ( + ConsoleFormat Format = iota // useful for console output (for humans) + JSONFormat // useful for logging aggregation systems (for robots) +) + +// String returns a lower-case ASCII representation of the log format. +func (f Format) String() string { + switch f { + case ConsoleFormat: + return "console" + case JSONFormat: + return "json" + } + + return fmt.Sprintf("format(%d)", f) +} + +// Formats returns a slice of all logging formats. +func Formats() []Format { + return []Format{ConsoleFormat, JSONFormat} +} + +// FormatStrings returns a slice of all logging formats as strings. +func FormatStrings() []string { + var ( + formats = Formats() + result = make([]string, len(formats)) + ) + + for i := range formats { + result[i] = formats[i].String() + } + + return result +} + +// ParseFormat parses a format (case is ignored) based on the ASCII representation of the log format. +// If the provided ASCII representation is invalid an error is returned. +// +// This is particularly useful when dealing with text input to configure log formats. +func ParseFormat[T string | []byte](text T) (Format, error) { + var format string + + if s, ok := any(text).(string); ok { + format = s + } else { + format = string(any(text).([]byte)) + } + + switch strings.ToLower(format) { + case "console", "": // make the zero value useful + return ConsoleFormat, nil + case "json": + return JSONFormat, nil + } + + return Format(0), fmt.Errorf("unrecognized logging format: %q", text) +} diff --git a/internal/logger/format_test.go b/internal/logger/format_test.go new file mode 100644 index 0000000..fb09c8f --- /dev/null +++ b/internal/logger/format_test.go @@ -0,0 +1,62 @@ +package logger_test + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/tarampampam/error-pages/internal/logger" +) + +func TestFormat_String(t *testing.T) { + for name, tt := range map[string]struct { + giveFormat logger.Format + wantString string + }{ + "json": {giveFormat: logger.JSONFormat, wantString: "json"}, + "console": {giveFormat: logger.ConsoleFormat, wantString: "console"}, + "": {giveFormat: logger.Format(255), wantString: "format(255)"}, + } { + t.Run(name, func(t *testing.T) { + require.Equal(t, tt.wantString, tt.giveFormat.String()) + }) + } +} + +func TestParseFormat(t *testing.T) { + for name, tt := range map[string]struct { + giveBytes []byte + giveString string + wantFormat logger.Format + wantError error + }{ + "": {giveBytes: []byte(""), wantFormat: logger.ConsoleFormat}, + " (string)": {giveString: "", wantFormat: logger.ConsoleFormat}, + "console": {giveBytes: []byte("console"), wantFormat: logger.ConsoleFormat}, + "console (string)": {giveString: "console", wantFormat: logger.ConsoleFormat}, + "json": {giveBytes: []byte("json"), wantFormat: logger.JSONFormat}, + "json (string)": {giveString: "json", wantFormat: logger.JSONFormat}, + "foobar": {giveBytes: []byte("foobar"), wantError: errors.New("unrecognized logging format: \"foobar\"")}, //nolint:lll + } { + t.Run(name, func(t *testing.T) { + var ( + f logger.Format + err error + ) + + if tt.giveString != "" { + f, err = logger.ParseFormat(tt.giveString) + } else { + f, err = logger.ParseFormat(tt.giveBytes) + } + + if tt.wantError == nil { + require.NoError(t, err) + require.Equal(t, tt.wantFormat, f) + } else { + require.EqualError(t, err, tt.wantError.Error()) + } + }) + } +} diff --git a/internal/logger/level.go b/internal/logger/level.go new file mode 100644 index 0000000..5977add --- /dev/null +++ b/internal/logger/level.go @@ -0,0 +1,83 @@ +package logger + +import ( + "fmt" + "strings" +) + +// A Level is a logging level. +type Level int8 + +const ( + DebugLevel Level = iota - 1 + InfoLevel // default level (zero-value) + WarnLevel + ErrorLevel + FatalLevel +) + +// String returns a lower-case ASCII representation of the log level. +func (l Level) String() string { + switch l { + case DebugLevel: + return "debug" + case InfoLevel: + return "info" + case WarnLevel: + return "warn" + case ErrorLevel: + return "error" + case FatalLevel: + return "fatal" + } + + return fmt.Sprintf("level(%d)", l) +} + +// Levels returns a slice of all logging levels. +func Levels() []Level { + return []Level{DebugLevel, InfoLevel, WarnLevel, ErrorLevel, FatalLevel} +} + +// LevelStrings returns a slice of all logging levels as strings. +func LevelStrings() []string { + var ( + levels = Levels() + result = make([]string, len(levels)) + ) + + for i := range levels { + result[i] = levels[i].String() + } + + return result +} + +// ParseLevel parses a level (case is ignored) based on the ASCII representation of the log level. +// If the provided ASCII representation is invalid an error is returned. +// +// This is particularly useful when dealing with text input to configure log levels. +func ParseLevel[T string | []byte](text T) (Level, error) { + var lvl string + + if s, ok := any(text).(string); ok { + lvl = s + } else { + lvl = string(any(text).([]byte)) + } + + switch strings.ToLower(lvl) { + case "debug", "verbose", "trace": + return DebugLevel, nil + case "info", "": // make the zero value useful + return InfoLevel, nil + case "warn": + return WarnLevel, nil + case "error": + return ErrorLevel, nil + case "fatal": + return FatalLevel, nil + } + + return Level(0), fmt.Errorf("unrecognized logging level: %q", text) +} diff --git a/internal/logger/level_test.go b/internal/logger/level_test.go new file mode 100644 index 0000000..59f4a1f --- /dev/null +++ b/internal/logger/level_test.go @@ -0,0 +1,84 @@ +package logger_test + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/tarampampam/error-pages/internal/logger" +) + +func TestLevel_String(t *testing.T) { + for name, tt := range map[string]struct { + giveLevel logger.Level + wantString string + }{ + "debug": {giveLevel: logger.DebugLevel, wantString: "debug"}, + "info": {giveLevel: logger.InfoLevel, wantString: "info"}, + "warn": {giveLevel: logger.WarnLevel, wantString: "warn"}, + "error": {giveLevel: logger.ErrorLevel, wantString: "error"}, + "fatal": {giveLevel: logger.FatalLevel, wantString: "fatal"}, + "": {giveLevel: logger.Level(127), wantString: "level(127)"}, + } { + t.Run(name, func(t *testing.T) { + require.Equal(t, tt.wantString, tt.giveLevel.String()) + }) + } +} + +func TestParseLevel(t *testing.T) { + for name, tt := range map[string]struct { + giveBytes []byte + giveString string + wantLevel logger.Level + wantError error + }{ + "": {giveBytes: []byte(""), wantLevel: logger.InfoLevel}, + " (string)": {giveString: "", wantLevel: logger.InfoLevel}, + "trace": {giveBytes: []byte("debug"), wantLevel: logger.DebugLevel}, + "verbose": {giveBytes: []byte("debug"), wantLevel: logger.DebugLevel}, + "debug": {giveBytes: []byte("debug"), wantLevel: logger.DebugLevel}, + "debug (string)": {giveString: "debug", wantLevel: logger.DebugLevel}, + "info": {giveBytes: []byte("info"), wantLevel: logger.InfoLevel}, + "warn": {giveBytes: []byte("warn"), wantLevel: logger.WarnLevel}, + "error": {giveBytes: []byte("error"), wantLevel: logger.ErrorLevel}, + "fatal": {giveBytes: []byte("fatal"), wantLevel: logger.FatalLevel}, + "fatal (string)": {giveString: "fatal", wantLevel: logger.FatalLevel}, + "foobar": {giveBytes: []byte("foobar"), wantError: errors.New("unrecognized logging level: \"foobar\"")}, //nolint:lll + } { + t.Run(name, func(t *testing.T) { + var ( + l logger.Level + err error + ) + + if tt.giveString != "" { + l, err = logger.ParseLevel(tt.giveString) + } else { + l, err = logger.ParseLevel(tt.giveBytes) + } + + if tt.wantError == nil { + require.NoError(t, err) + require.Equal(t, tt.wantLevel, l) + } else { + require.EqualError(t, err, tt.wantError.Error()) + } + }) + } +} + +func TestLevels(t *testing.T) { + require.Equal(t, []logger.Level{ + logger.DebugLevel, + logger.InfoLevel, + logger.WarnLevel, + logger.ErrorLevel, + logger.FatalLevel, + }, logger.Levels()) +} + +func TestLevelStrings(t *testing.T) { + require.Equal(t, []string{"debug", "info", "warn", "error", "fatal"}, logger.LevelStrings()) +} diff --git a/internal/logger/logger.go b/internal/logger/logger.go index b503de8..67cfe20 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -2,20 +2,27 @@ package logger import ( + "errors" + "go.uber.org/zap" "go.uber.org/zap/zapcore" ) -// New creates new "zap" logger with little customization. -func New(verbose, debug, logJSON bool) (*zap.Logger, error) { +// New creates new "zap" logger with a small customization. +func New(l Level, f Format) (*zap.Logger, error) { var config zap.Config - if logJSON { - config = zap.NewProductionConfig() - } else { + switch f { + case ConsoleFormat: config = zap.NewDevelopmentConfig() config.EncoderConfig.EncodeLevel = zapcore.LowercaseColorLevelEncoder config.EncoderConfig.EncodeTime = zapcore.TimeEncoderOfLayout("15:04:05") + + case JSONFormat: + config = zap.NewProductionConfig() // json encoder is used by default + + default: + return nil, errors.New("unsupported logging format") } // default configuration for all encoders @@ -24,15 +31,31 @@ func New(verbose, debug, logJSON bool) (*zap.Logger, error) { config.DisableStacktrace = true config.DisableCaller = true - if debug { + // enable additional features for debugging + if l <= DebugLevel { config.Development = true config.DisableStacktrace = false config.DisableCaller = false } - if verbose || debug { - config.Level = zap.NewAtomicLevelAt(zap.DebugLevel) + var zapLvl zapcore.Level + + switch l { // convert level to zap.Level + case DebugLevel: + zapLvl = zap.DebugLevel + case InfoLevel: + zapLvl = zap.InfoLevel + case WarnLevel: + zapLvl = zap.WarnLevel + case ErrorLevel: + zapLvl = zap.ErrorLevel + case FatalLevel: + zapLvl = zap.FatalLevel + default: + return nil, errors.New("unsupported logging level") } + config.Level = zap.NewAtomicLevelAt(zapLvl) + return config.Build() } diff --git a/internal/logger/logger_test.go b/internal/logger/logger_test.go index b6421a9..3e23127 100644 --- a/internal/logger/logger_test.go +++ b/internal/logger/logger_test.go @@ -8,64 +8,51 @@ import ( "github.com/kami-zh/go-capturer" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tarampampam/error-pages/internal/logger" ) -func TestNewNotVerboseDebugJSON(t *testing.T) { +func TestNewDebugLevelConsoleFormat(t *testing.T) { output := capturer.CaptureStderr(func() { - log, err := logger.New(false, false, false) - assert.NoError(t, err) + log, err := logger.New(logger.DebugLevel, logger.ConsoleFormat) + require.NoError(t, err) - log.Info("inf msg") log.Debug("dbg msg") + log.Info("inf msg") log.Error("err msg") }) assert.Contains(t, output, time.Now().Format("15:04:05")) assert.Regexp(t, `\t.+info.+\tinf msg`, output) - assert.NotContains(t, output, "dbg msg") - assert.Contains(t, output, "err msg") -} - -func TestNewVerboseNotDebugJSON(t *testing.T) { - output := capturer.CaptureStderr(func() { - log, err := logger.New(true, false, false) - assert.NoError(t, err) - - log.Info("inf msg") - log.Debug("dbg msg") - log.Error("err msg") - }) - - assert.Contains(t, output, time.Now().Format("15:04:05")) - assert.Regexp(t, `\t.+info.+\tinf msg`, output) - assert.Contains(t, output, "dbg msg") - assert.Contains(t, output, "err msg") -} - -func TestNewVerboseDebugNotJSON(t *testing.T) { - output := capturer.CaptureStderr(func() { - log, err := logger.New(true, true, false) - assert.NoError(t, err) - - log.Info("inf msg") - log.Debug("dbg msg") - log.Error("err msg") - }) - - assert.Contains(t, output, time.Now().Format("15:04:05")) assert.Regexp(t, `\t.+info.+\t.+logger_test\.go:\d+\tinf msg`, output) assert.Contains(t, output, "dbg msg") assert.Contains(t, output, "err msg") } -func TestNewNotVerboseDebugButJSON(t *testing.T) { +func TestNewErrorLevelConsoleFormat(t *testing.T) { output := capturer.CaptureStderr(func() { - log, err := logger.New(false, false, true) - assert.NoError(t, err) + log, err := logger.New(logger.ErrorLevel, logger.ConsoleFormat) + require.NoError(t, err) - log.Info("inf msg") log.Debug("dbg msg") + log.Info("inf msg") + log.Error("err msg") + }) + + assert.NotContains(t, output, "inf msg") + assert.NotContains(t, output, "dbg msg") + assert.Contains(t, output, "err msg") +} + +func TestNewWarnLevelJSONFormat(t *testing.T) { + output := capturer.CaptureStderr(func() { + log, err := logger.New(logger.WarnLevel, logger.JSONFormat) + require.NoError(t, err) + + log.Debug("dbg msg") + log.Info("inf msg") + log.Warn("warn msg") log.Error("err msg") }) @@ -75,6 +62,14 @@ func TestNewNotVerboseDebugButJSON(t *testing.T) { lines := strings.Split(strings.Trim(output, "\n"), "\n") - assert.JSONEq(t, `{"level":"info","ts":0.1,"msg":"inf msg"}`, lines[0]) + assert.JSONEq(t, `{"level":"warn","ts":0.1,"msg":"warn msg"}`, lines[0]) assert.JSONEq(t, `{"level":"error","ts":0.1,"msg":"err msg"}`, lines[1]) } + +func TestNewErrors(t *testing.T) { + _, err := logger.New(logger.Level(127), logger.ConsoleFormat) + require.EqualError(t, err, "unsupported logging level") + + _, err = logger.New(logger.WarnLevel, logger.Format(255)) + require.EqualError(t, err, "unsupported logging format") +}