From cf20e2a5105fe2a293e8fd16fc37d5ddeb5b43e7 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Thomas=20B=C3=B8gh=20Fangel?= All processes in the docker container runs as a dedicated non-root user called The API will be available at http://localhost:3000. The more resources are granted, the quicker will be the conversions. Also, in the deployment spec of the pod, specify the uid In the following examples, we will assume your
Gotenberg API is available at http://localhost:3000. In order to save some resources, the Gotenberg image accepts the environment variable It takes the strings It takes the strings If Google Chrome is disabled, the following conversions will not be available anymore:
@@ -263,7 +272,7 @@ See the timeout section. You may turn off this logging so as to avoid unnecessary entries in your logs with the environment variable This environment variable operates in the same manner as the This environment variable operates in the same manner as the $ docker run --rm -p 3000:3000 thecodingmachine/gotenberg:5
+gotenberg with user id 1001 from the working dir /gotenberg.
@@ -176,6 +178,13 @@ Otherwise the API will not be able to launch Google Chrome and LibreOffice (unoc
1001 of the user gotenberg: securityContext:
+ privileged: false
+ runAsUser: 1001
+
+
DISABLE_GOOGLE_CHROME."0" or "1" as value."0" or "1" as value where 1 means true
DISABLE_HEALTHCHECK_LOGGING.DISABLE_GOOGLE_CHROME and DISABLE_UNOCONV variables operate in that it accepts the strings "0" or "1" as values.DISABLE_GOOGLE_CHROME and DISABLE_UNOCONV variables operate in that it accepts the strings "0" or "1" as values, where 1 is enabled.timeout section.
This environment variable accepts any string that can be turned into a port number (e.g., the string "0" up to the string "65535").
By default, stdout and stderr messages from the started processes are disabled.
+ +You may enable some debug logging from starting the process by setting the environment variable DEBUG_PROCESS_STARTUP.
This environment variable operates in the same manner as the DISABLE_GOOGLE_CHROME and DISABLE_UNOCONV variables operate in that it accepts the strings "0" or "1" as values, where 1 means true.
$ docker run --rm -p 3000:3000 thecodingmachine/gotenberg:5
-All processes in the docker container runs as a dedicated non-root user called gotenberg with user id 1001 from the working dir /gotenberg.
All processes in the Docker container runs as a dedicated non-root user called gotenberg with user id 1001 from the working dir /gotenberg.
The API will be available at http://localhost:3000.
@@ -180,9 +180,9 @@ Otherwise the API will not be able to launch Google Chrome and LibreOffice (unocAlso, in the deployment spec of the pod, specify the uid
-1001of the usergotenberg:securityContext: - privileged: false - runAsUser: 1001 +securityContext: + privileged: false + runAsUser: 1001In the following examples, we will assume your @@ -288,7 +288,7 @@ See the timeout section.
Debug logging of process startup -By default, stdout and stderr messages from the started processes are disabled.
+By default,
stdoutandstderrmessages from the started processes are disabled.You may enable some debug logging from starting the process by setting the environment variable
diff --git a/internal/pkg/pm2/chrome.go b/internal/pkg/pm2/chrome.go index 105c76cc..bad71c3e 100644 --- a/internal/pkg/pm2/chrome.go +++ b/internal/pkg/pm2/chrome.go @@ -7,15 +7,13 @@ import ( "github.com/mafredri/cdp/devtool" ) -const ( - warmupTime = 10 * time.Second -) +const warmupTime = 10 * time.Second type chrome struct { manager *processManager } -// NewChrome retruns a Google Chrome +// NewChrome returns a Google Chrome // headless process. func NewChrome(debug bool) Process { return &chrome{ diff --git a/internal/pkg/pm2/pm2.go b/internal/pkg/pm2/pm2.go index 167b3e0d..fe2498d6 100644 --- a/internal/pkg/pm2/pm2.go +++ b/internal/pkg/pm2/pm2.go @@ -87,11 +87,11 @@ func (m *processManager) pm2(p Process, cmdName string) error { if m.verbose { processStdErr, err := cmd.StderrPipe() if err != nil { - return fmt.Errorf("failed getting stderr from '%s': %v", p.Fullname(), err) + return fmt.Errorf("failed getting stderr from %s: %s", p.Fullname(), err) } processStdOut, err := cmd.StdoutPipe() if err != nil { - return fmt.Errorf("failed getting stdout from '%s': %v", p.Fullname(), err) + return fmt.Errorf("failed getting stdout from %s: %s", p.Fullname(), err) } readFromPipe := func(name string, reader io.ReadCloser) { r := bufio.NewReader(reader) @@ -100,12 +100,12 @@ func (m *processManager) pm2(p Process, cmdName string) error { line, _, err := r.ReadLine() if err != nil { if err != io.EOF { - m.notifyf("error reading from %v for process '%v'", name, p.Fullname()) + m.notifyf("error reading from %s for process %s", name, p.Fullname()) } break } if len(line) != 0 { - m.notifyf("%v %v: %s", p.name(), name, string(line)) + m.notifyf("%s %s: %s", p.name(), name, string(line)) } } } diff --git a/internal/pkg/pm2/unoconv.go b/internal/pkg/pm2/unoconv.go index 7c4fa13e..e7274f69 100644 --- a/internal/pkg/pm2/unoconv.go +++ b/internal/pkg/pm2/unoconv.go @@ -4,7 +4,7 @@ type unoconv struct { manager *processManager } -// NewUnoconv retruns a unoconv listener +// NewUnoconv returns a unoconv listener // process. func NewUnoconv(debug bool) Process { return &unoconv{ From a4aa8dafac74c55ca617fd4491f5c274d27c1b04 Mon Sep 17 00:00:00 2001 From: Julien NeuhartDEBUG_PROCESS_STARTUP.Date: Mon, 1 Jul 2019 17:11:23 +0200 Subject: [PATCH 10/34] WIP: refactoring logging system --- Makefile | 9 +- build/tests/docker-entrypoint.sh | 1 - cmd/gotenberg/main.go | 162 +++++++++---------------------- go.mod | 4 +- go.sum | 11 ++- internal/app/api/api.go | 45 +++------ internal/app/api/handler.go | 36 ++++--- internal/app/api/middleware.go | 112 +++++++++++---------- internal/app/api/resource.go | 61 +++++------- internal/pkg/notify/doc.go | 5 - internal/pkg/notify/notify.go | 35 ------- internal/pkg/pm2/chrome.go | 14 +-- internal/pkg/pm2/pm2.go | 59 +++++------ internal/pkg/pm2/unoconv.go | 8 +- internal/pkg/printer/markdown.go | 7 +- internal/pkg/printer/office.go | 7 +- internal/pkg/rand/doc.go | 7 -- internal/pkg/rand/rand.go | 17 ---- internal/pkg/rand/rand_test.go | 16 --- 19 files changed, 225 insertions(+), 391 deletions(-) delete mode 100644 internal/pkg/notify/doc.go delete mode 100644 internal/pkg/notify/notify.go delete mode 100644 internal/pkg/rand/doc.go delete mode 100644 internal/pkg/rand/rand.go delete mode 100644 internal/pkg/rand/rand_test.go diff --git a/Makefile b/Makefile index 0af767b0..0651d971 100644 --- a/Makefile +++ b/Makefile @@ -3,6 +3,11 @@ VERSION=snapshot DOCKER_USER= DOCKER_PASSWORD= DOCKER_REPOSITORY=thecodingmachine +DEFAULT_WAIT_TIMEOUT=10 +DEFAULT_LISTEN_PORT=3000 +DISABLE_GOOGLE_CHROME=0 +DISABLE_UNOCONV=0 +LOG_LEVEL=INFO # generate documentation. doc: @@ -28,11 +33,11 @@ tests: # build Docker image. image: docker build -t $(DOCKER_REPOSITORY)/gotenberg:base -f build/base/Dockerfile . - docker build --build-arg GOLANG_VERSION=$(GOLANG_VERSION) --build-arg VERSION=$(VERSION) -t $(DOCKER_REPO)/gotenberg:$(VERSION) -f build/package/Dockerfile . + docker build --build-arg GOLANG_VERSION=$(GOLANG_VERSION) --build-arg VERSION=$(VERSION) -t $(DOCKER_REPOSITORY)/gotenberg:$(VERSION) -f build/package/Dockerfile . # start the API using previously built Docker image. gotenberg: - docker run -it --rm -e DEBUG_PROCESS_STARTUP=1 -p "3000:3000" $(DOCKER_REPOSITORY)/gotenberg:$(VERSION) + docker run -it --rm -e DEFAULT_WAIT_TIMEOUT=$(DEFAULT_WAIT_TIMEOUT) -e DEFAULT_LISTEN_PORT=$(DEFAULT_LISTEN_PORT) -e DISABLE_GOOGLE_CHROME=$(DISABLE_GOOGLE_CHROME) -e DISABLE_UNOCONV=$(DISABLE_UNOCONV) -e LOG_LEVEL=$(LOG_LEVEL) -p "3000:$(DEFAULT_LISTEN_PORT)" $(DOCKER_REPOSITORY)/gotenberg:$(VERSION) # publish Gotenberg images according to version. publish: diff --git a/build/tests/docker-entrypoint.sh b/build/tests/docker-entrypoint.sh index fa2f311a..91cc7d79 100755 --- a/build/tests/docker-entrypoint.sh +++ b/build/tests/docker-entrypoint.sh @@ -10,7 +10,6 @@ go test github.com/thecodingmachine/gotenberg/internal/pkg/pm2 -run TestUnoconvS # Running others tests. go test -race -cover -covermode=atomic github.com/thecodingmachine/gotenberg/internal/app/api -go test -race -cover -covermode=atomic github.com/thecodingmachine/gotenberg/internal/pkg/rand # Finally testing processes shutdown. go test github.com/thecodingmachine/gotenberg/internal/pkg/pm2 -run TestChromeShutdown diff --git a/cmd/gotenberg/main.go b/cmd/gotenberg/main.go index 9d6a3088..c3382512 100644 --- a/cmd/gotenberg/main.go +++ b/cmd/gotenberg/main.go @@ -6,12 +6,11 @@ import ( "net/http" "os" "os/signal" - "strconv" "time" - "github.com/labstack/echo/v4" "github.com/thecodingmachine/gotenberg/internal/app/api" - "github.com/thecodingmachine/gotenberg/internal/pkg/notify" + conf "github.com/thecodingmachine/gotenberg/internal/pkg/config" + log "github.com/thecodingmachine/gotenberg/internal/pkg/logger" "github.com/thecodingmachine/gotenberg/internal/pkg/pm2" ) @@ -19,116 +18,37 @@ import ( // nolint: gochecknoglobals var version = "snapshot" -const ( - defaultWaitTimeoutEnvVar = "DEFAULT_WAIT_TIMEOUT" - defaultListenPortEnvVar = "DEFAULT_LISTEN_PORT" - disableGoogleChromeEnvVar = "DISABLE_GOOGLE_CHROME" - disableUnoconvEnvVar = "DISABLE_UNOCONV" - disableHealthcheckLoggingEnvVar = "DISABLE_HEALTHCHECK_LOGGING" - debugProcessStartup = "DEBUG_PROCESS_STARTUP" -) - -func mustParseEnvVar() *api.Options { - opts := api.DefaultOptions() - if os.Getenv(defaultWaitTimeoutEnvVar) != "" { - defaultWaitTimeout, err := strconv.ParseFloat(os.Getenv(defaultWaitTimeoutEnvVar), 64) - if err != nil { - notify.ErrPrint(fmt.Errorf("%s: wrong value: want float got %v", defaultWaitTimeoutEnvVar, err)) - os.Exit(1) - } - opts.DefaultWaitTimeout = defaultWaitTimeout - } - if v, ok := os.LookupEnv(defaultListenPortEnvVar); ok { - defaultListener, err := strconv.ParseUint(os.Getenv(defaultListenPortEnvVar), 10, 64) - if err != nil { - notify.ErrPrint(fmt.Errorf("%s: wrong value: want uint got %v", defaultListenPortEnvVar, err)) - os.Exit(1) - } - if defaultListener > 65535 { - notify.ErrPrint(fmt.Errorf("%s: wrong value: want uint < 65535 got %v", defaultListenPortEnvVar, defaultListener)) - os.Exit(1) - } - opts.DefaultListenPort = v - } - // checkBoolEnv is a convenience function for reading - // an env var with a bool value where - // `1` is true and `0` is false. - checkBoolEnv := func(name string) bool { - if v, ok := os.LookupEnv(name); ok { - if v != "1" && v != "0" { - notify.ErrPrint(fmt.Errorf("%s: wrong value: want \"0\" or \"1\" got %v", name, v)) - os.Exit(1) - } - return v == "1" - } - return false - } - opts.EnableChromeEndpoints = !checkBoolEnv(disableGoogleChromeEnvVar) - opts.EnableUnoconvEndpoints = !checkBoolEnv(disableUnoconvEnvVar) - opts.EnableHealthcheckLogging = !checkBoolEnv(disableHealthcheckLoggingEnvVar) - opts.DebugProcessStartup = checkBoolEnv(debugProcessStartup) - return opts -} - -func mustStartProcesses(opts *api.Options) []pm2.Process { - var processes []pm2.Process - if opts.EnableChromeEndpoints { - processes = append(processes, pm2.NewChrome(opts.DebugProcessStartup)) - } - if opts.EnableUnoconvEndpoints { - processes = append(processes, pm2.NewUnoconv(opts.DebugProcessStartup)) - } - for _, p := range processes { - notify.Printf("starting %s with PM2...", p.Fullname()) - if err := p.Start(); err != nil { - notify.ErrPrint(err) - os.Exit(1) - } - } - return processes -} - -func mustStartAPI(srv *echo.Echo, port string) { - notify.Printf("http server started on port %v", port) - if err := srv.Start(fmt.Sprintf(":%v", port)); err != nil { - if err != http.ErrServerClosed { - notify.ErrPrint(err) - os.Exit(1) - } - } -} - -func mustShutdownProcesses(processes []pm2.Process) { - for _, p := range processes { - notify.Printf("shutting down %s with PM2... (Ctrl+C to force)", p.Fullname()) - if err := p.Shutdown(); err != nil { - notify.ErrPrint(err) - os.Exit(1) - } - } -} - -func mustShutdownAPI(srv *echo.Echo) { - // create a deadline to wait for. - ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) - defer cancel() - // doesn't block if no connections, but will otherwise wait - // until the timeout deadline. - notify.Print("shutting down http server... (Ctrl+C to force)") - if err := srv.Shutdown(ctx); err != nil { - notify.ErrPrint(err) - os.Exit(1) - } -} - func main() { - notify.Printf("Gotenberg %s", version) - opts := mustParseEnvVar() - srv := api.New(opts) - processes := mustStartProcesses(opts) - // run our API in a goroutine so that it doesn't block.s + config, err := conf.FromEnv() + systemLogger := log.New(config.LogLevel(), "system") + if err != nil { + systemLogger.Fatal(err) + } + systemLogger.Infof("Gotenberg %s", version) + // start PM2 processes. + var processes []pm2.Process + if config.EnableChromeEndpoints() { + processes = append(processes, pm2.NewChrome(systemLogger)) + } + if config.EnableUnoconvEndpoints() { + processes = append(processes, pm2.NewUnoconv(systemLogger)) + } + for _, p := range processes { + systemLogger.Infof("starting %s with PM2...", p.Fullname()) + if err := p.Start(); err != nil { + systemLogger.Fatal(err) + } + } + // run our API in a goroutine so that it doesn't block. + // create our API. + srv := api.New(config) go func() { - mustStartAPI(srv, opts.DefaultListenPort) + systemLogger.Infof("http server started on port %s", config.DefaultListenPort()) + if err := srv.Start(fmt.Sprintf(":%s", config.DefaultListenPort())); err != nil { + if err != http.ErrServerClosed { + systemLogger.Fatal(err) + } + } }() quit := make(chan os.Signal, 1) // we'll accept graceful shutdowns when quit via SIGINT (Ctrl+C) @@ -136,8 +56,22 @@ func main() { signal.Notify(quit, os.Interrupt) // block until we receive our signal. <-quit - mustShutdownAPI(srv) - mustShutdownProcesses(processes) - notify.Print("bye!") + // create a deadline to wait for. + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + // doesn't block if no connections, but will otherwise wait + // until the timeout deadline. + systemLogger.Info("shutting down http server...") + if err := srv.Shutdown(ctx); err != nil { + systemLogger.Fatal(err) + } + // shutdown PM2 processes. + for _, p := range processes { + systemLogger.Infof("shutting down %s with PM2...", p.Fullname()) + if err := p.Shutdown(); err != nil { + systemLogger.Fatal(err) + } + } + systemLogger.Info("bye!") os.Exit(0) } diff --git a/go.mod b/go.mod index d67246a6..85ced362 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,6 @@ module github.com/thecodingmachine/gotenberg go 1.12 require ( - github.com/davecgh/go-spew v1.1.1 // indirect github.com/google/go-cmp v0.2.0 // indirect github.com/gorilla/websocket v1.4.0 // indirect github.com/labstack/echo/v4 v4.0.0 @@ -14,10 +13,11 @@ require ( github.com/microcosm-cc/bluemonday v1.0.1 github.com/russross/blackfriday/v2 v2.0.1 github.com/shurcooL/sanitized_anchor_name v0.0.0-20170918181015-86672fcb3f95 // indirect + github.com/sirupsen/logrus v1.4.2 github.com/stretchr/testify v1.3.0 github.com/valyala/fasttemplate v1.0.1 // indirect golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c // indirect golang.org/x/net v0.0.0-20181201002055-351d144fa1fc // indirect golang.org/x/sync v0.0.0-20181108010431-42b317875d0f - golang.org/x/sys v0.0.0-20190322080309-f49334f85ddc // indirect + golang.org/x/sys v0.0.0-20190621062556-bf70e4678053 // indirect ) diff --git a/go.sum b/go.sum index 2e6eae15..32ff4bc2 100644 --- a/go.sum +++ b/go.sum @@ -7,6 +7,8 @@ github.com/google/go-cmp v0.2.0 h1:+dTQ8DZQJz0Mb/HjFlkptS1FeQ4cWSnN941F8aEG4SQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q= github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/labstack/echo/v4 v4.0.0 h1:q1GH+caIXPP7H2StPIdzy/ez9CO0EepqYeUg6vi9SWM= github.com/labstack/echo/v4 v4.0.0/go.mod h1:tZv7nai5buKSg5h/8E6zz4LsD/Dqh9/91Mvs7Z5Zyno= github.com/labstack/gommon v0.2.8 h1:JvRqmeZcfrHC5u6uVleB4NxxNbzx6gpbJiQknDbKQu0= @@ -30,7 +32,11 @@ github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0 github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/shurcooL/sanitized_anchor_name v0.0.0-20170918181015-86672fcb3f95 h1:/vdW8Cb7EXrkqWGufVMES1OH2sU9gKVb2n9/1y5NMBY= github.com/shurcooL/sanitized_anchor_name v0.0.0-20170918181015-86672fcb3f95/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= 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/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= @@ -49,5 +55,6 @@ golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sys v0.0.0-20190129075346-302c3dd5f1cc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190322080309-f49334f85ddc h1:4gbWbmmPFp4ySWICouJl6emP0MyS31yy9SrTlAGFT+g= -golang.org/x/sys v0.0.0-20190322080309-f49334f85ddc/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190621062556-bf70e4678053 h1:T0MJjz97TtCXa3ZNW2Oenb3KQWB91K965zMEbIJ4ThA= +golang.org/x/sys v0.0.0-20190621062556-bf70e4678053/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/internal/app/api/api.go b/internal/app/api/api.go index d00e01eb..23db1d6f 100644 --- a/internal/app/api/api.go +++ b/internal/app/api/api.go @@ -1,51 +1,32 @@ package api -import "github.com/labstack/echo/v4" +import ( + "github.com/labstack/echo/v4" + conf "github.com/thecodingmachine/gotenberg/internal/pkg/config" +) const pingEndpoint = "/ping" -// Options allows to customize the behaviour -// of the API. -type Options struct { - DefaultWaitTimeout float64 - DefaultListenPort string - EnableChromeEndpoints bool - EnableUnoconvEndpoints bool - EnableHealthcheckLogging bool - DebugProcessStartup bool -} - -// DefaultOptions returns default options. -func DefaultOptions() *Options { - return &Options{ - DefaultWaitTimeout: 10, - DefaultListenPort: "3000", - EnableChromeEndpoints: true, - EnableUnoconvEndpoints: true, - EnableHealthcheckLogging: true, - } -} - // New returns an API. -func New(opts *Options) *echo.Echo { +func New(config *conf.Config) *echo.Echo { api := echo.New() api.HideBanner = true api.HidePort = true - api.Use(handleLogging(opts.EnableHealthcheckLogging)) + api.Use(contextMiddleware(config)) + api.Use(loggingMiddleware()) + api.Use(finalizeMiddleware()) api.GET(pingEndpoint, func(c echo.Context) error { return nil }) - g := api.Group("/convert") - g.Use(handleContext(opts)) - g.Use(handleError()) - g.POST("/merge", merge) - if !opts.EnableChromeEndpoints && !opts.EnableUnoconvEndpoints { + api.POST("/merge", merge) + if !config.EnableChromeEndpoints() && !config.EnableUnoconvEndpoints() { return api } - if opts.EnableChromeEndpoints { + g := api.Group("/convert") + if config.EnableChromeEndpoints() { g.POST("/html", convertHTML) g.POST("/url", convertURL) g.POST("/markdown", convertMarkdown) } - if opts.EnableUnoconvEndpoints { + if config.EnableUnoconvEndpoints() { g.POST("/office", convertOffice) } return api diff --git a/internal/app/api/handler.go b/internal/app/api/handler.go index f350e452..69e97763 100644 --- a/internal/app/api/handler.go +++ b/internal/app/api/handler.go @@ -6,8 +6,8 @@ import ( "os" "github.com/labstack/echo/v4" + "github.com/labstack/gommon/random" "github.com/thecodingmachine/gotenberg/internal/pkg/printer" - "github.com/thecodingmachine/gotenberg/internal/pkg/rand" ) type errBadRequest struct { @@ -20,7 +20,7 @@ func (e *errBadRequest) Error() string { func merge(c echo.Context) error { ctx := c.(*resourceContext) - opts, err := ctx.resource.mergePrinterOptions() + opts, err := ctx.resource.mergePrinterOptions(ctx.config.DefaultWaitTimeout()) if err != nil { return &errBadRequest{err} } @@ -34,7 +34,7 @@ func merge(c echo.Context) error { func convertHTML(c echo.Context) error { ctx := c.(*resourceContext) - opts, err := ctx.resource.chromePrinterOptions() + opts, err := ctx.resource.chromePrinterOptions(ctx.config.DefaultWaitTimeout()) if err != nil { return &errBadRequest{err} } @@ -48,7 +48,7 @@ func convertHTML(c echo.Context) error { func convertMarkdown(c echo.Context) error { ctx := c.(*resourceContext) - opts, err := ctx.resource.chromePrinterOptions() + opts, err := ctx.resource.chromePrinterOptions(ctx.config.DefaultWaitTimeout()) if err != nil { return &errBadRequest{err} } @@ -65,7 +65,7 @@ func convertMarkdown(c echo.Context) error { func convertURL(c echo.Context) error { ctx := c.(*resourceContext) - opts, err := ctx.resource.chromePrinterOptions() + opts, err := ctx.resource.chromePrinterOptions(ctx.config.DefaultWaitTimeout()) if err != nil { return &errBadRequest{err} } @@ -79,7 +79,7 @@ func convertURL(c echo.Context) error { func convertOffice(c echo.Context) error { ctx := c.(*resourceContext) - opts, err := ctx.resource.officePrinterOptions() + opts, err := ctx.resource.officePrinterOptions(ctx.config.DefaultWaitTimeout()) if err != nil { return &errBadRequest{err} } @@ -105,10 +105,7 @@ func convertOffice(c echo.Context) error { } func convert(ctx *resourceContext, p printer.Printer) error { - baseFilename, err := rand.Get() - if err != nil { - return err - } + baseFilename := random.String(32) filename := fmt.Sprintf("%s.pdf", baseFilename) fpath := fmt.Sprintf("%s/%s", ctx.resource.formFilesDirPath, filename) // if no webhook URL given, run conversion @@ -118,11 +115,12 @@ func convert(ctx *resourceContext, p printer.Printer) error { if err := p.Print(fpath); err != nil { return err } - if ctx.resource.has(resultFilename) { - filename, err = ctx.resource.get(resultFilename) - if err != nil { - return &errBadRequest{err} - } + if !ctx.resource.has(resultFilename) { + return ctx.Attachment(fpath, filename) + } + filename, err := ctx.resource.get(resultFilename) + if err != nil { + return &errBadRequest{err} } return ctx.Attachment(fpath, filename) } @@ -132,23 +130,23 @@ func convert(ctx *resourceContext, p printer.Printer) error { go func() { defer ctx.resource.close() // nolint: errcheck if err := p.Print(fpath); err != nil { - ctx.Logger().Error(err) + ctx.logger.Error(err) return } f, err := os.Open(fpath) if err != nil { - ctx.Logger().Error(err) + ctx.logger.Error(err) return } defer f.Close() // nolint: errcheck webhook, err := ctx.resource.get(webhookURL) if err != nil { - ctx.Logger().Error(err) + ctx.logger.Error(err) return } resp, err := http.Post(webhook, "application/pdf", f) /* #nosec */ if err != nil { - ctx.Logger().Error(err) + ctx.logger.Error(err) return } defer resp.Body.Close() // nolint: errcheck diff --git a/internal/app/api/middleware.go b/internal/app/api/middleware.go index a3329f6d..1cb35b97 100644 --- a/internal/app/api/middleware.go +++ b/internal/app/api/middleware.go @@ -6,70 +6,84 @@ import ( "strings" "github.com/labstack/echo/v4" - "github.com/labstack/echo/v4/middleware" + "github.com/labstack/gommon/random" + conf "github.com/thecodingmachine/gotenberg/internal/pkg/config" + log "github.com/thecodingmachine/gotenberg/internal/pkg/logger" ) -func handleLogging(enableHealthcheckLogging bool) echo.MiddlewareFunc { - if enableHealthcheckLogging { - // default logging middleware. - return middleware.Logger() - } - // middleware for skipping logging when the ping endpoint is called. - return middleware.LoggerWithConfig(middleware.LoggerConfig{ - Skipper: func(c echo.Context) bool { - return c.Request().URL.Path == pingEndpoint - }, - }) -} - -func handleContext(opts *Options) echo.MiddlewareFunc { - // middleware for extending default context with our - // custom constext. +func contextMiddleware(config *conf.Config) echo.MiddlewareFunc { + // middleware for extending the default context + // with one of our own context. return func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { - ctx := &resourceContext{c, opts, nil} - r, err := newResource(ctx) - if err != nil { - if resourceErr := r.close(); resourceErr != nil { - c.Logger().Error(resourceErr) + // generate a unique identifier for our request. + trace := random.String(32) + // create the logger for this request using + // the previous identifier as trace. + logger := log.New(config.LogLevel(), trace) + // extend the current echo context with our standard + // context. + ctx := newStandardContext(c, logger, config) + // if the endpoint is not for liveness, make a + // context with resource. + if ctx.Path() != pingEndpoint { + ctx, err := ctx.withResource(trace) + if err != nil { + ctx.Error(err) + return ctx.logEndOfRequest(err) } - return err } - ctx.resource = r return next(ctx) } } } -func handleError() echo.MiddlewareFunc { - // middleware for handling errors and removing resources - // once the request has been handled. +func loggingMiddleware() echo.MiddlewareFunc { + // middleware for enabling logging. return func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { - err := next(c) - ctx := c.(*resourceContext) - // if a webhookURL has been given, - // do not remove the resources here because - // we don't know if the result file has been - // generated or sent. - if !ctx.resource.has(webhookURL) { - if resourceErr := ctx.resource.close(); resourceErr != nil { - c.Logger().Error(resourceErr) - } - } + ctx := c.(*standardContext) + err := next(ctx) if err != nil { - if _, ok := err.(*echo.HTTPError); ok { - return err - } - if _, ok := err.(*errBadRequest); ok { - return echo.NewHTTPError(http.StatusBadRequest, err.Error()) - } - if strings.Contains(err.Error(), context.DeadlineExceeded.Error()) { - return echo.NewHTTPError(http.StatusRequestTimeout) - } - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + ctx.Error(err) } - return nil + return ctx.logEndOfRequest(err) + } + } +} + +func finalizeMiddleware() echo.MiddlewareFunc { + // middleware for removing resources at the end of a request + // and for improving response in case of error. + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + err := next(c) + ctx, ok := c.(*resourceContext) + // a resource is associated with the context. + if ok { + // if a webhookURL has been given, + // do not remove the resources here because + // we don't know if the result file has been + // generated or sent. + if !ctx.resource.has(webhookURL) { + if resourceErr := ctx.resource.close(); resourceErr != nil { + ctx.logger.Error(err) + } + } + } + if err == nil { + return nil + } + if _, ok := err.(*echo.HTTPError); ok { + return err + } + if _, ok := err.(*errBadRequest); ok { + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } + if strings.Contains(err.Error(), context.DeadlineExceeded.Error()) { + return echo.NewHTTPError(http.StatusRequestTimeout, err.Error()) + } + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } } } diff --git a/internal/app/api/resource.go b/internal/app/api/resource.go index 6648466d..5a359cee 100644 --- a/internal/app/api/resource.go +++ b/internal/app/api/resource.go @@ -10,7 +10,6 @@ import ( "github.com/labstack/echo/v4" "github.com/thecodingmachine/gotenberg/internal/pkg/printer" - "github.com/thecodingmachine/gotenberg/internal/pkg/rand" ) const ( @@ -31,53 +30,41 @@ const ( type resource struct { formValues map[string]string formFilesDirPath string - opts *Options } -type resourceContext struct { - echo.Context - opts *Options - resource *resource -} - -func newResource(ctx *resourceContext) (*resource, error) { +func newResource(c echo.Context, dirPath string) (*resource, error) { r := &resource{ - formValues: formValues(ctx), - opts: ctx.opts, - } - dirPath, err := rand.Get() - if err != nil { - return r, err + formValues: formValues(c), } r.formFilesDirPath = dirPath if err := os.MkdirAll(dirPath, 0755); err != nil { return nil, fmt.Errorf("%s: making directory: %v", dirPath, err) } - if err := formFiles(ctx, dirPath); err != nil { + if err := formFiles(c, dirPath); err != nil { return r, err } return r, nil } -func formValues(ctx *resourceContext) map[string]string { +func formValues(c echo.Context) map[string]string { v := make(map[string]string) - v[resultFilename] = ctx.FormValue(resultFilename) - v[waitTimeout] = ctx.FormValue(waitTimeout) - v[webhookURL] = ctx.FormValue(webhookURL) - v[remoteURL] = ctx.FormValue(remoteURL) - v[waitDelay] = ctx.FormValue(waitDelay) - v[paperWidth] = ctx.FormValue(paperWidth) - v[paperHeight] = ctx.FormValue(paperHeight) - v[marginTop] = ctx.FormValue(marginTop) - v[marginBottom] = ctx.FormValue(marginBottom) - v[marginLeft] = ctx.FormValue(marginLeft) - v[marginRight] = ctx.FormValue(marginRight) - v[landscape] = ctx.FormValue(landscape) + v[resultFilename] = c.FormValue(resultFilename) + v[waitTimeout] = c.FormValue(waitTimeout) + v[webhookURL] = c.FormValue(webhookURL) + v[remoteURL] = c.FormValue(remoteURL) + v[waitDelay] = c.FormValue(waitDelay) + v[paperWidth] = c.FormValue(paperWidth) + v[paperHeight] = c.FormValue(paperHeight) + v[marginTop] = c.FormValue(marginTop) + v[marginBottom] = c.FormValue(marginBottom) + v[marginLeft] = c.FormValue(marginLeft) + v[marginRight] = c.FormValue(marginRight) + v[landscape] = c.FormValue(landscape) return v } -func formFiles(ctx *resourceContext, dirPath string) error { - form, err := ctx.MultipartForm() +func formFiles(c echo.Context, dirPath string) error { + form, err := c.MultipartForm() if err != nil { return fmt.Errorf("getting multipart form: %v", err) } @@ -117,8 +104,8 @@ func (r *resource) close() error { const defaultHeaderFooterHTML string = "" -func (r *resource) chromePrinterOptions() (*printer.ChromeOptions, error) { - timeout, err := r.float64(waitTimeout, r.opts.DefaultWaitTimeout) +func (r *resource) chromePrinterOptions(defaultWaitTimeout float64) (*printer.ChromeOptions, error) { + timeout, err := r.float64(waitTimeout, defaultWaitTimeout) if err != nil { return nil, err } @@ -177,8 +164,8 @@ func (r *resource) chromePrinterOptions() (*printer.ChromeOptions, error) { }, nil } -func (r *resource) officePrinterOptions() (*printer.OfficeOptions, error) { - timeout, err := r.float64(waitTimeout, r.opts.DefaultWaitTimeout) +func (r *resource) officePrinterOptions(defaultWaitTimeout float64) (*printer.OfficeOptions, error) { + timeout, err := r.float64(waitTimeout, defaultWaitTimeout) if err != nil { return nil, err } @@ -192,8 +179,8 @@ func (r *resource) officePrinterOptions() (*printer.OfficeOptions, error) { }, nil } -func (r *resource) mergePrinterOptions() (*printer.MergeOptions, error) { - timeout, err := r.float64(waitTimeout, r.opts.DefaultWaitTimeout) +func (r *resource) mergePrinterOptions(defaultWaitTimeout float64) (*printer.MergeOptions, error) { + timeout, err := r.float64(waitTimeout, defaultWaitTimeout) if err != nil { return nil, err } diff --git a/internal/pkg/notify/doc.go b/internal/pkg/notify/doc.go deleted file mode 100644 index d60f45c6..00000000 --- a/internal/pkg/notify/doc.go +++ /dev/null @@ -1,5 +0,0 @@ -/* -Package notify helps displaying nice outputs -to the user. -*/ -package notify diff --git a/internal/pkg/notify/notify.go b/internal/pkg/notify/notify.go deleted file mode 100644 index 6690e2cc..00000000 --- a/internal/pkg/notify/notify.go +++ /dev/null @@ -1,35 +0,0 @@ -package notify - -import ( - "fmt" - "os" - - "github.com/labstack/gommon/color" -) - -// Print prints a message to stdout. -func Print(message string) { - stdout := color.New() - stdout.SetOutput(os.Stdout) - stdout.Printf("⇨ %s\n", message) -} - -// Printf prints a formatted message to stdout. -func Printf(format string, a ...interface{}) { - message := fmt.Sprintf(format, a...) - Print(message) -} - -// WarnPrint prints a warning to stderr. -func WarnPrint(err error) { - stderr := color.New() - stderr.SetOutput(os.Stderr) - stderr.Printf("%s\n", color.Yellow(fmt.Sprintf("⇨ warn: %v", err))) -} - -// ErrPrint prints an error to stderr. -func ErrPrint(err error) { - stderr := color.New() - stderr.SetOutput(os.Stderr) - stderr.Printf("%s\n", color.Red(fmt.Sprintf("⇨ error: %v", err))) -} diff --git a/internal/pkg/pm2/chrome.go b/internal/pkg/pm2/chrome.go index bad71c3e..aa04d4f1 100644 --- a/internal/pkg/pm2/chrome.go +++ b/internal/pkg/pm2/chrome.go @@ -5,6 +5,7 @@ import ( "time" "github.com/mafredri/cdp/devtool" + log "github.com/thecodingmachine/gotenberg/internal/pkg/logger" ) const warmupTime = 10 * time.Second @@ -15,9 +16,9 @@ type chrome struct { // NewChrome returns a Google Chrome // headless process. -func NewChrome(debug bool) Process { +func NewChrome(logger *log.StandardLogger) Process { return &chrome{ - manager: &processManager{verbose: debug}, + manager: &processManager{logger: logger}, } } @@ -60,19 +61,18 @@ func (p *chrome) viable() bool { // check if Google Chrome is correctly running. ctx, cancel := context.WithCancel(context.Background()) defer cancel() - p.manager.notifyf(`%v: checking Chrome liveness via debug version endpoint -'http://localhost:9222/json/version'`, p.name()) + p.manager.logger.Debugf("%s: checking liveness via debug version endpoint http://localhost:9222/json/version", p.Fullname()) v, err := devtool.New("http://localhost:9222").Version(ctx) if err != nil { - p.manager.notifyf("%s: %s version endpoint returned error: %v", p.name(), p.Fullname(), err) + p.manager.logger.Debugf("%s: debug version endpoint returned error: %v", p.Fullname(), err) return false } - p.manager.notifyf("%s: %s returned version info: %+v", p.name(), p.Fullname(), *v) + p.manager.logger.Debugf("%s: debug version endpoint returned version info: %+v", p.Fullname(), *v) return true } func (p *chrome) warmup() { - p.manager.notifyf("%s: allowing %s %v to startup", p.name(), p.Fullname(), warmupTime) + p.manager.logger.Debugf("%s: allowing %v to startup", p.Fullname(), warmupTime) time.Sleep(warmupTime) } diff --git a/internal/pkg/pm2/pm2.go b/internal/pkg/pm2/pm2.go index fe2498d6..2850c9ac 100644 --- a/internal/pkg/pm2/pm2.go +++ b/internal/pkg/pm2/pm2.go @@ -6,9 +6,8 @@ import ( "io" "os/exec" "strings" - "time" - "github.com/thecodingmachine/gotenberg/internal/pkg/notify" + log "github.com/thecodingmachine/gotenberg/internal/pkg/logger" ) const ( @@ -31,7 +30,7 @@ type Process interface { type processManager struct { heuristicState int32 - verbose bool + logger *log.StandardLogger } func (m *processManager) start(p Process) error { @@ -83,43 +82,35 @@ func (m *processManager) pm2(p Process, cmdName string) error { "pm2", cmdArgs..., ) - m.notifyf("executing command '%v'", strings.Join(cmd.Args, " ")) - if m.verbose { - processStdErr, err := cmd.StderrPipe() - if err != nil { - return fmt.Errorf("failed getting stderr from %s: %s", p.Fullname(), err) - } - processStdOut, err := cmd.StdoutPipe() - if err != nil { - return fmt.Errorf("failed getting stdout from %s: %s", p.Fullname(), err) - } - readFromPipe := func(name string, reader io.ReadCloser) { - r := bufio.NewReader(reader) - defer reader.Close() // nolint: errcheck - for { - line, _, err := r.ReadLine() - if err != nil { - if err != io.EOF { - m.notifyf("error reading from %s for process %s", name, p.Fullname()) - } - break - } - if len(line) != 0 { - m.notifyf("%s %s: %s", p.name(), name, string(line)) + m.logger.Debugf("executing command: %v", strings.Join(cmd.Args, " ")) + processStdOut, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("failed getting stdout from %s: %s", p.Fullname(), err) + } + processStdErr, err := cmd.StderrPipe() + if err != nil { + return fmt.Errorf("failed getting stderr from %s: %s", p.Fullname(), err) + } + readFromPipe := func(outputType string, reader io.ReadCloser) { + r := bufio.NewReader(reader) + defer reader.Close() // nolint: errcheck + for { + line, _, err := r.ReadLine() + if err != nil { + if err != io.EOF { + m.logger.Errorf("error reading from %s for process %s", outputType, p.Fullname()) } + break + } + if len(line) != 0 { + m.logger.Debugf("%s %s: %s", p.Fullname(), outputType, string(line)) } } - go readFromPipe("stdout", processStdOut) - go readFromPipe("stderr", processStdErr) } + go readFromPipe("stdout", processStdOut) + go readFromPipe("stderr", processStdErr) if err := cmd.Start(); err != nil { return fmt.Errorf("%s %s with PM2: %v", cmdName, p.Fullname(), err) } return nil } - -func (m *processManager) notifyf(format string, args ...interface{}) { - if m.verbose { - notify.Printf(fmt.Sprintf("%v: %s", time.Now().Format(time.RFC3339), format), args...) - } -} diff --git a/internal/pkg/pm2/unoconv.go b/internal/pkg/pm2/unoconv.go index e7274f69..9350ab26 100644 --- a/internal/pkg/pm2/unoconv.go +++ b/internal/pkg/pm2/unoconv.go @@ -1,14 +1,18 @@ package pm2 +import ( + log "github.com/thecodingmachine/gotenberg/internal/pkg/logger" +) + type unoconv struct { manager *processManager } // NewUnoconv returns a unoconv listener // process. -func NewUnoconv(debug bool) Process { +func NewUnoconv(logger *log.StandardLogger) Process { return &unoconv{ - manager: &processManager{verbose: debug}, + manager: &processManager{logger: logger}, } } diff --git a/internal/pkg/printer/markdown.go b/internal/pkg/printer/markdown.go index 0bbaa518..9c84830c 100644 --- a/internal/pkg/printer/markdown.go +++ b/internal/pkg/printer/markdown.go @@ -7,9 +7,9 @@ import ( "io/ioutil" "path/filepath" + "github.com/labstack/gommon/random" "github.com/microcosm-cc/bluemonday" "github.com/russross/blackfriday/v2" - "github.com/thecodingmachine/gotenberg/internal/pkg/rand" ) // NewMarkdown returns a Markdown printer. @@ -27,10 +27,7 @@ func NewMarkdown(fpath string, opts *ChromeOptions) (Printer, error) { if err := tmpl.Execute(&buffer, data); err != nil { return nil, fmt.Errorf("%s: executing template: %v", fpath, err) } - baseFilename, err := rand.Get() - if err != nil { - return nil, err - } + baseFilename := random.String(32) dst := fmt.Sprintf("%s/%s.html", dirPath, baseFilename) if err := ioutil.WriteFile(dst, buffer.Bytes(), 0644); err != nil { return nil, fmt.Errorf("%s: writing file: %v", dst, err) diff --git a/internal/pkg/printer/office.go b/internal/pkg/printer/office.go index 6a104ccf..8e83824e 100644 --- a/internal/pkg/printer/office.go +++ b/internal/pkg/printer/office.go @@ -9,7 +9,7 @@ import ( "sync" "time" - "github.com/thecodingmachine/gotenberg/internal/pkg/rand" + "github.com/labstack/gommon/random" ) type office struct { @@ -38,10 +38,7 @@ func (p *office) Print(destination string) error { fpaths := make([]string, len(p.fpaths)) dirPath := filepath.Dir(destination) for i, fpath := range p.fpaths { - baseFilename, err := rand.Get() - if err != nil { - return err - } + baseFilename := random.String(32) tmpDest := fmt.Sprintf("%s/%d%s.pdf", dirPath, i, baseFilename) if err := unoconv(ctx, fpath, tmpDest, p.opts); err != nil { return err diff --git a/internal/pkg/rand/doc.go b/internal/pkg/rand/doc.go deleted file mode 100644 index 35fa53e7..00000000 --- a/internal/pkg/rand/doc.go +++ /dev/null @@ -1,7 +0,0 @@ -/* -Package rand helps generating a random string. - -It should be used for creating directory and -file names in order to avoid collision. -*/ -package rand diff --git a/internal/pkg/rand/rand.go b/internal/pkg/rand/rand.go deleted file mode 100644 index 6291b515..00000000 --- a/internal/pkg/rand/rand.go +++ /dev/null @@ -1,17 +0,0 @@ -package rand - -import ( - "crypto/rand" - "encoding/hex" - "fmt" -) - -// Get returns a random string. -func Get() (string, error) { - randBytes := make([]byte, 16) - _, err := rand.Read(randBytes) - if err != nil { - return "", fmt.Errorf("creating random string: %v", err) - } - return hex.EncodeToString(randBytes), nil -} diff --git a/internal/pkg/rand/rand_test.go b/internal/pkg/rand/rand_test.go deleted file mode 100644 index c171539c..00000000 --- a/internal/pkg/rand/rand_test.go +++ /dev/null @@ -1,16 +0,0 @@ -package rand - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestGet(t *testing.T) { - rand1, err := Get() - require.Nil(t, err) - rand2, err := Get() - require.Nil(t, err) - assert.NotEqual(t, rand1, rand2) -} From c8f7ea934c6599a17c7d5c0455f821546a7bab03 Mon Sep 17 00:00:00 2001 From: Julien Neuhart Date: Sun, 7 Jul 2019 17:45:07 +0200 Subject: [PATCH 11/34] wip refactoring: better logging and error systems --- go.mod | 24 +- go.sum | 74 ++-- internal/app/api/api.go | 29 +- internal/app/api/api_test.go | 128 ------ internal/app/api/handler.go | 155 -------- internal/app/api/handler_test.go | 360 ----------------- internal/app/api/middleware.go | 89 ----- internal/app/api/pkg/context/context.go | 137 +++++++ internal/app/api/pkg/context/doc.go | 3 + internal/app/api/pkg/handler/doc.go | 3 + internal/app/api/pkg/handler/handler.go | 151 +++++++ internal/app/api/pkg/handler/html.go | 24 ++ internal/app/api/pkg/handler/markdown.go | 27 ++ internal/app/api/pkg/handler/merge.go | 24 ++ internal/app/api/pkg/handler/office.go | 37 ++ internal/app/api/pkg/handler/ping.go | 10 + internal/app/api/pkg/handler/url.go | 25 ++ internal/app/api/pkg/middleware/cleanup.go | 38 ++ internal/app/api/pkg/middleware/context.go | 41 ++ internal/app/api/pkg/middleware/doc.go | 3 + internal/app/api/pkg/middleware/error.go | 43 ++ internal/app/api/pkg/middleware/logger.go | 21 + internal/app/api/pkg/resource/doc.go | 5 + internal/app/api/pkg/resource/resource.go | 417 ++++++++++++++++++++ internal/app/api/resource.go | 297 -------------- internal/pkg/config/config.go | 122 ++++++ internal/pkg/config/doc.go | 1 + internal/pkg/logger/doc.go | 1 + internal/pkg/logger/logger.go | 33 ++ internal/pkg/pm2/chrome.go | 9 +- internal/pkg/pm2/pm2.go | 4 +- internal/pkg/pm2/unoconv.go | 4 +- internal/pkg/random/doc.go | 3 + internal/pkg/random/random.go | 10 + internal/pkg/standarderror/doc.go | 5 + internal/pkg/standarderror/standarderror.go | 85 ++++ 36 files changed, 1346 insertions(+), 1096 deletions(-) delete mode 100644 internal/app/api/api_test.go delete mode 100644 internal/app/api/handler.go delete mode 100644 internal/app/api/handler_test.go delete mode 100644 internal/app/api/middleware.go create mode 100644 internal/app/api/pkg/context/context.go create mode 100644 internal/app/api/pkg/context/doc.go create mode 100644 internal/app/api/pkg/handler/doc.go create mode 100644 internal/app/api/pkg/handler/handler.go create mode 100644 internal/app/api/pkg/handler/html.go create mode 100644 internal/app/api/pkg/handler/markdown.go create mode 100644 internal/app/api/pkg/handler/merge.go create mode 100644 internal/app/api/pkg/handler/office.go create mode 100644 internal/app/api/pkg/handler/ping.go create mode 100644 internal/app/api/pkg/handler/url.go create mode 100644 internal/app/api/pkg/middleware/cleanup.go create mode 100644 internal/app/api/pkg/middleware/context.go create mode 100644 internal/app/api/pkg/middleware/doc.go create mode 100644 internal/app/api/pkg/middleware/error.go create mode 100644 internal/app/api/pkg/middleware/logger.go create mode 100644 internal/app/api/pkg/resource/doc.go create mode 100644 internal/app/api/pkg/resource/resource.go delete mode 100644 internal/app/api/resource.go create mode 100644 internal/pkg/config/config.go create mode 100644 internal/pkg/config/doc.go create mode 100644 internal/pkg/logger/doc.go create mode 100644 internal/pkg/logger/logger.go create mode 100644 internal/pkg/random/doc.go create mode 100644 internal/pkg/random/random.go create mode 100644 internal/pkg/standarderror/doc.go create mode 100644 internal/pkg/standarderror/standarderror.go diff --git a/go.mod b/go.mod index 85ced362..82700001 100644 --- a/go.mod +++ b/go.mod @@ -3,21 +3,19 @@ module github.com/thecodingmachine/gotenberg go 1.12 require ( - github.com/google/go-cmp v0.2.0 // indirect + github.com/google/go-cmp v0.3.0 // indirect github.com/gorilla/websocket v1.4.0 // indirect - github.com/labstack/echo/v4 v4.0.0 - github.com/labstack/gommon v0.2.8 - github.com/mafredri/cdp v0.22.0 - github.com/mattn/go-colorable v0.1.1 // indirect - github.com/mattn/go-isatty v0.0.7 // indirect - github.com/microcosm-cc/bluemonday v1.0.1 + github.com/konsorten/go-windows-terminal-sequences v1.0.2 // indirect + github.com/labstack/echo/v4 v4.1.6 + github.com/labstack/gommon v0.2.9 + github.com/mafredri/cdp v0.23.4 + github.com/microcosm-cc/bluemonday v1.0.2 github.com/russross/blackfriday/v2 v2.0.1 - github.com/shurcooL/sanitized_anchor_name v0.0.0-20170918181015-86672fcb3f95 // indirect + github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect github.com/sirupsen/logrus v1.4.2 github.com/stretchr/testify v1.3.0 - github.com/valyala/fasttemplate v1.0.1 // indirect - golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c // indirect - golang.org/x/net v0.0.0-20181201002055-351d144fa1fc // indirect - golang.org/x/sync v0.0.0-20181108010431-42b317875d0f - golang.org/x/sys v0.0.0-20190621062556-bf70e4678053 // indirect + golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4 // indirect + golang.org/x/net v0.0.0-20190628185345-da137c7871d7 // indirect + golang.org/x/sync v0.0.0-20190423024810-112230192c58 + golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb // indirect ) diff --git a/go.sum b/go.sum index 32ff4bc2..ececb92e 100644 --- a/go.sum +++ b/go.sum @@ -3,58 +3,66 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= -github.com/google/go-cmp v0.2.0 h1:+dTQ8DZQJz0Mb/HjFlkptS1FeQ4cWSnN941F8aEG4SQ= -github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0 h1:crn/baboCvb5fXaQ0IJ1SGTsTVrWpDsCWC8EGETZijY= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q= github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/labstack/echo/v4 v4.0.0 h1:q1GH+caIXPP7H2StPIdzy/ez9CO0EepqYeUg6vi9SWM= -github.com/labstack/echo/v4 v4.0.0/go.mod h1:tZv7nai5buKSg5h/8E6zz4LsD/Dqh9/91Mvs7Z5Zyno= -github.com/labstack/gommon v0.2.8 h1:JvRqmeZcfrHC5u6uVleB4NxxNbzx6gpbJiQknDbKQu0= -github.com/labstack/gommon v0.2.8/go.mod h1:/tj9csK2iPSBvn+3NLM9e52usepMtrd5ilFYA+wQNJ4= -github.com/mafredri/cdp v0.22.0 h1:BV17j8hXLDWczo2SZIAFuOjMpQMIOq5DOcd9sgB2hv0= -github.com/mafredri/cdp v0.22.0/go.mod h1:hgdiA0yp1uqhSaDOHJWPgXpMbh+LAfUdD9vbN2AM8gE= -github.com/mattn/go-colorable v0.0.9 h1:UVL0vNpWh04HeJXV0KLcaT7r06gOH2l4OW6ddYRUIY4= -github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= -github.com/mattn/go-colorable v0.1.1 h1:G1f5SKeVxmagw/IyvzvtZE4Gybcc4Tr1tf7I8z0XgOg= -github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= -github.com/mattn/go-isatty v0.0.4 h1:bnP0vzxcAdeI1zdubAl5PjU6zsERjGZb7raWodagDYs= -github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= -github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= -github.com/mattn/go-isatty v0.0.7 h1:UvyT9uN+3r7yLEYSlJsbQGdsaB/a0DlgWP3pql6iwOc= -github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= -github.com/microcosm-cc/bluemonday v1.0.1 h1:SIYunPjnlXcW+gVfvm0IlSeR5U3WZUOLfVmqg85Go44= -github.com/microcosm-cc/bluemonday v1.0.1/go.mod h1:hsXNsILzKxV+sX77C5b8FSuKF00vh2OMYv+xgHpAMF4= +github.com/konsorten/go-windows-terminal-sequences v1.0.2 h1:DB17ag19krx9CFsz4o3enTrPXyIXCl+2iCXH/aMAp9s= +github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/labstack/echo/v4 v4.1.6 h1:WOvLa4T1KzWCRpANwz0HGgWDelXSSGwIKtKBbFdHTv4= +github.com/labstack/echo/v4 v4.1.6/go.mod h1:kU/7PwzgNxZH4das4XNsSpBSOD09XIF5YEPzjpkGnGE= +github.com/labstack/gommon v0.2.9 h1:heVeuAYtevIQVYkGj6A41dtfT91LrvFG220lavpWhrU= +github.com/labstack/gommon v0.2.9/go.mod h1:E8ZTmW9vw5az5/ZyHWCp0Lw4OH2ecsaBP1C/NKavGG4= +github.com/mafredri/cdp v0.23.4 h1:ffp4qq6slfCL4rFWBDeRHapkLE776gER4tX5Z3LS8CY= +github.com/mafredri/cdp v0.23.4/go.mod h1:hgdiA0yp1uqhSaDOHJWPgXpMbh+LAfUdD9vbN2AM8gE= +github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU= +github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-isatty v0.0.8 h1:HLtExJ+uU2HOZ+wI0Tt5DtUDrx8yhUqDcp7fYERX4CE= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/microcosm-cc/bluemonday v1.0.2 h1:5lPfLTTAvAbtS0VqT+94yOtFnGfUWYyx0+iToC3Os3s= +github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/leAFZyRl6bYmGDlGc= 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/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/shurcooL/sanitized_anchor_name v0.0.0-20170918181015-86672fcb3f95 h1:/vdW8Cb7EXrkqWGufVMES1OH2sU9gKVb2n9/1y5NMBY= -github.com/shurcooL/sanitized_anchor_name v0.0.0-20170918181015-86672fcb3f95/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= 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.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 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/fasttemplate v0.0.0-20170224212429-dcecefd839c4 h1:gKMu1Bf6QINDnvyZuTaACm9ofY+PRh+5vFz4oxBZeF8= -github.com/valyala/fasttemplate v0.0.0-20170224212429-dcecefd839c4/go.mod h1:50wTf68f99/Zt14pr046Tgt3Lp2vLyFZKzbFXTOabXw= github.com/valyala/fasttemplate v1.0.1 h1:tY9CJiPnMXf1ERmG2EyK7gNUd+c6RKGD0IfU8WdUSz8= github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8= -golang.org/x/crypto v0.0.0-20190130090550-b01c7a725664/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c h1:Vj5n4GlwjmQteupaxJ9+0FNOmBrHfq7vN4btdGoDZgI= -golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/net v0.0.0-20181201002055-351d144fa1fc h1:a3CU5tJYVj92DY2LaA1kUkrsqD5/3mLDhx2NcNqyW+0= -golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/sync v0.0.0-20181108010431-42b317875d0f h1:Bl/8QSvNqXvPGPGXa2z5xUTmV7VDcZyvRZ+QQXkXTZQ= -golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20190129075346-302c3dd5f1cc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4 h1:HuIa8hRrWRSrqYzx1qI49NNxhdi2PrY7gxVSq1JjLDc= +golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190607181551-461777fb6f67/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190628185345-da137c7871d7 h1:rTIdg5QFRR7XCaK4LCjBiPbx8j4DQRpdYMnGn/bJUEU= +golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/sync v0.0.0-20190423024810-112230192c58 h1:8gQV6CLnAEikrhgkHFbMAEhagSSnXWGV915qUMm9mrU= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190621062556-bf70e4678053 h1:T0MJjz97TtCXa3ZNW2Oenb3KQWB91K965zMEbIJ4ThA= -golang.org/x/sys v0.0.0-20190621062556-bf70e4678053/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190602015325-4c4f7f33c9ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190609082536-301114b31cce/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb h1:fgwFCsaw9buMuxNd6+DQfAuSFqbNiQZpcgJQAgJsK6k= +golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190608022120-eacb66d2a7c3/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= diff --git a/internal/app/api/api.go b/internal/app/api/api.go index 23db1d6f..3a711782 100644 --- a/internal/app/api/api.go +++ b/internal/app/api/api.go @@ -2,32 +2,33 @@ package api import ( "github.com/labstack/echo/v4" - conf "github.com/thecodingmachine/gotenberg/internal/pkg/config" + "github.com/thecodingmachine/gotenberg/internal/app/api/pkg/handler" + "github.com/thecodingmachine/gotenberg/internal/app/api/pkg/middleware" + "github.com/thecodingmachine/gotenberg/internal/pkg/config" ) -const pingEndpoint = "/ping" - // New returns an API. -func New(config *conf.Config) *echo.Echo { +func New(config *config.Config) *echo.Echo { api := echo.New() api.HideBanner = true api.HidePort = true - api.Use(contextMiddleware(config)) - api.Use(loggingMiddleware()) - api.Use(finalizeMiddleware()) - api.GET(pingEndpoint, func(c echo.Context) error { return nil }) - api.POST("/merge", merge) + api.Use(middleware.Context(config)) + api.Use(middleware.Logger()) + api.Use(middleware.Cleanup()) + api.Use(middleware.Error()) + api.GET(handler.PingEndpoint, handler.Ping) + api.POST(handler.MergeEndpoint, handler.Merge) if !config.EnableChromeEndpoints() && !config.EnableUnoconvEndpoints() { return api } - g := api.Group("/convert") + g := api.Group(handler.ConvertGroupEndpoint) if config.EnableChromeEndpoints() { - g.POST("/html", convertHTML) - g.POST("/url", convertURL) - g.POST("/markdown", convertMarkdown) + g.POST(handler.HTMLEndpoint, handler.HTML) + g.POST(handler.URLEndpoint, handler.URL) + g.POST(handler.MarkdownEndpoint, handler.Markdown) } if config.EnableUnoconvEndpoints() { - g.POST("/office", convertOffice) + g.POST(handler.OfficeEndpoint, handler.Office) } return api } diff --git a/internal/app/api/api_test.go b/internal/app/api/api_test.go deleted file mode 100644 index 1f29b33c..00000000 --- a/internal/app/api/api_test.go +++ /dev/null @@ -1,128 +0,0 @@ -package api - -import ( - "net/http" - "net/http/httptest" - "testing" - - "github.com/labstack/echo/v4" - "github.com/thecodingmachine/gotenberg/test" -) - -func TestDefaultWaitTimeout(t *testing.T) { - opts := DefaultOptions() - opts.DefaultWaitTimeout = 0 - srv := New(opts) - // testing if timeout. - body, contentType := test.URLTestMultipartForm(t, nil) - req := httptest.NewRequest(http.MethodPost, "/convert/url", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusRequestTimeout, srv, req) - // testing if no timeout. - body, contentType = test.URLTestMultipartForm(t, map[string]string{waitTimeout: "10"}) - req = httptest.NewRequest(http.MethodPost, "/convert/url", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusOK, srv, req) -} - -func TestDisableChromeEndpoints(t *testing.T) { - opts := DefaultOptions() - opts.EnableChromeEndpoints = false - srv := New(opts) - // Ping. - req := httptest.NewRequest(http.MethodGet, "/ping", nil) - test.AssertStatusCode(t, http.StatusOK, srv, req) - // Merge. - body, contentType := test.PDFTestMultipartForm(t, nil) - req = httptest.NewRequest(http.MethodPost, "/convert/merge", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusOK, srv, req) - // HTML. - body, contentType = test.HTMLTestMultipartForm(t, nil) - req = httptest.NewRequest(http.MethodPost, "/convert/html", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusNotFound, srv, req) - // Markdown. - body, contentType = test.MarkdownTestMultipartForm(t, nil) - req = httptest.NewRequest(http.MethodPost, "/convert/markdown", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusNotFound, srv, req) - // URL. - body, contentType = test.URLTestMultipartForm(t, nil) - req = httptest.NewRequest(http.MethodPost, "/convert/url", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusNotFound, srv, req) - // Office. - body, contentType = test.OfficeTestMultipartForm(t, nil) - req = httptest.NewRequest(http.MethodPost, "/convert/office", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusOK, srv, req) -} - -func TestDisableUnoconvEndpoints(t *testing.T) { - opts := DefaultOptions() - opts.EnableUnoconvEndpoints = false - srv := New(opts) - // Ping. - req := httptest.NewRequest(http.MethodGet, "/ping", nil) - test.AssertStatusCode(t, http.StatusOK, srv, req) - // Merge. - body, contentType := test.PDFTestMultipartForm(t, nil) - req = httptest.NewRequest(http.MethodPost, "/convert/merge", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusOK, srv, req) - // HTML. - body, contentType = test.HTMLTestMultipartForm(t, nil) - req = httptest.NewRequest(http.MethodPost, "/convert/html", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusOK, srv, req) - // Markdown. - body, contentType = test.MarkdownTestMultipartForm(t, nil) - req = httptest.NewRequest(http.MethodPost, "/convert/markdown", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusOK, srv, req) - // URL. - body, contentType = test.URLTestMultipartForm(t, nil) - req = httptest.NewRequest(http.MethodPost, "/convert/url", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusOK, srv, req) - // Office. - body, contentType = test.OfficeTestMultipartForm(t, nil) - req = httptest.NewRequest(http.MethodPost, "/convert/office", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusNotFound, srv, req) -} -func TestDisableChromeAndUnoconvEndpoints(t *testing.T) { - opts := DefaultOptions() - opts.EnableChromeEndpoints = false - opts.EnableUnoconvEndpoints = false - srv := New(opts) - // Ping. - req := httptest.NewRequest(http.MethodGet, "/ping", nil) - test.AssertStatusCode(t, http.StatusOK, srv, req) - // Merge. - body, contentType := test.PDFTestMultipartForm(t, nil) - req = httptest.NewRequest(http.MethodPost, "/convert/merge", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusOK, srv, req) - // HTML. - body, contentType = test.HTMLTestMultipartForm(t, nil) - req = httptest.NewRequest(http.MethodPost, "/convert/html", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusNotFound, srv, req) - // Markdown. - body, contentType = test.MarkdownTestMultipartForm(t, nil) - req = httptest.NewRequest(http.MethodPost, "/convert/markdown", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusNotFound, srv, req) - // URL. - body, contentType = test.URLTestMultipartForm(t, nil) - req = httptest.NewRequest(http.MethodPost, "/convert/url", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusNotFound, srv, req) - // Office. - body, contentType = test.OfficeTestMultipartForm(t, nil) - req = httptest.NewRequest(http.MethodPost, "/convert/office", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusNotFound, srv, req) -} diff --git a/internal/app/api/handler.go b/internal/app/api/handler.go deleted file mode 100644 index 69e97763..00000000 --- a/internal/app/api/handler.go +++ /dev/null @@ -1,155 +0,0 @@ -package api - -import ( - "fmt" - "net/http" - "os" - - "github.com/labstack/echo/v4" - "github.com/labstack/gommon/random" - "github.com/thecodingmachine/gotenberg/internal/pkg/printer" -) - -type errBadRequest struct { - err error -} - -func (e *errBadRequest) Error() string { - return e.err.Error() -} - -func merge(c echo.Context) error { - ctx := c.(*resourceContext) - opts, err := ctx.resource.mergePrinterOptions(ctx.config.DefaultWaitTimeout()) - if err != nil { - return &errBadRequest{err} - } - fpaths, err := ctx.resource.fpaths(".pdf") - if err != nil { - return &errBadRequest{err} - } - p := printer.NewMerge(fpaths, opts) - return convert(ctx, p) -} - -func convertHTML(c echo.Context) error { - ctx := c.(*resourceContext) - opts, err := ctx.resource.chromePrinterOptions(ctx.config.DefaultWaitTimeout()) - if err != nil { - return &errBadRequest{err} - } - fpath, err := ctx.resource.fpath("index.html") - if err != nil { - return &errBadRequest{err} - } - p := printer.NewHTML(fpath, opts) - return convert(ctx, p) -} - -func convertMarkdown(c echo.Context) error { - ctx := c.(*resourceContext) - opts, err := ctx.resource.chromePrinterOptions(ctx.config.DefaultWaitTimeout()) - if err != nil { - return &errBadRequest{err} - } - fpath, err := ctx.resource.fpath("index.html") - if err != nil { - return &errBadRequest{err} - } - p, err := printer.NewMarkdown(fpath, opts) - if err != nil { - return err - } - return convert(ctx, p) -} - -func convertURL(c echo.Context) error { - ctx := c.(*resourceContext) - opts, err := ctx.resource.chromePrinterOptions(ctx.config.DefaultWaitTimeout()) - if err != nil { - return &errBadRequest{err} - } - remote, err := ctx.resource.get(remoteURL) - if err != nil { - return &errBadRequest{err} - } - p := printer.NewURL(remote, opts) - return convert(ctx, p) -} - -func convertOffice(c echo.Context) error { - ctx := c.(*resourceContext) - opts, err := ctx.resource.officePrinterOptions(ctx.config.DefaultWaitTimeout()) - if err != nil { - return &errBadRequest{err} - } - fpaths, err := ctx.resource.fpaths( - ".txt", - ".rtf", - ".fodt", - ".doc", - ".docx", - ".odt", - ".xls", - ".xlsx", - ".ods", - ".ppt", - ".pptx", - ".odp", - ) - if err != nil { - return &errBadRequest{err} - } - p := printer.NewOffice(fpaths, opts) - return convert(ctx, p) -} - -func convert(ctx *resourceContext, p printer.Printer) error { - baseFilename := random.String(32) - filename := fmt.Sprintf("%s.pdf", baseFilename) - fpath := fmt.Sprintf("%s/%s", ctx.resource.formFilesDirPath, filename) - // if no webhook URL given, run conversion - // and directly return the resulting PDF file - // or an error. - if !ctx.resource.has(webhookURL) { - if err := p.Print(fpath); err != nil { - return err - } - if !ctx.resource.has(resultFilename) { - return ctx.Attachment(fpath, filename) - } - filename, err := ctx.resource.get(resultFilename) - if err != nil { - return &errBadRequest{err} - } - return ctx.Attachment(fpath, filename) - } - // as a webhook URL has been given, we - // run the following lines in a goroutine so that - // it doesn't block. - go func() { - defer ctx.resource.close() // nolint: errcheck - if err := p.Print(fpath); err != nil { - ctx.logger.Error(err) - return - } - f, err := os.Open(fpath) - if err != nil { - ctx.logger.Error(err) - return - } - defer f.Close() // nolint: errcheck - webhook, err := ctx.resource.get(webhookURL) - if err != nil { - ctx.logger.Error(err) - return - } - resp, err := http.Post(webhook, "application/pdf", f) /* #nosec */ - if err != nil { - ctx.logger.Error(err) - return - } - defer resp.Body.Close() // nolint: errcheck - }() - return nil -} diff --git a/internal/app/api/handler_test.go b/internal/app/api/handler_test.go deleted file mode 100644 index 32cfd159..00000000 --- a/internal/app/api/handler_test.go +++ /dev/null @@ -1,360 +0,0 @@ -package api - -import ( - "errors" - "fmt" - "io/ioutil" - "net/http" - "net/http/httptest" - "testing" - - "github.com/labstack/echo/v4" - "github.com/stretchr/testify/assert" - "github.com/thecodingmachine/gotenberg/test" -) - -func TestMerge(t *testing.T) { - opts := DefaultOptions() - srv := New(opts) - // OK. - body, contentType := test.PDFTestMultipartForm(t, nil) - req := httptest.NewRequest(http.MethodPost, "/convert/merge", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusOK, srv, req) - // Bad request. - body, contentType = test.PDFTestMultipartForm(t, map[string]string{waitTimeout: "not a float"}) - req = httptest.NewRequest(http.MethodPost, "/convert/merge", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.URLTestMultipartForm(t, nil) - req = httptest.NewRequest(http.MethodPost, "/convert/merge", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - // Timeout. - body, contentType = test.PDFTestMultipartForm(t, map[string]string{waitTimeout: "0"}) - req = httptest.NewRequest(http.MethodPost, "/convert/merge", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusRequestTimeout, srv, req) -} - -func TestHTML(t *testing.T) { - opts := DefaultOptions() - srv := New(opts) - // OK. - body, contentType := test.HTMLTestMultipartForm(t, nil) - req := httptest.NewRequest(http.MethodPost, "/convert/html", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusOK, srv, req) - // Bad request. - body, contentType = test.HTMLTestMultipartForm(t, map[string]string{waitTimeout: "not a float"}) - req = httptest.NewRequest(http.MethodPost, "/convert/html", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.HTMLTestMultipartForm(t, map[string]string{waitDelay: "not a float"}) - req = httptest.NewRequest(http.MethodPost, "/convert/html", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.HTMLTestMultipartForm(t, map[string]string{paperWidth: "not a float"}) - req = httptest.NewRequest(http.MethodPost, "/convert/html", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.HTMLTestMultipartForm(t, map[string]string{paperHeight: "not a float"}) - req = httptest.NewRequest(http.MethodPost, "/convert/html", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.HTMLTestMultipartForm(t, map[string]string{marginTop: "not a float"}) - req = httptest.NewRequest(http.MethodPost, "/convert/html", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.HTMLTestMultipartForm(t, map[string]string{marginBottom: "not a float"}) - req = httptest.NewRequest(http.MethodPost, "/convert/html", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.HTMLTestMultipartForm(t, map[string]string{marginLeft: "not a float"}) - req = httptest.NewRequest(http.MethodPost, "/convert/html", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.HTMLTestMultipartForm(t, map[string]string{marginRight: "not a float"}) - req = httptest.NewRequest(http.MethodPost, "/convert/html", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.HTMLTestMultipartForm(t, map[string]string{landscape: "not a bool"}) - req = httptest.NewRequest(http.MethodPost, "/convert/html", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.URLTestMultipartForm(t, nil) - req = httptest.NewRequest(http.MethodPost, "/convert/html", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - // Timeout. - body, contentType = test.HTMLTestMultipartForm(t, map[string]string{waitTimeout: "0"}) - req = httptest.NewRequest(http.MethodPost, "/convert/html", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusRequestTimeout, srv, req) -} - -func TestMarkdown(t *testing.T) { - opts := DefaultOptions() - srv := New(opts) - // OK. - body, contentType := test.MarkdownTestMultipartForm(t, nil) - req := httptest.NewRequest(http.MethodPost, "/convert/markdown", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusOK, srv, req) - // Bad request. - body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{waitTimeout: "not a float"}) - req = httptest.NewRequest(http.MethodPost, "/convert/markdown", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{waitDelay: "not a float"}) - req = httptest.NewRequest(http.MethodPost, "/convert/markdown", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{paperWidth: "not a float"}) - req = httptest.NewRequest(http.MethodPost, "/convert/markdown", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{paperHeight: "not a float"}) - req = httptest.NewRequest(http.MethodPost, "/convert/markdown", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{marginTop: "not a float"}) - req = httptest.NewRequest(http.MethodPost, "/convert/markdown", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{marginBottom: "not a float"}) - req = httptest.NewRequest(http.MethodPost, "/convert/markdown", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{marginLeft: "not a float"}) - req = httptest.NewRequest(http.MethodPost, "/convert/markdown", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{marginRight: "not a float"}) - req = httptest.NewRequest(http.MethodPost, "/convert/markdown", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{landscape: "not a bool"}) - req = httptest.NewRequest(http.MethodPost, "/convert/markdown", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.URLTestMultipartForm(t, nil) - req = httptest.NewRequest(http.MethodPost, "/convert/markdown", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - // Timeout. - body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{waitTimeout: "0"}) - req = httptest.NewRequest(http.MethodPost, "/convert/markdown", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusRequestTimeout, srv, req) -} - -func TestURL(t *testing.T) { - opts := DefaultOptions() - srv := New(opts) - // OK. - body, contentType := test.URLTestMultipartForm(t, nil) - req := httptest.NewRequest(http.MethodPost, "/convert/url", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusOK, srv, req) - // Bad request. - body, contentType = test.URLTestMultipartForm(t, map[string]string{waitTimeout: "not a float"}) - req = httptest.NewRequest(http.MethodPost, "/convert/url", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.URLTestMultipartForm(t, map[string]string{waitDelay: "not a float"}) - req = httptest.NewRequest(http.MethodPost, "/convert/url", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.URLTestMultipartForm(t, map[string]string{paperWidth: "not a float"}) - req = httptest.NewRequest(http.MethodPost, "/convert/url", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.URLTestMultipartForm(t, map[string]string{paperHeight: "not a float"}) - req = httptest.NewRequest(http.MethodPost, "/convert/url", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.URLTestMultipartForm(t, map[string]string{marginTop: "not a float"}) - req = httptest.NewRequest(http.MethodPost, "/convert/url", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.URLTestMultipartForm(t, map[string]string{marginBottom: "not a float"}) - req = httptest.NewRequest(http.MethodPost, "/convert/url", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.URLTestMultipartForm(t, map[string]string{marginLeft: "not a float"}) - req = httptest.NewRequest(http.MethodPost, "/convert/url", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.URLTestMultipartForm(t, map[string]string{marginRight: "not a float"}) - req = httptest.NewRequest(http.MethodPost, "/convert/url", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.URLTestMultipartForm(t, map[string]string{landscape: "not a bool"}) - req = httptest.NewRequest(http.MethodPost, "/convert/url", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - // Timeout. - body, contentType = test.URLTestMultipartForm(t, map[string]string{waitTimeout: "0"}) - req = httptest.NewRequest(http.MethodPost, "/convert/url", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusRequestTimeout, srv, req) -} - -func TestOffice(t *testing.T) { - opts := DefaultOptions() - srv := New(opts) - // OK. - body, contentType := test.OfficeTestMultipartForm(t, nil) - req := httptest.NewRequest(http.MethodPost, "/convert/office", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusOK, srv, req) - // Bad request. - body, contentType = test.OfficeTestMultipartForm(t, map[string]string{waitTimeout: "not a float"}) - req = httptest.NewRequest(http.MethodPost, "/convert/office", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.OfficeTestMultipartForm(t, map[string]string{landscape: "not a bool"}) - req = httptest.NewRequest(http.MethodPost, "/convert/office", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.URLTestMultipartForm(t, nil) - req = httptest.NewRequest(http.MethodPost, "/convert/office", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - // Timeout. - body, contentType = test.OfficeTestMultipartForm(t, map[string]string{waitTimeout: "0"}) - req = httptest.NewRequest(http.MethodPost, "/convert/office", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusRequestTimeout, srv, req) -} - -func TestConcurrent(t *testing.T) { - opts := DefaultOptions() - opts.DefaultWaitTimeout = 30 - srv := New(opts) - // Merge. - test.AssertConcurrent( - t, - func() error { - body, contentType := test.MarkdownTestMultipartForm(t, nil) - req := httptest.NewRequest(http.MethodPost, "/convert/html", body) - req.Header.Set(echo.HeaderContentType, contentType) - rec := httptest.NewRecorder() - srv.ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - return fmt.Errorf("wrong status code: want %d got %d", http.StatusOK, rec.Code) - } - return nil - }, - 10, - ) - // HTML. - test.AssertConcurrent( - t, - func() error { - body, contentType := test.HTMLTestMultipartForm(t, nil) - req := httptest.NewRequest(http.MethodPost, "/convert/html", body) - req.Header.Set(echo.HeaderContentType, contentType) - rec := httptest.NewRecorder() - srv.ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - return fmt.Errorf("wrong status code: want %d got %d", http.StatusOK, rec.Code) - } - return nil - }, - 10, - ) - // Markdown. - test.AssertConcurrent( - t, - func() error { - body, contentType := test.MarkdownTestMultipartForm(t, nil) - req := httptest.NewRequest(http.MethodPost, "/convert/markdown", body) - req.Header.Set(echo.HeaderContentType, contentType) - rec := httptest.NewRecorder() - srv.ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - return fmt.Errorf("wrong status code: want %d got %d", http.StatusOK, rec.Code) - } - return nil - }, - 10, - ) - // URL. - test.AssertConcurrent( - t, - func() error { - body, contentType := test.URLTestMultipartForm(t, nil) - req := httptest.NewRequest(http.MethodPost, "/convert/url", body) - req.Header.Set(echo.HeaderContentType, contentType) - rec := httptest.NewRecorder() - srv.ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - return fmt.Errorf("wrong status code: want %d got %d", http.StatusOK, rec.Code) - } - return nil - }, - 10, - ) - // Office. - test.AssertConcurrent( - t, - func() error { - body, contentType := test.OfficeTestMultipartForm(t, nil) - req := httptest.NewRequest(http.MethodPost, "/convert/office", body) - req.Header.Set(echo.HeaderContentType, contentType) - rec := httptest.NewRecorder() - srv.ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - return fmt.Errorf("wrong status code: want %d got %d", http.StatusOK, rec.Code) - } - return nil - }, - 10, - ) -} - -func TestWebhook(t *testing.T) { - status := make(chan error, 2) - rcv := echo.New() - rcv.POST("/foo", func(c echo.Context) error { - if c.Request().Header.Get("Content-type") != "application/pdf" { - status <- fmt.Errorf("wrong Content-type: got %s want %s", c.Request().Header.Get("Content-type"), "application/pdf") - return nil - } - body, err := ioutil.ReadAll(c.Request().Body) - if err != nil { - status <- err - return nil - } - if body == nil || len(body) == 0 { - status <- errors.New("empty body") - return nil - } - status <- nil - return nil - }) - go func() { - rcv.Start(":3001") - }() - opts := DefaultOptions() - srv := New(opts) - body, contentType := test.PDFTestMultipartForm(t, map[string]string{webhookURL: "http://localhost:3001/foo"}) - req := httptest.NewRequest(http.MethodPost, "/convert/merge", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusOK, srv, req) - err := <-status - assert.NoError(t, err) -} - -func TestResultFilename(t *testing.T) { - opts := DefaultOptions() - srv := New(opts) - body, contentType := test.PDFTestMultipartForm(t, map[string]string{resultFilename: "foo.pdf"}) - req := httptest.NewRequest(http.MethodPost, "/convert/merge", body) - req.Header.Set(echo.HeaderContentType, contentType) - rec := httptest.NewRecorder() - srv.ServeHTTP(rec, req) - assert.Equal(t, "attachment; filename=\"foo.pdf\"", rec.Header().Get("Content-Disposition")) -} diff --git a/internal/app/api/middleware.go b/internal/app/api/middleware.go deleted file mode 100644 index 1cb35b97..00000000 --- a/internal/app/api/middleware.go +++ /dev/null @@ -1,89 +0,0 @@ -package api - -import ( - "context" - "net/http" - "strings" - - "github.com/labstack/echo/v4" - "github.com/labstack/gommon/random" - conf "github.com/thecodingmachine/gotenberg/internal/pkg/config" - log "github.com/thecodingmachine/gotenberg/internal/pkg/logger" -) - -func contextMiddleware(config *conf.Config) echo.MiddlewareFunc { - // middleware for extending the default context - // with one of our own context. - return func(next echo.HandlerFunc) echo.HandlerFunc { - return func(c echo.Context) error { - // generate a unique identifier for our request. - trace := random.String(32) - // create the logger for this request using - // the previous identifier as trace. - logger := log.New(config.LogLevel(), trace) - // extend the current echo context with our standard - // context. - ctx := newStandardContext(c, logger, config) - // if the endpoint is not for liveness, make a - // context with resource. - if ctx.Path() != pingEndpoint { - ctx, err := ctx.withResource(trace) - if err != nil { - ctx.Error(err) - return ctx.logEndOfRequest(err) - } - } - return next(ctx) - } - } -} - -func loggingMiddleware() echo.MiddlewareFunc { - // middleware for enabling logging. - return func(next echo.HandlerFunc) echo.HandlerFunc { - return func(c echo.Context) error { - ctx := c.(*standardContext) - err := next(ctx) - if err != nil { - ctx.Error(err) - } - return ctx.logEndOfRequest(err) - } - } -} - -func finalizeMiddleware() echo.MiddlewareFunc { - // middleware for removing resources at the end of a request - // and for improving response in case of error. - return func(next echo.HandlerFunc) echo.HandlerFunc { - return func(c echo.Context) error { - err := next(c) - ctx, ok := c.(*resourceContext) - // a resource is associated with the context. - if ok { - // if a webhookURL has been given, - // do not remove the resources here because - // we don't know if the result file has been - // generated or sent. - if !ctx.resource.has(webhookURL) { - if resourceErr := ctx.resource.close(); resourceErr != nil { - ctx.logger.Error(err) - } - } - } - if err == nil { - return nil - } - if _, ok := err.(*echo.HTTPError); ok { - return err - } - if _, ok := err.(*errBadRequest); ok { - return echo.NewHTTPError(http.StatusBadRequest, err.Error()) - } - if strings.Contains(err.Error(), context.DeadlineExceeded.Error()) { - return echo.NewHTTPError(http.StatusRequestTimeout, err.Error()) - } - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) - } - } -} diff --git a/internal/app/api/pkg/context/context.go b/internal/app/api/pkg/context/context.go new file mode 100644 index 00000000..3b298ffc --- /dev/null +++ b/internal/app/api/pkg/context/context.go @@ -0,0 +1,137 @@ +package context + +import ( + "net/http" + "strconv" + "time" + + "github.com/labstack/echo/v4" + "github.com/thecodingmachine/gotenberg/internal/app/api/pkg/resource" + "github.com/thecodingmachine/gotenberg/internal/pkg/config" + "github.com/thecodingmachine/gotenberg/internal/pkg/logger" + "github.com/thecodingmachine/gotenberg/internal/pkg/standarderror" +) + +// Context extends the default echo.Context. +type Context struct { + echo.Context + logger *logger.Logger + config *config.Config + resource *resource.Resource + startTime time.Time +} + +// New creates a new context. +func New(c echo.Context, logger *logger.Logger, config *config.Config) *Context { + // TODO timeout context? + return &Context{ + c, + logger, + config, + nil, + time.Now(), + } +} + +// MustCastFromEchoContext cast an echo.Context to our custom +// context. If something goes wrong, panic. +func MustCastFromEchoContext(c echo.Context) *Context { + ctx, ok := c.(*Context) + if !ok { + panic("unable to cast an echo.Context to a custom context") + } + return ctx +} + +// StandardLogger returns the custom logger. +// This method should be used instead of the +// default Logger() method coming from +// the echo.Context! +func (ctx *Context) StandardLogger() *logger.Logger { + return ctx.logger +} + +// Resource returns the associated resource +// to the context. +func (ctx *Context) Resource() *resource.Resource { + return ctx.resource +} + +// WithResource adds a resource to the context. +func (ctx *Context) WithResource(resourceDirPath string) error { + const op = "context.WithResource" + r, err := resource.New(ctx, ctx.logger, ctx.config, resourceDirPath) + ctx.resource = r + if err != nil { + return &standarderror.Error{ + Op: op, + Err: err, + } + } + return nil +} + +// LogRequestResult logs the result of a request. +// This method should only be used by a middleware! +func (ctx *Context) LogRequestResult(err error, isDebug bool) error { + req := ctx.Request() + resp := ctx.Response() + stopTime := time.Now() + fields := map[string]interface{}{ + "time_rfc3339": timeRFC3339(), // FIXME required? + "remote_ip": ctx.RealIP(), + "host": req.Host, + "uri": req.RequestURI, + "method": req.Method, + "path": path(req), + "referer": req.Referer(), + "user_agent": req.UserAgent(), + "status": resp.Status, + "latency": lantency(ctx.startTime, stopTime), + "latency_human": latencyHuman(ctx.startTime, stopTime), + "bytes_in": bytesIn(req), + "bytes_out": bytesOut(resp), + } + if err != nil { + ctx.logger.WithFields(fields).Error("request failed") + return err + } + if isDebug { + ctx.logger.WithFields(fields).Debug("request handled") + return nil + } + ctx.logger.WithFields(fields).Info("request handled") + return nil +} + +func timeRFC3339() string { + return time.Now().Format(time.RFC3339) +} + +func path(r *http.Request) string { + path := r.URL.Path + if path == "" { + path = "/" + } + return path +} + +func lantency(startTime time.Time, stopTime time.Time) string { + return strconv.FormatInt(int64(stopTime.Sub(startTime)), 10) +} + +func latencyHuman(startTime time.Time, stopTime time.Time) string { + return stopTime.Sub(startTime).String() +} + +func bytesIn(r *http.Request) string { + bytesIn := r.Header.Get(echo.HeaderContentLength) + if bytesIn == "" { + bytesIn = "0" + } + return bytesIn +} + +func bytesOut(r *echo.Response) string { + return strconv.FormatInt(r.Size, 10) +} diff --git a/internal/app/api/pkg/context/doc.go b/internal/app/api/pkg/context/doc.go new file mode 100644 index 00000000..4c8b2439 --- /dev/null +++ b/internal/app/api/pkg/context/doc.go @@ -0,0 +1,3 @@ +// Package context helps extending +// the default echo.Context. +package context diff --git a/internal/app/api/pkg/handler/doc.go b/internal/app/api/pkg/handler/doc.go new file mode 100644 index 00000000..c7fdafe7 --- /dev/null +++ b/internal/app/api/pkg/handler/doc.go @@ -0,0 +1,3 @@ +// Package handler contains all +// the endpoint methods of the API. +package handler diff --git a/internal/app/api/pkg/handler/handler.go b/internal/app/api/pkg/handler/handler.go new file mode 100644 index 00000000..c15b29ea --- /dev/null +++ b/internal/app/api/pkg/handler/handler.go @@ -0,0 +1,151 @@ +package handler + +import ( + "fmt" + "net/http" + "os" + + "github.com/thecodingmachine/gotenberg/internal/app/api/pkg/context" + "github.com/thecodingmachine/gotenberg/internal/app/api/pkg/resource" + "github.com/thecodingmachine/gotenberg/internal/pkg/printer" + "github.com/thecodingmachine/gotenberg/internal/pkg/random" + "github.com/thecodingmachine/gotenberg/internal/pkg/standarderror" +) + +const ( + // PingEndpoint is the route for healthcheck. + PingEndpoint = "/ping" + // MergeEndpoint is the route for merging PDF files. + MergeEndpoint = "/merge" + // ConvertGroupEndpoint is the route of the group + // in charge of converting files to PDF. + ConvertGroupEndpoint = "/convert" + // HTMLEndpoint is the route for converting + // HTML to PDF. + HTMLEndpoint = "/html" + // URLEndpoint is the route for converting + // a URL to PDF. + URLEndpoint = "/url" + // MarkdownEndpoint is the route for converting + // Markdown to PDF. + MarkdownEndpoint = "/markdown" + // OfficeEndpoint is the route for converting + // Office files to PDF. + OfficeEndpoint = "/office" +) + +func convert(ctx *context.Context, p printer.Printer) error { + const ( + op = "convert" + debugOp = "handler.convert" + ) + r := ctx.Resource() + logger := ctx.StandardLogger() + baseFilename := random.Get() + filename := fmt.Sprintf("%s.pdf", baseFilename) + fpath := fmt.Sprintf("%s/%s", r.DirPath(), filename) + // if no webhook URL given, run conversion + // and directly return the resulting PDF file + // or an error. + if !r.Has(resource.WebhookURLFormField) { + logger.DebugfOp(debugOp, "no '%s' found, converting synchronously", resource.WebhookURLFormField) + if err := convertSync(filename, fpath, ctx, p); err != nil { + return &standarderror.Error{Op: op, Err: err} + } + return nil + } + // as a webhook URL has been given, we + // run the following lines in a goroutine so that + // it doesn't block. + logger.DebugfOp(debugOp, "'%s' found, converting asynchronously", resource.WebhookURLFormField) + return convertAsync(filename, fpath, ctx, p) +} + +func convertSync(filename, fpath string, ctx *context.Context, p printer.Printer) error { + const ( + op = "convertSync" + debugOp = "handler.convertSync" + ) + r := ctx.Resource() + logger := ctx.StandardLogger() + if err := p.Print(fpath); err != nil { + return &standarderror.Error{Op: op, Err: err} + } + if !r.Has(resource.ResultFilenameFormField) { + logger.DebugfOp( + debugOp, + "no '%s' found, using generated filename '%s'", + resource.ResultFilenameFormField, + filename, + ) + if err := ctx.Attachment(fpath, filename); err != nil { + return &standarderror.Error{Op: op, Err: err} + } + return nil + } + logger.DebugfOp( + debugOp, + "'%s' found, so not using generated filename", + resource.ResultFilenameFormField, + ) + filename, err := r.Get(resource.ResultFilenameFormField) + if err != nil { + return &standarderror.Error{Op: op, Err: err} + } + if err := ctx.Attachment(fpath, filename); err != nil { + return &standarderror.Error{Op: op, Err: err} + } + return nil +} + +func convertAsync(filename, fpath string, ctx *context.Context, p printer.Printer) error { + const ( + op = "convertAsync" + debugOp = "handler.convertAsync" + ) + r := ctx.Resource() + logger := ctx.StandardLogger() + go func() { + defer r.Close() // nolint: errcheck + if err := p.Print(fpath); err != nil { + logger.ErrorOp( + op, + &standarderror.Error{Op: op, Err: err}, + ) + return + } + f, err := os.Open(fpath) + if err != nil { + logger.ErrorOp( + op, + &standarderror.Error{Op: op, Err: err}, + ) + return + } + defer f.Close() // nolint: errcheck + webhookURL, err := r.Get(resource.WebhookURLFormField) + if err != nil { + logger.ErrorOp( + op, + &standarderror.Error{Op: op, Err: err}, + ) + return + } + logger.DebugfOp( + debugOp, + "sending result file '%s' to '%s'", + filename, + webhookURL, + ) + resp, err := http.Post(webhookURL, "application/pdf", f) /* #nosec */ + if err != nil { + logger.ErrorOp( + op, + &standarderror.Error{Op: op, Err: err}, + ) + return + } + defer resp.Body.Close() // nolint: errcheck + }() + return nil +} diff --git a/internal/app/api/pkg/handler/html.go b/internal/app/api/pkg/handler/html.go new file mode 100644 index 00000000..d5850685 --- /dev/null +++ b/internal/app/api/pkg/handler/html.go @@ -0,0 +1,24 @@ +package handler + +import ( + "github.com/labstack/echo/v4" + "github.com/thecodingmachine/gotenberg/internal/app/api/pkg/context" + "github.com/thecodingmachine/gotenberg/internal/pkg/printer" +) + +// HTML is the endpoint for converting +// HTML to PDF. +func HTML(c echo.Context) error { + ctx := context.MustCastFromEchoContext(c) + r := ctx.Resource() + opts, err := r.ChromePrinterOptions() + if err != nil { + return err + } + fpath, err := r.Fpath("index.html") + if err != nil { + return err + } + p := printer.NewHTML(fpath, opts) + return convert(ctx, p) +} diff --git a/internal/app/api/pkg/handler/markdown.go b/internal/app/api/pkg/handler/markdown.go new file mode 100644 index 00000000..a4255fd2 --- /dev/null +++ b/internal/app/api/pkg/handler/markdown.go @@ -0,0 +1,27 @@ +package handler + +import ( + "github.com/labstack/echo/v4" + "github.com/thecodingmachine/gotenberg/internal/app/api/pkg/context" + "github.com/thecodingmachine/gotenberg/internal/pkg/printer" +) + +// Markdown is the endpoint for converting +// Markdown to PDF. +func Markdown(c echo.Context) error { + ctx := context.MustCastFromEchoContext(c) + r := ctx.Resource() + opts, err := r.ChromePrinterOptions() + if err != nil { + return err + } + fpath, err := r.Fpath("index.html") + if err != nil { + return err + } + p, err := printer.NewMarkdown(fpath, opts) + if err != nil { + return err + } + return convert(ctx, p) +} diff --git a/internal/app/api/pkg/handler/merge.go b/internal/app/api/pkg/handler/merge.go new file mode 100644 index 00000000..f9d1e5d1 --- /dev/null +++ b/internal/app/api/pkg/handler/merge.go @@ -0,0 +1,24 @@ +package handler + +import ( + "github.com/labstack/echo/v4" + "github.com/thecodingmachine/gotenberg/internal/app/api/pkg/context" + "github.com/thecodingmachine/gotenberg/internal/pkg/printer" +) + +// Merge is the endpoint for +// merging PDF files. +func Merge(c echo.Context) error { + ctx := context.MustCastFromEchoContext(c) + r := ctx.Resource() + opts, err := r.MergePrinterOptions() + if err != nil { + return err + } + fpaths, err := r.Fpaths(".pdf") + if err != nil { + return err + } + p := printer.NewMerge(fpaths, opts) + return convert(ctx, p) +} diff --git a/internal/app/api/pkg/handler/office.go b/internal/app/api/pkg/handler/office.go new file mode 100644 index 00000000..3cf5c315 --- /dev/null +++ b/internal/app/api/pkg/handler/office.go @@ -0,0 +1,37 @@ +package handler + +import ( + "github.com/labstack/echo/v4" + "github.com/thecodingmachine/gotenberg/internal/app/api/pkg/context" + "github.com/thecodingmachine/gotenberg/internal/pkg/printer" +) + +// Office is the endpoint for converting +// Office files to PDF. +func Office(c echo.Context) error { + ctx := context.MustCastFromEchoContext(c) + r := ctx.Resource() + opts, err := r.OfficePrinterOptions() + if err != nil { + return err + } + fpaths, err := r.Fpaths( + ".txt", + ".rtf", + ".fodt", + ".doc", + ".docx", + ".odt", + ".xls", + ".xlsx", + ".ods", + ".ppt", + ".pptx", + ".odp", + ) + if err != nil { + return err + } + p := printer.NewOffice(fpaths, opts) + return convert(ctx, p) +} diff --git a/internal/app/api/pkg/handler/ping.go b/internal/app/api/pkg/handler/ping.go new file mode 100644 index 00000000..c2fa275d --- /dev/null +++ b/internal/app/api/pkg/handler/ping.go @@ -0,0 +1,10 @@ +package handler + +import ( + "github.com/labstack/echo/v4" +) + +// Ping is the endpoint for healthcheck. +func Ping(c echo.Context) error { + return nil +} diff --git a/internal/app/api/pkg/handler/url.go b/internal/app/api/pkg/handler/url.go new file mode 100644 index 00000000..cf1bc75d --- /dev/null +++ b/internal/app/api/pkg/handler/url.go @@ -0,0 +1,25 @@ +package handler + +import ( + "github.com/labstack/echo/v4" + "github.com/thecodingmachine/gotenberg/internal/app/api/pkg/context" + "github.com/thecodingmachine/gotenberg/internal/app/api/pkg/resource" + "github.com/thecodingmachine/gotenberg/internal/pkg/printer" +) + +// URL is the endpoint for converting +// a URL to PDF. +func URL(c echo.Context) error { + ctx := context.MustCastFromEchoContext(c) + r := ctx.Resource() + opts, err := r.ChromePrinterOptions() + if err != nil { + return err + } + remoteURL, err := r.Get(resource.RemoteURLFormField) + if err != nil { + return err + } + p := printer.NewURL(remoteURL, opts) + return convert(ctx, p) +} diff --git a/internal/app/api/pkg/middleware/cleanup.go b/internal/app/api/pkg/middleware/cleanup.go new file mode 100644 index 00000000..ee21c9fb --- /dev/null +++ b/internal/app/api/pkg/middleware/cleanup.go @@ -0,0 +1,38 @@ +package middleware + +import ( + "github.com/labstack/echo/v4" + "github.com/thecodingmachine/gotenberg/internal/app/api/pkg/context" + "github.com/thecodingmachine/gotenberg/internal/app/api/pkg/resource" + "github.com/thecodingmachine/gotenberg/internal/pkg/standarderror" +) + +// Cleanup helps removing a resource at the end of a request. +func Cleanup() echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + const op = "middleware.Cleanup" + err := next(c) + ctx := context.MustCastFromEchoContext(c) + r := ctx.Resource() + if r == nil { + return err + } + // if a webhook URL has been given, + // do not remove the resource here because + // we don't know if the result file has been + // generated or sent. + if r.Has(resource.WebhookURLFormField) { + return err + } + // a resource is associated with our custom context. + if resourceErr := r.Close(); resourceErr != nil { + ctx.StandardLogger().ErrorOp(op, &standarderror.Error{ + Op: op, + Err: resourceErr, + }) + } + return err + } + } +} diff --git a/internal/app/api/pkg/middleware/context.go b/internal/app/api/pkg/middleware/context.go new file mode 100644 index 00000000..29dc26c0 --- /dev/null +++ b/internal/app/api/pkg/middleware/context.go @@ -0,0 +1,41 @@ +package middleware + +import ( + "github.com/labstack/echo/v4" + "github.com/thecodingmachine/gotenberg/internal/app/api/pkg/context" + "github.com/thecodingmachine/gotenberg/internal/app/api/pkg/handler" + "github.com/thecodingmachine/gotenberg/internal/pkg/config" + "github.com/thecodingmachine/gotenberg/internal/pkg/logger" + "github.com/thecodingmachine/gotenberg/internal/pkg/random" +) + +// Context helps extending the default echo.Context with +// our custom context. +func Context(config *config.Config) echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + // generate a unique identifier for the request. + trace := random.Get() + // create the logger for this request using + // the previous identifier as trace. + logger := logger.New(config.LogLevel(), trace) + // extend the current echo context with our custom + // context. + ctx := context.New(c, logger, config) + // if its an healthcheck request, there + // is no resource associated to it. + if ctx.Path() == handler.PingEndpoint { + return next(ctx) + } + // if the endpoint is not for healthcheck, associate a + // resource to our custom context. + if err := ctx.WithResource(trace); err != nil { + // required to have a correct status code + // in the logs. + ctx.Error(err) + return ctx.LogRequestResult(err, false) + } + return next(ctx) + } + } +} diff --git a/internal/app/api/pkg/middleware/doc.go b/internal/app/api/pkg/middleware/doc.go new file mode 100644 index 00000000..91719d08 --- /dev/null +++ b/internal/app/api/pkg/middleware/doc.go @@ -0,0 +1,3 @@ +// Package middleware contains the +// middleware of the API. +package middleware diff --git a/internal/app/api/pkg/middleware/error.go b/internal/app/api/pkg/middleware/error.go new file mode 100644 index 00000000..7cdb6199 --- /dev/null +++ b/internal/app/api/pkg/middleware/error.go @@ -0,0 +1,43 @@ +package middleware + +import ( + "net/http" + + "github.com/labstack/echo/v4" + "github.com/thecodingmachine/gotenberg/internal/app/api/pkg/context" + "github.com/thecodingmachine/gotenberg/internal/pkg/standarderror" +) + +// Error helps handling errors (if any). +func Error() echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + ctx := context.MustCastFromEchoContext(c) + err := next(ctx) + if err == nil { + // so far so good! + return nil + } + // we log the initial error before returning + // the HTTP error. + logger := ctx.StandardLogger() + logger.Error(err.Error()) + // handle our custom HTTP error. + var httpErr error + errCode := standarderror.Code(err) + errMessage := standarderror.Message(err) + switch errCode { + case standarderror.Invalid: + httpErr = echo.NewHTTPError(http.StatusBadRequest, errMessage) + case standarderror.Timeout: + httpErr = echo.NewHTTPError(http.StatusRequestTimeout, errMessage) + default: + httpErr = echo.NewHTTPError(http.StatusInternalServerError, errMessage) + } + // required to have a correct status code + // in the logs. + ctx.Error(httpErr) + return httpErr + } + } +} diff --git a/internal/app/api/pkg/middleware/logger.go b/internal/app/api/pkg/middleware/logger.go new file mode 100644 index 00000000..5fb2579e --- /dev/null +++ b/internal/app/api/pkg/middleware/logger.go @@ -0,0 +1,21 @@ +package middleware + +import ( + "github.com/labstack/echo/v4" + "github.com/thecodingmachine/gotenberg/internal/app/api/pkg/context" + "github.com/thecodingmachine/gotenberg/internal/app/api/pkg/handler" +) + +// Logger helps logging the result of a request. +func Logger() echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + ctx := context.MustCastFromEchoContext(c) + err := next(ctx) + // we do not want to log healthcheck requests if + // log level is not set to DEBUG. + isDebug := ctx.Path() == handler.PingEndpoint + return ctx.LogRequestResult(err, isDebug) + } + } +} diff --git a/internal/app/api/pkg/resource/doc.go b/internal/app/api/pkg/resource/doc.go new file mode 100644 index 00000000..56c149b0 --- /dev/null +++ b/internal/app/api/pkg/resource/doc.go @@ -0,0 +1,5 @@ +// Package resource helps creating a folder +// containing all uploaded files and the resulting +// PDF file. It also helps centralizing all +// the form values. +package resource diff --git a/internal/app/api/pkg/resource/resource.go b/internal/app/api/pkg/resource/resource.go new file mode 100644 index 00000000..985bd30d --- /dev/null +++ b/internal/app/api/pkg/resource/resource.go @@ -0,0 +1,417 @@ +package resource + +import ( + "fmt" + "io" + "io/ioutil" + "os" + "path/filepath" + "strconv" + + "github.com/labstack/echo/v4" + "github.com/thecodingmachine/gotenberg/internal/pkg/config" + "github.com/thecodingmachine/gotenberg/internal/pkg/logger" + "github.com/thecodingmachine/gotenberg/internal/pkg/printer" + "github.com/thecodingmachine/gotenberg/internal/pkg/standarderror" +) + +const ( + // ResultFilenameFormField contains the name + // of a form field. + ResultFilenameFormField string = "resultFilename" + // WaitTimeoutFormField contains the name + // of a form field. + WaitTimeoutFormField string = "waitTimeout" + // WebhookURLFormField contains the name + // of a form field. + WebhookURLFormField string = "webhookURL" + // RemoteURLFormField contains the name + // of a form field. + RemoteURLFormField string = "remoteURL" + // WaitDelayFormField contains the name + // of a form field. + WaitDelayFormField string = "waitDelay" + // PaperWidthFormField contains the name + // of a form field. + PaperWidthFormField string = "paperWidth" + // PaperHeightFormField contains the name + // of a form field. + PaperHeightFormField string = "paperHeight" + // MarginTopFormField contains the name + // of a form field. + MarginTopFormField string = "marginTop" + // MarginBottomFormField contains the name + // of a form field. + MarginBottomFormField string = "marginBottom" + // MarginLeftFormField contains the name + // of a form field. + MarginLeftFormField string = "marginLeft" + // MarginRightFormField contains the name + // of a form field. + MarginRightFormField string = "marginRight" + // LandscapeFormField contains the name + // of a form field. + LandscapeFormField string = "landscape" +) + +// Resource helps retrieving form values +// and form files from a request. +type Resource struct { + logger *logger.Logger + config *config.Config + formValues map[string]string + formFilesDirPath string +} + +// New creates a new resource. +func New(c echo.Context, logger *logger.Logger, config *config.Config, dirPath string) (*Resource, error) { + const op = "resource.New" + r := &Resource{ + logger: logger, + config: config, + formValues: formValues(c, logger), + formFilesDirPath: dirPath, + } + if err := os.MkdirAll(dirPath, 0755); err != nil { + return nil, &standarderror.Error{Op: op, Err: err} + } + r.logger.DebugfOp(op, "directory '%s' created", dirPath) + if err := formFiles(c, logger, dirPath); err != nil { + return r, &standarderror.Error{Op: op, Err: err} + } + return r, nil +} + +func formValues(c echo.Context, logger *logger.Logger) map[string]string { + const debugOp = "resource.formValues" + v := make(map[string]string) + v[ResultFilenameFormField] = c.FormValue(ResultFilenameFormField) + v[WaitTimeoutFormField] = c.FormValue(WaitTimeoutFormField) + v[WebhookURLFormField] = c.FormValue(WebhookURLFormField) + v[RemoteURLFormField] = c.FormValue(RemoteURLFormField) + v[WaitDelayFormField] = c.FormValue(WaitDelayFormField) + v[PaperWidthFormField] = c.FormValue(PaperWidthFormField) + v[PaperHeightFormField] = c.FormValue(PaperHeightFormField) + v[MarginTopFormField] = c.FormValue(MarginTopFormField) + v[MarginBottomFormField] = c.FormValue(MarginBottomFormField) + v[MarginLeftFormField] = c.FormValue(MarginLeftFormField) + v[MarginRightFormField] = c.FormValue(MarginRightFormField) + v[LandscapeFormField] = c.FormValue(LandscapeFormField) + logger.DebugfOp(debugOp, "%v", v) + return v +} + +func formFiles(c echo.Context, logger *logger.Logger, dirPath string) error { + const ( + op = "formFiles" + debugOp = "resource.formFiles" + ) + form, err := c.MultipartForm() + if err != nil { + return &standarderror.Error{Op: op, Err: err} + } + for _, files := range form.File { + for _, fh := range files { + in, err := fh.Open() + if err != nil { + return &standarderror.Error{Op: op, Err: err} + } + defer in.Close() // nolint: errcheck + fpath := fmt.Sprintf("%s/%s", dirPath, fh.Filename) + out, err := os.Create(fpath) + if err != nil { + return &standarderror.Error{Op: op, Err: err} + } + defer out.Close() // nolint: errcheck + if err := out.Chmod(0644); err != nil { + return &standarderror.Error{Op: op, Err: err} + } + if _, err := io.Copy(out, in); err != nil { + return &standarderror.Error{Op: op, Err: err} + } + if _, err := out.Seek(0, 0); err != nil { + return &standarderror.Error{Op: op, Err: err} + } + logger.DebugfOp(debugOp, "'%s' created", fh.Filename) + } + } + return nil +} + +// DirPath returns the directory +// path where are stored the form +// files and the resulting PDF file. +func (r *Resource) DirPath() string { + return r.formFilesDirPath +} + +// Close deletes the working directory of the +// resource if it exists. +func (r *Resource) Close() error { + const op = "resource.Close" + if _, err := os.Stat(r.formFilesDirPath); os.IsNotExist(err) { + r.logger.DebugfOp(op, "directory '%s' does not exist, nothing to remove", r.formFilesDirPath) + return nil + } + if err := os.RemoveAll(r.formFilesDirPath); err != nil { + return &standarderror.Error{Op: op, Err: err} + } + r.logger.DebugfOp(op, "directory '%s' removed", r.formFilesDirPath) + return nil +} + +const defaultHeaderFooterHTML string = "" + +// ChromePrinterOptions returns the Chrome printer options +// thanks to the form values and form files from the request +// plus the default values from the configuration. +func (r *Resource) ChromePrinterOptions() (*printer.ChromeOptions, error) { + const op = "resource.ChromePrinterOptions" + waitTimeout, err := r.float64(WaitTimeoutFormField, r.config.DefaultWaitTimeout()) + if err != nil { + return nil, &standarderror.Error{Op: op, Err: err} + } + waitDelay, err := r.float64(WaitDelayFormField, 0.0) + if err != nil { + return nil, &standarderror.Error{Op: op, Err: err} + } + headerHTML, err := r.content("header.html", defaultHeaderFooterHTML) + if err != nil { + return nil, &standarderror.Error{Op: op, Err: err} + } + footerHTML, err := r.content("footer.html", defaultHeaderFooterHTML) + if err != nil { + return nil, &standarderror.Error{Op: op, Err: err} + } + paperWidth, err := r.float64(PaperWidthFormField, 8.27) + if err != nil { + return nil, &standarderror.Error{Op: op, Err: err} + } + paperHeight, err := r.float64(PaperHeightFormField, 11.7) + if err != nil { + return nil, &standarderror.Error{Op: op, Err: err} + } + marginTop, err := r.float64(MarginTopFormField, 1) + if err != nil { + return nil, &standarderror.Error{Op: op, Err: err} + } + marginBottom, err := r.float64(MarginBottomFormField, 1) + if err != nil { + return nil, &standarderror.Error{Op: op, Err: err} + } + marginLeft, err := r.float64(MarginLeftFormField, 1) + if err != nil { + return nil, &standarderror.Error{Op: op, Err: err} + } + marginRight, err := r.float64(MarginRightFormField, 1) + if err != nil { + return nil, &standarderror.Error{Op: op, Err: err} + } + landscape, err := r.bool(LandscapeFormField, false) + if err != nil { + return nil, &standarderror.Error{Op: op, Err: err} + } + opts := &printer.ChromeOptions{ + WaitTimeout: waitTimeout, + WaitDelay: waitDelay, + HeaderHTML: headerHTML, + FooterHTML: footerHTML, + PaperWidth: paperWidth, + PaperHeight: paperHeight, + MarginTop: marginTop, + MarginBottom: marginBottom, + MarginLeft: marginLeft, + MarginRight: marginRight, + Landscape: landscape, + } + r.logger.DebugfOp(op, "%v", opts) + return opts, nil +} + +// OfficePrinterOptions returns the Office printer options +// thanks to the form values from the request +// plus the default values from the configuration. +func (r *Resource) OfficePrinterOptions() (*printer.OfficeOptions, error) { + const op = "resource.OfficePrinterOptions" + waitTimeout, err := r.float64(WaitTimeoutFormField, r.config.DefaultWaitTimeout()) + if err != nil { + return nil, &standarderror.Error{Op: op, Err: err} + } + landscape, err := r.bool(LandscapeFormField, false) + if err != nil { + return nil, &standarderror.Error{Op: op, Err: err} + } + opts := &printer.OfficeOptions{ + WaitTimeout: waitTimeout, + Landscape: landscape, + } + r.logger.DebugfOp(op, "%v", opts) + return opts, nil +} + +// MergePrinterOptions returns the merge printer options +// thanks to the form values from the request +// plus the default values from the configuration. +func (r *Resource) MergePrinterOptions() (*printer.MergeOptions, error) { + const op = "resource.MergePrinterOptions" + waitTimeout, err := r.float64(WaitTimeoutFormField, r.config.DefaultWaitTimeout()) + if err != nil { + return nil, &standarderror.Error{Op: op, Err: err} + } + opts := &printer.MergeOptions{ + WaitTimeout: waitTimeout, + } + r.logger.DebugfOp(op, "%v", opts) + return opts, nil +} + +// Has returns true if the resource +// contains the given form field and +// its value is not empty. +func (r *Resource) Has(formField string) bool { + v, ok := r.formValues[formField] + if ok { + ok = v != "" + } + return ok +} + +func (r *Resource) hasFile(filename string) bool { + fpath := fmt.Sprintf("%s/%s", r.formFilesDirPath, filename) + _, err := os.Stat(fpath) + return !os.IsNotExist(err) +} + +// Get returns the form field value. +func (r *Resource) Get(formField string) (string, error) { + const op = "resource.Get" + v, err := r.value(formField) + if err != nil { + return "", &standarderror.Error{Op: op, Err: err} + } + return v, nil +} + +func (r *Resource) value(formField string) (string, error) { + const op = "value" + v, ok := r.formValues[formField] + if !ok { + return "", &standarderror.Error{ + Code: standarderror.Invalid, + Message: fmt.Sprintf("'%s' does not exist", formField), + Op: op, + } + } + return v, nil +} + +func (r *Resource) float64(formField string, defaultValue float64) (float64, error) { + const op = "float64" + if !r.Has(formField) { + return defaultValue, nil + } + v, err := r.value(formField) + if err != nil { + return 0.0, &standarderror.Error{Op: op, Err: err} + } + f, err := strconv.ParseFloat(v, 64) + if err != nil { + return 0.0, &standarderror.Error{ + Code: standarderror.Invalid, + Message: fmt.Sprintf("'%s' is not a float", formField), + Op: op, + } + } + return f, nil +} + +func (r *Resource) bool(formField string, defaultValue bool) (bool, error) { + const op = "bool" + if !r.Has(formField) { + return defaultValue, nil + } + v, err := r.value(formField) + if err != nil { + return false, &standarderror.Error{Op: op, Err: err} + } + b, err := strconv.ParseBool(v) + if err != nil { + return false, &standarderror.Error{ + Code: standarderror.Invalid, + Message: fmt.Sprintf("'%s' is not a boolean", formField), + Op: op, + } + } + return b, nil +} + +// Fpath returns the path of the given filename. +// This filename should be the name of a form file. +func (r *Resource) Fpath(filename string) (string, error) { + const op = "resource.Fpath" + fpath := fmt.Sprintf("%s/%s", r.formFilesDirPath, filename) + _, err := os.Stat(fpath) + if os.IsNotExist(err) { + return "", &standarderror.Error{ + Code: standarderror.Invalid, + Message: fmt.Sprintf("file '%s' does not exist", filename), + Op: op, + } + } + absPath, err := filepath.Abs(fpath) + if err != nil { + return "", &standarderror.Error{Op: op, Err: err} + } + return absPath, nil +} + +func (r *Resource) content(filename string, defaultValue string) (string, error) { + const op = "content" + if !r.hasFile(filename) { + return defaultValue, nil + } + fpath, err := r.Fpath(filename) + if err != nil { + return "", &standarderror.Error{Op: op, Err: err} + } + b, err := ioutil.ReadFile(fpath) + if err != nil { + return "", &standarderror.Error{Op: op, Err: err} + } + return string(b), nil +} + +// Fpaths returns the list of files of the resource +// according to given file extensions. +func (r *Resource) Fpaths(exts ...string) ([]string, error) { + const op = "resource.Fpaths" + var fpaths []string + err := filepath.Walk(r.formFilesDirPath, func(path string, info os.FileInfo, _ error) error { + const walkOp = "filepath.Walk" + if info.IsDir() { + return nil + } + fpath, err := r.Fpath(info.Name()) + if err != nil { + return &standarderror.Error{Op: walkOp, Err: err} + } + for _, ext := range exts { + if filepath.Ext(fpath) == ext { + fpaths = append(fpaths, fpath) + return nil + } + } + return nil + }) + if err != nil { + return nil, &standarderror.Error{Op: op, Err: err} + } + if len(fpaths) == 0 { + return nil, &standarderror.Error{ + Code: standarderror.Invalid, + Message: fmt.Sprintf("no file found for extentions %v", exts), + Op: op, + } + } + return fpaths, nil +} diff --git a/internal/app/api/resource.go b/internal/app/api/resource.go deleted file mode 100644 index 5a359cee..00000000 --- a/internal/app/api/resource.go +++ /dev/null @@ -1,297 +0,0 @@ -package api - -import ( - "fmt" - "io" - "io/ioutil" - "os" - "path/filepath" - "strconv" - - "github.com/labstack/echo/v4" - "github.com/thecodingmachine/gotenberg/internal/pkg/printer" -) - -const ( - resultFilename string = "resultFilename" - waitTimeout string = "waitTimeout" - webhookURL string = "webhookURL" - remoteURL string = "remoteURL" - waitDelay string = "waitDelay" - paperWidth string = "paperWidth" - paperHeight string = "paperHeight" - marginTop string = "marginTop" - marginBottom string = "marginBottom" - marginLeft string = "marginLeft" - marginRight string = "marginRight" - landscape string = "landscape" -) - -type resource struct { - formValues map[string]string - formFilesDirPath string -} - -func newResource(c echo.Context, dirPath string) (*resource, error) { - r := &resource{ - formValues: formValues(c), - } - r.formFilesDirPath = dirPath - if err := os.MkdirAll(dirPath, 0755); err != nil { - return nil, fmt.Errorf("%s: making directory: %v", dirPath, err) - } - if err := formFiles(c, dirPath); err != nil { - return r, err - } - return r, nil -} - -func formValues(c echo.Context) map[string]string { - v := make(map[string]string) - v[resultFilename] = c.FormValue(resultFilename) - v[waitTimeout] = c.FormValue(waitTimeout) - v[webhookURL] = c.FormValue(webhookURL) - v[remoteURL] = c.FormValue(remoteURL) - v[waitDelay] = c.FormValue(waitDelay) - v[paperWidth] = c.FormValue(paperWidth) - v[paperHeight] = c.FormValue(paperHeight) - v[marginTop] = c.FormValue(marginTop) - v[marginBottom] = c.FormValue(marginBottom) - v[marginLeft] = c.FormValue(marginLeft) - v[marginRight] = c.FormValue(marginRight) - v[landscape] = c.FormValue(landscape) - return v -} - -func formFiles(c echo.Context, dirPath string) error { - form, err := c.MultipartForm() - if err != nil { - return fmt.Errorf("getting multipart form: %v", err) - } - for _, files := range form.File { - for _, fh := range files { - in, err := fh.Open() - if err != nil { - return fmt.Errorf("%s: opening file: %v", fh.Filename, err) - } - defer in.Close() // nolint: errcheck - fpath := fmt.Sprintf("%s/%s", dirPath, fh.Filename) - out, err := os.Create(fpath) - if err != nil { - return fmt.Errorf("%s: creating new file: %v", fpath, err) - } - defer out.Close() // nolint: errcheck - if err := out.Chmod(0644); err != nil { - return fmt.Errorf("%s: changing file mode: %v", fpath, err) - } - if _, err := io.Copy(out, in); err != nil { - return fmt.Errorf("%s: writing file: %v", fpath, err) - } - if _, err := out.Seek(0, 0); err != nil { - return fmt.Errorf("%s: resetting read pointer: %v", fpath, err) - } - } - } - return nil -} - -func (r *resource) close() error { - if _, err := os.Stat(r.formFilesDirPath); os.IsNotExist(err) { - return nil - } - return os.RemoveAll(r.formFilesDirPath) -} - -const defaultHeaderFooterHTML string = "" - -func (r *resource) chromePrinterOptions(defaultWaitTimeout float64) (*printer.ChromeOptions, error) { - timeout, err := r.float64(waitTimeout, defaultWaitTimeout) - if err != nil { - return nil, err - } - delay, err := r.float64(waitDelay, 0.0) - if err != nil { - return nil, err - } - header, err := r.content("header.html", defaultHeaderFooterHTML) - if err != nil { - return nil, err - } - footer, err := r.content("footer.html", defaultHeaderFooterHTML) - if err != nil { - return nil, err - } - width, err := r.float64(paperWidth, 8.27) - if err != nil { - return nil, err - } - height, err := r.float64(paperHeight, 11.7) - if err != nil { - return nil, err - } - top, err := r.float64(marginTop, 1) - if err != nil { - return nil, err - } - bottom, err := r.float64(marginBottom, 1) - if err != nil { - return nil, err - } - left, err := r.float64(marginLeft, 1) - if err != nil { - return nil, err - } - right, err := r.float64(marginRight, 1) - if err != nil { - return nil, err - } - landscape, err := r.bool(landscape, false) - if err != nil { - return nil, err - } - return &printer.ChromeOptions{ - WaitTimeout: timeout, - WaitDelay: delay, - HeaderHTML: header, - FooterHTML: footer, - PaperWidth: width, - PaperHeight: height, - MarginTop: top, - MarginBottom: bottom, - MarginLeft: left, - MarginRight: right, - Landscape: landscape, - }, nil -} - -func (r *resource) officePrinterOptions(defaultWaitTimeout float64) (*printer.OfficeOptions, error) { - timeout, err := r.float64(waitTimeout, defaultWaitTimeout) - if err != nil { - return nil, err - } - landscape, err := r.bool(landscape, false) - if err != nil { - return nil, err - } - return &printer.OfficeOptions{ - WaitTimeout: timeout, - Landscape: landscape, - }, nil -} - -func (r *resource) mergePrinterOptions(defaultWaitTimeout float64) (*printer.MergeOptions, error) { - timeout, err := r.float64(waitTimeout, defaultWaitTimeout) - if err != nil { - return nil, err - } - return &printer.MergeOptions{ - WaitTimeout: timeout, - }, nil -} - -func (r *resource) has(key string) bool { - v, ok := r.formValues[key] - if ok { - ok = v != "" - } - return ok -} - -func (r *resource) hasFile(filename string) bool { - fpath := fmt.Sprintf("%s/%s", r.formFilesDirPath, filename) - _, err := os.Stat(fpath) - return !os.IsNotExist(err) -} - -func (r *resource) get(key string) (string, error) { - v, ok := r.formValues[key] - if !ok { - return "", fmt.Errorf("form value %s does not exist", key) - } - return v, nil -} - -func (r *resource) float64(key string, defaultValue float64) (float64, error) { - if !r.has(key) { - return defaultValue, nil - } - v, err := r.get(key) - if err != nil { - return 0.0, err - } - f, err := strconv.ParseFloat(v, 64) - if err != nil { - return 0.0, fmt.Errorf("form value %s: %v", key, err) - } - return f, nil -} - -func (r *resource) bool(key string, defaultValue bool) (bool, error) { - if !r.has(key) { - return defaultValue, nil - } - v, err := r.get(key) - if err != nil { - return false, err - } - b, err := strconv.ParseBool(v) - if err != nil { - return false, fmt.Errorf("form value %s: %v", key, err) - } - return b, nil -} - -func (r *resource) fpath(filename string) (string, error) { - fpath := fmt.Sprintf("%s/%s", r.formFilesDirPath, filename) - _, err := os.Stat(fpath) - if os.IsNotExist(err) { - return "", fmt.Errorf("%s: form file does not exist", filename) - } - absPath, err := filepath.Abs(fpath) - if err != nil { - return "", fmt.Errorf("%s: getting absolute path: %v", fpath, err) - } - return absPath, nil -} - -func (r *resource) content(filename string, defaultValue string) (string, error) { - if !r.hasFile(filename) { - return defaultValue, nil - } - fpath, err := r.fpath(filename) - if err != nil { - return "", err - } - b, err := ioutil.ReadFile(fpath) - if err != nil { - return "", fmt.Errorf("%s: reading form file: %v", fpath, err) - } - return string(b), nil -} - -func (r *resource) fpaths(exts ...string) ([]string, error) { - var fpaths []string - err := filepath.Walk(r.formFilesDirPath, func(path string, info os.FileInfo, _ error) error { - if info.IsDir() { - return nil - } - fpath, err := r.fpath(info.Name()) - if err != nil { - return err - } - for _, ext := range exts { - if filepath.Ext(fpath) == ext { - fpaths = append(fpaths, fpath) - return nil - } - } - return nil - }) - if err != nil { - return nil, err - } - if len(fpaths) == 0 { - return nil, fmt.Errorf("no form files found for extensions: %v", exts) - } - return fpaths, nil -} diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go new file mode 100644 index 00000000..56dca33d --- /dev/null +++ b/internal/pkg/config/config.go @@ -0,0 +1,122 @@ +package config + +import ( + "fmt" + "os" + "strconv" + + log "github.com/sirupsen/logrus" +) + +const ( + defaultWaitTimeoutEnvVar = "DEFAULT_WAIT_TIMEOUT" + defaultListenPortEnvVar = "DEFAULT_LISTEN_PORT" + disableGoogleChromeEnvVar = "DISABLE_GOOGLE_CHROME" + disableUnoconvEnvVar = "DISABLE_UNOCONV" + logLevelEnvVar = "LOG_LEVEL" +) + +type Config struct { + defaultWaitTimeout float64 + defaultListenPort string + enableChromeEndpoints bool + enableUnoconvEndpoints bool + logLevel log.Level +} + +func defaultConfig() *Config { + return &Config{ + defaultWaitTimeout: 10, + defaultListenPort: "3000", + enableChromeEndpoints: true, + enableUnoconvEndpoints: true, + logLevel: log.InfoLevel, + } +} + +func FromEnv() (*Config, error) { + c := defaultConfig() + defaultWaitTimeout, err := defaultWaitTimeoutFromEnv(defaultWaitTimeoutEnvVar, c.DefaultWaitTimeout()) + c.defaultWaitTimeout = defaultWaitTimeout + if err != nil { + return c, err + } + defaultListenPort, err := defaultListenPortFromEnv(defaultListenPortEnvVar, c.DefaultListenPort()) + c.defaultListenPort = defaultListenPort + if err != nil { + return c, err + } + disableChromeEndpoints, err := boolFromEnv(disableGoogleChromeEnvVar, c.EnableChromeEndpoints()) + c.enableChromeEndpoints = !disableChromeEndpoints + if err != nil { + return c, err + } + disableUnoconvEndpoints, err := boolFromEnv(disableUnoconvEnvVar, c.EnableUnoconvEndpoints()) + c.enableUnoconvEndpoints = !disableUnoconvEndpoints + if err != nil { + return c, err + } + logLevel, err := logLevelFromEnv(logLevelEnvVar, c.LogLevel()) + c.logLevel = logLevel + if err != nil { + return c, err + } + return c, nil +} + +func (c *Config) DefaultWaitTimeout() float64 { return c.defaultWaitTimeout } +func (c *Config) DefaultListenPort() string { return c.defaultListenPort } +func (c *Config) EnableChromeEndpoints() bool { return c.enableChromeEndpoints } +func (c *Config) EnableUnoconvEndpoints() bool { return c.enableUnoconvEndpoints } +func (c *Config) LogLevel() log.Level { return c.logLevel } + +func defaultWaitTimeoutFromEnv(envVar string, defaultValue float64) (float64, error) { + if v, ok := os.LookupEnv(envVar); ok { + waitTimeout, err := strconv.ParseFloat(v, 64) + if err != nil { + return defaultValue, fmt.Errorf("%s: wrong value: want float got %v", envVar, err) + } + return waitTimeout, nil + } + return defaultValue, nil +} + +func defaultListenPortFromEnv(envVar string, defaultValue string) (string, error) { + if v, ok := os.LookupEnv(envVar); ok { + portAsUint, err := strconv.ParseUint(v, 10, 64) + if err != nil { + return defaultValue, fmt.Errorf("%s: wrong value: want uint got %v", envVar, err) + } + if portAsUint > 65535 { + return defaultValue, fmt.Errorf("%s: wrong value: want uint < 65535 got %d", envVar, portAsUint) + } + return v, nil + } + return defaultValue, nil +} + +func boolFromEnv(envVar string, defaultValue bool) (bool, error) { + if v, ok := os.LookupEnv(envVar); ok { + if v != "1" && v != "0" { + return defaultValue, fmt.Errorf("%s: wrong value: want \"0\" or \"1\" got %s", envVar, v) + } + return v == "1", nil + } + return defaultValue, nil +} + +func logLevelFromEnv(envVar string, defaultValue log.Level) (log.Level, error) { + if v, ok := os.LookupEnv(envVar); ok { + switch v { + case "DEBUG": + return log.DebugLevel, nil + case "INFO": + return log.InfoLevel, nil + case "ERROR": + return log.ErrorLevel, nil + default: + return defaultValue, fmt.Errorf("%s: wrong value: want \"DEBUG\",\"INFO\" or \"ERROR\" got %s", envVar, v) + } + } + return defaultValue, nil +} diff --git a/internal/pkg/config/doc.go b/internal/pkg/config/doc.go new file mode 100644 index 00000000..d912156b --- /dev/null +++ b/internal/pkg/config/doc.go @@ -0,0 +1 @@ +package config diff --git a/internal/pkg/logger/doc.go b/internal/pkg/logger/doc.go new file mode 100644 index 00000000..90c66f62 --- /dev/null +++ b/internal/pkg/logger/doc.go @@ -0,0 +1 @@ +package logger diff --git a/internal/pkg/logger/logger.go b/internal/pkg/logger/logger.go new file mode 100644 index 00000000..cd290564 --- /dev/null +++ b/internal/pkg/logger/logger.go @@ -0,0 +1,33 @@ +package logger + +import ( + "github.com/sirupsen/logrus" +) + +// Logger enforces specific log message formats. +type Logger struct { + *logrus.Entry +} + +// New initializes the logger. +func New(level logrus.Level, trace string) *Logger { + l := logrus.New() + l.SetLevel(level) + // TODO no formatter if TTY. + l.SetFormatter(&logrus.JSONFormatter{}) + return &Logger{ + l.WithField("trace", trace), + } +} + +// DebugfOp logs a debug message for given +// logical operation. +func (l *Logger) DebugfOp(op string, format string, args ...interface{}) { + l.WithField("op", op).Debugf(format, args...) +} + +// ErrorOp logs an error message for given +// logical operation. +func (l *Logger) ErrorOp(op string, err error) { + l.WithField("op", op).Error(err.Error()) +} diff --git a/internal/pkg/pm2/chrome.go b/internal/pkg/pm2/chrome.go index aa04d4f1..df87a4e5 100644 --- a/internal/pkg/pm2/chrome.go +++ b/internal/pkg/pm2/chrome.go @@ -5,7 +5,7 @@ import ( "time" "github.com/mafredri/cdp/devtool" - log "github.com/thecodingmachine/gotenberg/internal/pkg/logger" + "github.com/thecodingmachine/gotenberg/internal/pkg/logger" ) const warmupTime = 10 * time.Second @@ -16,7 +16,7 @@ type chrome struct { // NewChrome returns a Google Chrome // headless process. -func NewChrome(logger *log.StandardLogger) Process { +func NewChrome(logger *logger.Logger) Process { return &chrome{ manager: &processManager{logger: logger}, } @@ -61,7 +61,10 @@ func (p *chrome) viable() bool { // check if Google Chrome is correctly running. ctx, cancel := context.WithCancel(context.Background()) defer cancel() - p.manager.logger.Debugf("%s: checking liveness via debug version endpoint http://localhost:9222/json/version", p.Fullname()) + p.manager.logger.Debugf( + "%s: checking liveness via debug version endpoint http://localhost:9222/json/version", + p.Fullname(), + ) v, err := devtool.New("http://localhost:9222").Version(ctx) if err != nil { p.manager.logger.Debugf("%s: debug version endpoint returned error: %v", p.Fullname(), err) diff --git a/internal/pkg/pm2/pm2.go b/internal/pkg/pm2/pm2.go index 2850c9ac..c53709fa 100644 --- a/internal/pkg/pm2/pm2.go +++ b/internal/pkg/pm2/pm2.go @@ -7,7 +7,7 @@ import ( "os/exec" "strings" - log "github.com/thecodingmachine/gotenberg/internal/pkg/logger" + "github.com/thecodingmachine/gotenberg/internal/pkg/logger" ) const ( @@ -30,7 +30,7 @@ type Process interface { type processManager struct { heuristicState int32 - logger *log.StandardLogger + logger *logger.Logger } func (m *processManager) start(p Process) error { diff --git a/internal/pkg/pm2/unoconv.go b/internal/pkg/pm2/unoconv.go index 9350ab26..2bd53745 100644 --- a/internal/pkg/pm2/unoconv.go +++ b/internal/pkg/pm2/unoconv.go @@ -1,7 +1,7 @@ package pm2 import ( - log "github.com/thecodingmachine/gotenberg/internal/pkg/logger" + "github.com/thecodingmachine/gotenberg/internal/pkg/logger" ) type unoconv struct { @@ -10,7 +10,7 @@ type unoconv struct { // NewUnoconv returns a unoconv listener // process. -func NewUnoconv(logger *log.StandardLogger) Process { +func NewUnoconv(logger *logger.Logger) Process { return &unoconv{ manager: &processManager{logger: logger}, } diff --git a/internal/pkg/random/doc.go b/internal/pkg/random/doc.go new file mode 100644 index 00000000..d79456c1 --- /dev/null +++ b/internal/pkg/random/doc.go @@ -0,0 +1,3 @@ +// Package random helps generating +// a random string. +package random diff --git a/internal/pkg/random/random.go b/internal/pkg/random/random.go new file mode 100644 index 00000000..07556b5b --- /dev/null +++ b/internal/pkg/random/random.go @@ -0,0 +1,10 @@ +package random + +import ( + "github.com/labstack/gommon/random" +) + +// Get returns a random string. +func Get() string { + return random.String(32) +} diff --git a/internal/pkg/standarderror/doc.go b/internal/pkg/standarderror/doc.go new file mode 100644 index 00000000..b834229b --- /dev/null +++ b/internal/pkg/standarderror/doc.go @@ -0,0 +1,5 @@ +// Package standarderror helps standardizing +// the errors in the application. +// +// Credits: https://middlemost.com/failure-is-your-domain/ +package standarderror diff --git a/internal/pkg/standarderror/standarderror.go b/internal/pkg/standarderror/standarderror.go new file mode 100644 index 00000000..f77bbe58 --- /dev/null +++ b/internal/pkg/standarderror/standarderror.go @@ -0,0 +1,85 @@ +package standarderror + +import ( + "bytes" + "fmt" +) + +const ( + // Internal is a code + // for internal errors. + Internal = "internal" + // Invalid is a code + // for validation errors. + Invalid = "invalid" + // Timeout is a code + // for timeout errors. + Timeout = "timeout" +) + +// Error defines a standard application +// error. +type Error struct { + // Code is a machine-readable + // error code. + Code string + // Message is a human-readable + // message. + Message string + // Op is a logical operation. + Op string + // Err is a nested error. + Err error +} + +// Error returns the string representation of the error message. +func (err *Error) Error() string { + var buf bytes.Buffer + // print the current operation in our stack, if any. + if err.Op != "" { + fmt.Fprintf(&buf, "%s: ", err.Op) + } + // if wrapping an error, print its Error() message. + // Otherwise print the error code & message. + if err.Err != nil { + buf.WriteString(err.Err.Error()) + } else { + if err.Code != "" { + fmt.Fprintf(&buf, "<%s> ", err.Code) + } + buf.WriteString(err.Message) + } + return buf.String() +} + +// Code returns the code of the root error, if available. +// Otherwise returns Internal. +func Code(err error) string { + if err == nil { + return "" + } + e, ok := err.(*Error) + if ok && e.Code != "" { + return e.Code + } + if ok && e.Err != nil { + return Code(e.Err) + } + return Internal +} + +// Message returns the human-readable message of the error, if available. +// Otherwise returns a generic error message. +func Message(err error) string { + if err == nil { + return "" + } + e, ok := err.(*Error) + if ok && e.Message != "" { + return e.Message + } + if ok && e.Err != nil { + return Message(e.Err) + } + return "An internal error has occurred. Please contact technical support." +} From 5f6ab2fc087cf28c6404def3faece30f3545616b Mon Sep 17 00:00:00 2001 From: Julien Neuhart Date: Sun, 7 Jul 2019 19:54:44 +0200 Subject: [PATCH 12/34] all logs now have an op field --- build/lint/Dockerfile | 2 +- build/tests/Dockerfile | 2 +- cmd/gotenberg/main.go | 31 +++--- go.mod | 1 + internal/app/api/pkg/context/context.go | 12 +-- internal/app/api/pkg/middleware/error.go | 3 +- internal/app/api/pkg/resource/resource.go | 4 +- internal/pkg/config/config.go | 100 +++++++++++++++----- internal/pkg/config/doc.go | 2 + internal/pkg/logger/doc.go | 2 + internal/pkg/logger/logger.go | 44 +++++++-- internal/pkg/pm2/chrome.go | 39 ++++++-- internal/pkg/pm2/pm2.go | 28 ++++-- internal/pkg/pm2/unoconv.go | 13 ++- internal/pkg/standarderror/doc.go | 10 +- internal/pkg/standarderror/standarderror.go | 32 ++++++- 16 files changed, 240 insertions(+), 85 deletions(-) diff --git a/build/lint/Dockerfile b/build/lint/Dockerfile index 869447e6..87fc8096 100644 --- a/build/lint/Dockerfile +++ b/build/lint/Dockerfile @@ -22,7 +22,7 @@ RUN curl -sfL https://install.goreleaser.com/github.com/golangci/golangci-lint.s # | Last instructions of this build. # | -# Define our workding outside of $GOPATH (we're using go modules). +# Define our working directory outside of $GOPATH (we're using go modules). WORKDIR /lint # Copy our module dependencies definitions. diff --git a/build/tests/Dockerfile b/build/tests/Dockerfile index 0c8b7c32..7aaa9586 100644 --- a/build/tests/Dockerfile +++ b/build/tests/Dockerfile @@ -35,7 +35,7 @@ ENV PATH $GOPATH/bin:/usr/local/go/bin:$PATH # | Last instructions of this build. # | -# Define our workding outside of $GOPATH (we're using go modules). +# Define our working directory outside of $GOPATH (we're using go modules). WORKDIR /tests # Copy our module dependencies definitions. diff --git a/cmd/gotenberg/main.go b/cmd/gotenberg/main.go index c3382512..28a8e976 100644 --- a/cmd/gotenberg/main.go +++ b/cmd/gotenberg/main.go @@ -9,8 +9,8 @@ import ( "time" "github.com/thecodingmachine/gotenberg/internal/app/api" - conf "github.com/thecodingmachine/gotenberg/internal/pkg/config" - log "github.com/thecodingmachine/gotenberg/internal/pkg/logger" + "github.com/thecodingmachine/gotenberg/internal/pkg/config" + "github.com/thecodingmachine/gotenberg/internal/pkg/logger" "github.com/thecodingmachine/gotenberg/internal/pkg/pm2" ) @@ -19,12 +19,13 @@ import ( var version = "snapshot" func main() { - config, err := conf.FromEnv() - systemLogger := log.New(config.LogLevel(), "system") + const op = "main" + config, err := config.FromEnv() + systemLogger := logger.New(config.LogLevel(), "system") if err != nil { - systemLogger.Fatal(err) + systemLogger.FatalOp(op, err) } - systemLogger.Infof("Gotenberg %s", version) + systemLogger.InfofOp(op, "Gotenberg %s", version) // start PM2 processes. var processes []pm2.Process if config.EnableChromeEndpoints() { @@ -34,19 +35,19 @@ func main() { processes = append(processes, pm2.NewUnoconv(systemLogger)) } for _, p := range processes { - systemLogger.Infof("starting %s with PM2...", p.Fullname()) + systemLogger.InfofOp(op, "starting %s with PM2...", p.Fullname()) if err := p.Start(); err != nil { - systemLogger.Fatal(err) + systemLogger.FatalOp(op, err) } } // run our API in a goroutine so that it doesn't block. // create our API. srv := api.New(config) go func() { - systemLogger.Infof("http server started on port %s", config.DefaultListenPort()) + systemLogger.InfofOp(op, "http server started on port %s", config.DefaultListenPort()) if err := srv.Start(fmt.Sprintf(":%s", config.DefaultListenPort())); err != nil { if err != http.ErrServerClosed { - systemLogger.Fatal(err) + systemLogger.FatalOp(op, err) } } }() @@ -61,17 +62,17 @@ func main() { defer cancel() // doesn't block if no connections, but will otherwise wait // until the timeout deadline. - systemLogger.Info("shutting down http server...") + systemLogger.InfofOp(op, "shutting down http server...") if err := srv.Shutdown(ctx); err != nil { - systemLogger.Fatal(err) + systemLogger.FatalOp(op, err) } // shutdown PM2 processes. for _, p := range processes { - systemLogger.Infof("shutting down %s with PM2...", p.Fullname()) + systemLogger.InfofOp(op, "shutting down %s with PM2...", p.Fullname()) if err := p.Shutdown(); err != nil { - systemLogger.Fatal(err) + systemLogger.FatalOp(op, err) } } - systemLogger.Info("bye!") + systemLogger.InfofOp(op, "bye!") os.Exit(0) } diff --git a/go.mod b/go.mod index 82700001..12672302 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/labstack/echo/v4 v4.1.6 github.com/labstack/gommon v0.2.9 github.com/mafredri/cdp v0.23.4 + github.com/mattn/go-isatty v0.0.8 github.com/microcosm-cc/bluemonday v1.0.2 github.com/russross/blackfriday/v2 v2.0.1 github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect diff --git a/internal/app/api/pkg/context/context.go b/internal/app/api/pkg/context/context.go index 3b298ffc..1ee8ab5b 100644 --- a/internal/app/api/pkg/context/context.go +++ b/internal/app/api/pkg/context/context.go @@ -74,11 +74,11 @@ func (ctx *Context) WithResource(resourceDirPath string) error { // LogRequestResult logs the result of a request. // This method should only be used by a middleware! func (ctx *Context) LogRequestResult(err error, isDebug bool) error { + const op = "context.LogRequestResult" req := ctx.Request() resp := ctx.Response() stopTime := time.Now() fields := map[string]interface{}{ - "time_rfc3339": timeRFC3339(), // FIXME required? "remote_ip": ctx.RealIP(), "host": req.Host, "uri": req.RequestURI, @@ -93,21 +93,17 @@ func (ctx *Context) LogRequestResult(err error, isDebug bool) error { "bytes_out": bytesOut(resp), } if err != nil { - ctx.logger.WithFields(fields).Error("request failed") + ctx.logger.WithFields(fields).ErrorfOp(op, "request failed") return err } if isDebug { - ctx.logger.WithFields(fields).Debug("request handled") + ctx.logger.WithFields(fields).DebugfOp(op, "request handled") return nil } - ctx.logger.WithFields(fields).Info("request handled") + ctx.logger.WithFields(fields).InfofOp(op, "request handled") return nil } -func timeRFC3339() string { - return time.Now().Format(time.RFC3339) -} - func path(r *http.Request) string { path := r.URL.Path if path == "" { diff --git a/internal/app/api/pkg/middleware/error.go b/internal/app/api/pkg/middleware/error.go index 7cdb6199..48928e26 100644 --- a/internal/app/api/pkg/middleware/error.go +++ b/internal/app/api/pkg/middleware/error.go @@ -20,8 +20,9 @@ func Error() echo.MiddlewareFunc { } // we log the initial error before returning // the HTTP error. + errOp := standarderror.Op(err) logger := ctx.StandardLogger() - logger.Error(err.Error()) + logger.ErrorOp(errOp, err) // handle our custom HTTP error. var httpErr error errCode := standarderror.Code(err) diff --git a/internal/app/api/pkg/resource/resource.go b/internal/app/api/pkg/resource/resource.go index 985bd30d..67d0eda9 100644 --- a/internal/app/api/pkg/resource/resource.go +++ b/internal/app/api/pkg/resource/resource.go @@ -318,7 +318,7 @@ func (r *Resource) float64(formField string, defaultValue float64) (float64, err if err != nil { return 0.0, &standarderror.Error{ Code: standarderror.Invalid, - Message: fmt.Sprintf("'%s' is not a float", formField), + Message: fmt.Sprintf("'%s' is not a float, got '%s'", formField, v), Op: op, } } @@ -338,7 +338,7 @@ func (r *Resource) bool(formField string, defaultValue bool) (bool, error) { if err != nil { return false, &standarderror.Error{ Code: standarderror.Invalid, - Message: fmt.Sprintf("'%s' is not a boolean", formField), + Message: fmt.Sprintf("'%s' is not a boolean, got '%s'", formField, v), Op: op, } } diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index 56dca33d..10168ff3 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -5,7 +5,8 @@ import ( "os" "strconv" - log "github.com/sirupsen/logrus" + "github.com/sirupsen/logrus" + "github.com/thecodingmachine/gotenberg/internal/pkg/standarderror" ) const ( @@ -16,12 +17,14 @@ const ( logLevelEnvVar = "LOG_LEVEL" ) +// Config contains the application +// configuration. type Config struct { defaultWaitTimeout float64 defaultListenPort string enableChromeEndpoints bool enableUnoconvEndpoints bool - logLevel log.Level + logLevel logrus.Level } func defaultConfig() *Config { @@ -30,51 +33,85 @@ func defaultConfig() *Config { defaultListenPort: "3000", enableChromeEndpoints: true, enableUnoconvEndpoints: true, - logLevel: log.InfoLevel, + logLevel: logrus.InfoLevel, } } +// FromEnv fetches configuration +// from environment variables. func FromEnv() (*Config, error) { + const op = "config.FromEnv" c := defaultConfig() defaultWaitTimeout, err := defaultWaitTimeoutFromEnv(defaultWaitTimeoutEnvVar, c.DefaultWaitTimeout()) c.defaultWaitTimeout = defaultWaitTimeout if err != nil { - return c, err + return c, &standarderror.Error{Op: op, Err: err} } defaultListenPort, err := defaultListenPortFromEnv(defaultListenPortEnvVar, c.DefaultListenPort()) c.defaultListenPort = defaultListenPort if err != nil { - return c, err + return c, &standarderror.Error{Op: op, Err: err} } disableChromeEndpoints, err := boolFromEnv(disableGoogleChromeEnvVar, c.EnableChromeEndpoints()) c.enableChromeEndpoints = !disableChromeEndpoints if err != nil { - return c, err + return c, &standarderror.Error{Op: op, Err: err} } disableUnoconvEndpoints, err := boolFromEnv(disableUnoconvEnvVar, c.EnableUnoconvEndpoints()) c.enableUnoconvEndpoints = !disableUnoconvEndpoints if err != nil { - return c, err + return c, &standarderror.Error{Op: op, Err: err} } logLevel, err := logLevelFromEnv(logLevelEnvVar, c.LogLevel()) c.logLevel = logLevel if err != nil { - return c, err + return c, &standarderror.Error{Op: op, Err: err} } return c, nil } -func (c *Config) DefaultWaitTimeout() float64 { return c.defaultWaitTimeout } -func (c *Config) DefaultListenPort() string { return c.defaultListenPort } -func (c *Config) EnableChromeEndpoints() bool { return c.enableChromeEndpoints } -func (c *Config) EnableUnoconvEndpoints() bool { return c.enableUnoconvEndpoints } -func (c *Config) LogLevel() log.Level { return c.logLevel } +// DefaultWaitTimeout returns the default +// wait timeout from the configuration. +func (c *Config) DefaultWaitTimeout() float64 { + return c.defaultWaitTimeout +} + +// DefaultListenPort returns the default +// listen port from the configuration. +func (c *Config) DefaultListenPort() string { + return c.defaultListenPort +} + +// EnableChromeEndpoints returns true if +// Chrome endpoints are enabled in the +// configuration. +func (c *Config) EnableChromeEndpoints() bool { + return c.enableChromeEndpoints +} + +// EnableUnoconvEndpoints returns true if +// Unoconv endpoints are enabled in the +// configuration. +func (c *Config) EnableUnoconvEndpoints() bool { + return c.enableUnoconvEndpoints +} + +// LogLevel returns the logrus.Level from +// the configuration. +func (c *Config) LogLevel() logrus.Level { + return c.logLevel +} func defaultWaitTimeoutFromEnv(envVar string, defaultValue float64) (float64, error) { + const op = "defaultWaitTimeoutFromEnv" if v, ok := os.LookupEnv(envVar); ok { waitTimeout, err := strconv.ParseFloat(v, 64) if err != nil { - return defaultValue, fmt.Errorf("%s: wrong value: want float got %v", envVar, err) + return defaultValue, &standarderror.Error{ + Code: standarderror.Invalid, + Message: fmt.Sprintf("'%s' is not a float, got '%s'", envVar, v), + Op: op, + } } return waitTimeout, nil } @@ -82,13 +119,22 @@ func defaultWaitTimeoutFromEnv(envVar string, defaultValue float64) (float64, er } func defaultListenPortFromEnv(envVar string, defaultValue string) (string, error) { + const op = "defaultListenPortFromEnv" if v, ok := os.LookupEnv(envVar); ok { portAsUint, err := strconv.ParseUint(v, 10, 64) if err != nil { - return defaultValue, fmt.Errorf("%s: wrong value: want uint got %v", envVar, err) + return defaultValue, &standarderror.Error{ + Code: standarderror.Invalid, + Message: fmt.Sprintf("'%s' is not a uint, got '%s'", envVar, v), + Op: op, + } } if portAsUint > 65535 { - return defaultValue, fmt.Errorf("%s: wrong value: want uint < 65535 got %d", envVar, portAsUint) + return defaultValue, &standarderror.Error{ + Code: standarderror.Invalid, + Message: fmt.Sprintf("'%s' is not a uint < 65535, got '%d'", envVar, portAsUint), + Op: op, + } } return v, nil } @@ -96,26 +142,36 @@ func defaultListenPortFromEnv(envVar string, defaultValue string) (string, error } func boolFromEnv(envVar string, defaultValue bool) (bool, error) { + const op = "boolFromEnv" if v, ok := os.LookupEnv(envVar); ok { if v != "1" && v != "0" { - return defaultValue, fmt.Errorf("%s: wrong value: want \"0\" or \"1\" got %s", envVar, v) + return defaultValue, &standarderror.Error{ + Code: standarderror.Invalid, + Message: fmt.Sprintf("'%s' is not '0' or '1', got %s", envVar, v), + Op: op, + } } return v == "1", nil } return defaultValue, nil } -func logLevelFromEnv(envVar string, defaultValue log.Level) (log.Level, error) { +func logLevelFromEnv(envVar string, defaultValue logrus.Level) (logrus.Level, error) { + const op = "logLevelFromEnv" if v, ok := os.LookupEnv(envVar); ok { switch v { case "DEBUG": - return log.DebugLevel, nil + return logrus.DebugLevel, nil case "INFO": - return log.InfoLevel, nil + return logrus.InfoLevel, nil case "ERROR": - return log.ErrorLevel, nil + return logrus.ErrorLevel, nil default: - return defaultValue, fmt.Errorf("%s: wrong value: want \"DEBUG\",\"INFO\" or \"ERROR\" got %s", envVar, v) + return defaultValue, &standarderror.Error{ + Code: standarderror.Invalid, + Message: fmt.Sprintf("'%s' is not 'DEBUG', 'INFO' or 'ERROR', got '%s'", envVar, v), + Op: op, + } } } return defaultValue, nil diff --git a/internal/pkg/config/doc.go b/internal/pkg/config/doc.go index d912156b..55b7416f 100644 --- a/internal/pkg/config/doc.go +++ b/internal/pkg/config/doc.go @@ -1 +1,3 @@ +// Package config gathers all +// configuration data. package config diff --git a/internal/pkg/logger/doc.go b/internal/pkg/logger/doc.go index 90c66f62..b9fcafe6 100644 --- a/internal/pkg/logger/doc.go +++ b/internal/pkg/logger/doc.go @@ -1 +1,3 @@ +// Package logger defines a standard +// logger for the application. package logger diff --git a/internal/pkg/logger/logger.go b/internal/pkg/logger/logger.go index cd290564..ee24d8c3 100644 --- a/internal/pkg/logger/logger.go +++ b/internal/pkg/logger/logger.go @@ -1,33 +1,63 @@ package logger import ( + "os" + + "github.com/mattn/go-isatty" "github.com/sirupsen/logrus" ) // Logger enforces specific log message formats. type Logger struct { - *logrus.Entry + entry *logrus.Entry } // New initializes the logger. func New(level logrus.Level, trace string) *Logger { l := logrus.New() l.SetLevel(level) - // TODO no formatter if TTY. - l.SetFormatter(&logrus.JSONFormatter{}) + if !isatty.IsTerminal(os.Stdout.Fd()) { + l.SetFormatter(&logrus.JSONFormatter{}) + } return &Logger{ - l.WithField("trace", trace), + entry: l.WithField("trace", trace), + } +} + +// WithFields creates a new logger with +// given fields. +func (l *Logger) WithFields(fields map[string]interface{}) *Logger { + return &Logger{ + entry: l.entry.WithFields(fields), } } // DebugfOp logs a debug message for given // logical operation. func (l *Logger) DebugfOp(op string, format string, args ...interface{}) { - l.WithField("op", op).Debugf(format, args...) + l.entry.WithField("op", op).Debugf(format, args...) } -// ErrorOp logs an error message for given +// InfofOp logs an info message for given +// logical operation. +func (l *Logger) InfofOp(op string, format string, args ...interface{}) { + l.entry.WithField("op", op).Infof(format, args...) +} + +// ErrorOp logs an error for given // logical operation. func (l *Logger) ErrorOp(op string, err error) { - l.WithField("op", op).Error(err.Error()) + l.entry.WithField("op", op).Error(err.Error()) +} + +// ErrorfOp logs an error message for given +// logical operation. +func (l *Logger) ErrorfOp(op string, message string) { + l.entry.WithField("op", op).Error(message) +} + +// FatalOp logs an error message for given +// logical operation. +func (l *Logger) FatalOp(op string, err error) { + l.entry.WithField("op", op).Error(err.Error()) } diff --git a/internal/pkg/pm2/chrome.go b/internal/pkg/pm2/chrome.go index df87a4e5..07abc846 100644 --- a/internal/pkg/pm2/chrome.go +++ b/internal/pkg/pm2/chrome.go @@ -6,6 +6,7 @@ import ( "github.com/mafredri/cdp/devtool" "github.com/thecodingmachine/gotenberg/internal/pkg/logger" + "github.com/thecodingmachine/gotenberg/internal/pkg/standarderror" ) const warmupTime = 10 * time.Second @@ -27,11 +28,19 @@ func (p *chrome) Fullname() string { } func (p *chrome) Start() error { - return p.manager.start(p) + const op = "chrome.Start" + if err := p.manager.start(p); err != nil { + return &standarderror.Error{Op: op, Err: err} + } + return nil } func (p *chrome) Shutdown() error { - return p.manager.shutdown(p) + const op = "chrome.Shutdown" + if err := p.manager.shutdown(p); err != nil { + return &standarderror.Error{Op: op, Err: err} + } + return nil } func (p *chrome) args() []string { @@ -58,24 +67,38 @@ func (p *chrome) name() string { } func (p *chrome) viable() bool { + const debugOp = "chrome.viable" // check if Google Chrome is correctly running. ctx, cancel := context.WithCancel(context.Background()) defer cancel() - p.manager.logger.Debugf( - "%s: checking liveness via debug version endpoint http://localhost:9222/json/version", - p.Fullname(), + p.manager.logger.DebugfOp( + debugOp, + "checking liveness via debug version endpoint http://localhost:9222/json/version", ) v, err := devtool.New("http://localhost:9222").Version(ctx) if err != nil { - p.manager.logger.Debugf("%s: debug version endpoint returned error: %v", p.Fullname(), err) + p.manager.logger.DebugfOp( + debugOp, + "debug version endpoint returned error: %v", + err, + ) return false } - p.manager.logger.Debugf("%s: debug version endpoint returned version info: %+v", p.Fullname(), *v) + p.manager.logger.DebugfOp( + debugOp, + "debug version endpoint returned version info: %+v", + *v, + ) return true } func (p *chrome) warmup() { - p.manager.logger.Debugf("%s: allowing %v to startup", p.Fullname(), warmupTime) + const debugOp = "chrome.warmup" + p.manager.logger.DebugfOp( + debugOp, + "allowing %v to startup", + warmupTime, + ) time.Sleep(warmupTime) } diff --git a/internal/pkg/pm2/pm2.go b/internal/pkg/pm2/pm2.go index c53709fa..065ff5eb 100644 --- a/internal/pkg/pm2/pm2.go +++ b/internal/pkg/pm2/pm2.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/thecodingmachine/gotenberg/internal/pkg/logger" + "github.com/thecodingmachine/gotenberg/internal/pkg/standarderror" ) const ( @@ -34,8 +35,9 @@ type processManager struct { } func (m *processManager) start(p Process) error { + const op = "pm2.start" if err := m.pm2(p, "start"); err != nil { - return err + return &standarderror.Error{Op: op, Err: err} } p.warmup() if !p.viable() { @@ -43,14 +45,17 @@ func (m *processManager) start(p Process) error { for attempts < 5 && !p.viable() { if err := m.pm2(p, "restart"); err != nil { m.heuristicState = errorState - return err + return &standarderror.Error{Op: op, Err: err} } p.warmup() attempts++ } if !p.viable() { m.heuristicState = errorState - return fmt.Errorf("failed to launch %s", p.Fullname()) + return &standarderror.Error{ + Op: op, + Message: fmt.Sprintf("failed to launch %s", p.Fullname()), + } } } m.heuristicState = runningState @@ -58,18 +63,20 @@ func (m *processManager) start(p Process) error { } func (m *processManager) shutdown(p Process) error { + const op = "pm2.shutdown" if m.heuristicState != runningState { return nil } if err := m.pm2(p, "stop"); err != nil { m.heuristicState = errorState - return err + return &standarderror.Error{Op: op, Err: err} } m.heuristicState = stoppedState return nil } func (m *processManager) pm2(p Process, cmdName string) error { + const op = "pm2.pm2" cmdArgs := []string{ cmdName, p.name(), @@ -82,35 +89,36 @@ func (m *processManager) pm2(p Process, cmdName string) error { "pm2", cmdArgs..., ) - m.logger.Debugf("executing command: %v", strings.Join(cmd.Args, " ")) + m.logger.DebugfOp(op, "executing command: %s", strings.Join(cmd.Args, " ")) processStdOut, err := cmd.StdoutPipe() if err != nil { - return fmt.Errorf("failed getting stdout from %s: %s", p.Fullname(), err) + return &standarderror.Error{Op: op, Err: err} } processStdErr, err := cmd.StderrPipe() if err != nil { - return fmt.Errorf("failed getting stderr from %s: %s", p.Fullname(), err) + return &standarderror.Error{Op: op, Err: err} } readFromPipe := func(outputType string, reader io.ReadCloser) { + readFromPipeOp := fmt.Sprintf("pm2.%s.%s", p.name(), outputType) r := bufio.NewReader(reader) defer reader.Close() // nolint: errcheck for { line, _, err := r.ReadLine() if err != nil { if err != io.EOF { - m.logger.Errorf("error reading from %s for process %s", outputType, p.Fullname()) + m.logger.ErrorOp(readFromPipeOp, err) } break } if len(line) != 0 { - m.logger.Debugf("%s %s: %s", p.Fullname(), outputType, string(line)) + m.logger.DebugfOp(readFromPipeOp, string(line)) } } } go readFromPipe("stdout", processStdOut) go readFromPipe("stderr", processStdErr) if err := cmd.Start(); err != nil { - return fmt.Errorf("%s %s with PM2: %v", cmdName, p.Fullname(), err) + return &standarderror.Error{Op: op, Err: err} } return nil } diff --git a/internal/pkg/pm2/unoconv.go b/internal/pkg/pm2/unoconv.go index 2bd53745..87319718 100644 --- a/internal/pkg/pm2/unoconv.go +++ b/internal/pkg/pm2/unoconv.go @@ -2,6 +2,7 @@ package pm2 import ( "github.com/thecodingmachine/gotenberg/internal/pkg/logger" + "github.com/thecodingmachine/gotenberg/internal/pkg/standarderror" ) type unoconv struct { @@ -21,11 +22,19 @@ func (p *unoconv) Fullname() string { } func (p *unoconv) Start() error { - return p.manager.start(p) + const op = "unoconv.Start" + if err := p.manager.start(p); err != nil { + return &standarderror.Error{Op: op, Err: err} + } + return nil } func (p *unoconv) Shutdown() error { - return p.manager.shutdown(p) + const op = "unoconv.Shutdown" + if err := p.manager.shutdown(p); err != nil { + return &standarderror.Error{Op: op, Err: err} + } + return nil } func (p *unoconv) args() []string { diff --git a/internal/pkg/standarderror/doc.go b/internal/pkg/standarderror/doc.go index b834229b..b932abe3 100644 --- a/internal/pkg/standarderror/doc.go +++ b/internal/pkg/standarderror/doc.go @@ -1,5 +1,7 @@ -// Package standarderror helps standardizing -// the errors in the application. -// -// Credits: https://middlemost.com/failure-is-your-domain/ +/* +Package standarderror helps standardizing +the errors in the application. + +Credits: https://middlemost.com/failure-is-your-domain/ +*/ package standarderror diff --git a/internal/pkg/standarderror/standarderror.go b/internal/pkg/standarderror/standarderror.go index f77bbe58..cda9cae9 100644 --- a/internal/pkg/standarderror/standarderror.go +++ b/internal/pkg/standarderror/standarderror.go @@ -35,10 +35,6 @@ type Error struct { // Error returns the string representation of the error message. func (err *Error) Error() string { var buf bytes.Buffer - // print the current operation in our stack, if any. - if err.Op != "" { - fmt.Fprintf(&buf, "%s: ", err.Op) - } // if wrapping an error, print its Error() message. // Otherwise print the error code & message. if err.Err != nil { @@ -83,3 +79,31 @@ func Message(err error) string { } return "An internal error has occurred. Please contact technical support." } + +// Op returns the logical operation of the error, if available. +// Otherwise returns an empty string. +// FIXME: "resource.ChromePrinterOptions: float64: : " +func Op(err error) string { + if err == nil { + return "" + } + e, ok := err.(*Error) + if !ok { + return "" + } + var buf bytes.Buffer + if e.Op != "" { + fmt.Fprintf(&buf, "%s: ", e.Op) + } + if e.Err != nil { + if wrappedOp := Op(e.Err); wrappedOp != "" { + fmt.Fprintf(&buf, "%s: ", wrappedOp) + } + } + return buf.String() +} + +// Compile-time checks to ensure type implements desired interfaces. +var ( + _ = error(new(Error)) +) From 5af01505c2148fa15997742416b45ae9132aa54d Mon Sep 17 00:00:00 2001 From: Julien Neuhart Date: Mon, 8 Jul 2019 10:28:43 +0200 Subject: [PATCH 13/34] better op names + fixing issue with op chaining in errors --- internal/app/api/pkg/handler/handler.go | 25 +++++++-------------- internal/app/api/pkg/handler/html.go | 11 ++++++--- internal/app/api/pkg/handler/markdown.go | 13 +++++++---- internal/app/api/pkg/handler/merge.go | 11 ++++++--- internal/app/api/pkg/handler/office.go | 11 ++++++--- internal/app/api/pkg/handler/url.go | 11 ++++++--- internal/app/api/pkg/resource/resource.go | 21 ++++++++--------- internal/pkg/config/config.go | 8 +++---- internal/pkg/pm2/chrome.go | 14 ++++++------ internal/pkg/pm2/unoconv.go | 4 ++-- internal/pkg/standarderror/standarderror.go | 9 +++----- 11 files changed, 74 insertions(+), 64 deletions(-) diff --git a/internal/app/api/pkg/handler/handler.go b/internal/app/api/pkg/handler/handler.go index c15b29ea..b511818c 100644 --- a/internal/app/api/pkg/handler/handler.go +++ b/internal/app/api/pkg/handler/handler.go @@ -35,10 +35,7 @@ const ( ) func convert(ctx *context.Context, p printer.Printer) error { - const ( - op = "convert" - debugOp = "handler.convert" - ) + const op = "handler.convert" r := ctx.Resource() logger := ctx.StandardLogger() baseFilename := random.Get() @@ -48,7 +45,7 @@ func convert(ctx *context.Context, p printer.Printer) error { // and directly return the resulting PDF file // or an error. if !r.Has(resource.WebhookURLFormField) { - logger.DebugfOp(debugOp, "no '%s' found, converting synchronously", resource.WebhookURLFormField) + logger.DebugfOp(op, "no '%s' found, converting synchronously", resource.WebhookURLFormField) if err := convertSync(filename, fpath, ctx, p); err != nil { return &standarderror.Error{Op: op, Err: err} } @@ -57,15 +54,12 @@ func convert(ctx *context.Context, p printer.Printer) error { // as a webhook URL has been given, we // run the following lines in a goroutine so that // it doesn't block. - logger.DebugfOp(debugOp, "'%s' found, converting asynchronously", resource.WebhookURLFormField) + logger.DebugfOp(op, "'%s' found, converting asynchronously", resource.WebhookURLFormField) return convertAsync(filename, fpath, ctx, p) } func convertSync(filename, fpath string, ctx *context.Context, p printer.Printer) error { - const ( - op = "convertSync" - debugOp = "handler.convertSync" - ) + const op = "handler.convertSync" r := ctx.Resource() logger := ctx.StandardLogger() if err := p.Print(fpath); err != nil { @@ -73,7 +67,7 @@ func convertSync(filename, fpath string, ctx *context.Context, p printer.Printer } if !r.Has(resource.ResultFilenameFormField) { logger.DebugfOp( - debugOp, + op, "no '%s' found, using generated filename '%s'", resource.ResultFilenameFormField, filename, @@ -84,7 +78,7 @@ func convertSync(filename, fpath string, ctx *context.Context, p printer.Printer return nil } logger.DebugfOp( - debugOp, + op, "'%s' found, so not using generated filename", resource.ResultFilenameFormField, ) @@ -99,10 +93,7 @@ func convertSync(filename, fpath string, ctx *context.Context, p printer.Printer } func convertAsync(filename, fpath string, ctx *context.Context, p printer.Printer) error { - const ( - op = "convertAsync" - debugOp = "handler.convertAsync" - ) + const op = "handler.convertAsync" r := ctx.Resource() logger := ctx.StandardLogger() go func() { @@ -132,7 +123,7 @@ func convertAsync(filename, fpath string, ctx *context.Context, p printer.Printe return } logger.DebugfOp( - debugOp, + op, "sending result file '%s' to '%s'", filename, webhookURL, diff --git a/internal/app/api/pkg/handler/html.go b/internal/app/api/pkg/handler/html.go index d5850685..117e1749 100644 --- a/internal/app/api/pkg/handler/html.go +++ b/internal/app/api/pkg/handler/html.go @@ -4,21 +4,26 @@ import ( "github.com/labstack/echo/v4" "github.com/thecodingmachine/gotenberg/internal/app/api/pkg/context" "github.com/thecodingmachine/gotenberg/internal/pkg/printer" + "github.com/thecodingmachine/gotenberg/internal/pkg/standarderror" ) // HTML is the endpoint for converting // HTML to PDF. func HTML(c echo.Context) error { + const op = "handler.HTML" ctx := context.MustCastFromEchoContext(c) r := ctx.Resource() opts, err := r.ChromePrinterOptions() if err != nil { - return err + return &standarderror.Error{Op: op, Err: err} } fpath, err := r.Fpath("index.html") if err != nil { - return err + return &standarderror.Error{Op: op, Err: err} } p := printer.NewHTML(fpath, opts) - return convert(ctx, p) + if err := convert(ctx, p); err != nil { + return &standarderror.Error{Op: op, Err: err} + } + return nil } diff --git a/internal/app/api/pkg/handler/markdown.go b/internal/app/api/pkg/handler/markdown.go index a4255fd2..6e22a556 100644 --- a/internal/app/api/pkg/handler/markdown.go +++ b/internal/app/api/pkg/handler/markdown.go @@ -4,24 +4,29 @@ import ( "github.com/labstack/echo/v4" "github.com/thecodingmachine/gotenberg/internal/app/api/pkg/context" "github.com/thecodingmachine/gotenberg/internal/pkg/printer" + "github.com/thecodingmachine/gotenberg/internal/pkg/standarderror" ) // Markdown is the endpoint for converting // Markdown to PDF. func Markdown(c echo.Context) error { + const op = "handler.Markdown" ctx := context.MustCastFromEchoContext(c) r := ctx.Resource() opts, err := r.ChromePrinterOptions() if err != nil { - return err + return &standarderror.Error{Op: op, Err: err} } fpath, err := r.Fpath("index.html") if err != nil { - return err + return &standarderror.Error{Op: op, Err: err} } p, err := printer.NewMarkdown(fpath, opts) if err != nil { - return err + return &standarderror.Error{Op: op, Err: err} } - return convert(ctx, p) + if err := convert(ctx, p); err != nil { + return &standarderror.Error{Op: op, Err: err} + } + return nil } diff --git a/internal/app/api/pkg/handler/merge.go b/internal/app/api/pkg/handler/merge.go index f9d1e5d1..6bd74d27 100644 --- a/internal/app/api/pkg/handler/merge.go +++ b/internal/app/api/pkg/handler/merge.go @@ -4,21 +4,26 @@ import ( "github.com/labstack/echo/v4" "github.com/thecodingmachine/gotenberg/internal/app/api/pkg/context" "github.com/thecodingmachine/gotenberg/internal/pkg/printer" + "github.com/thecodingmachine/gotenberg/internal/pkg/standarderror" ) // Merge is the endpoint for // merging PDF files. func Merge(c echo.Context) error { + const op = "handler.Merge" ctx := context.MustCastFromEchoContext(c) r := ctx.Resource() opts, err := r.MergePrinterOptions() if err != nil { - return err + return &standarderror.Error{Op: op, Err: err} } fpaths, err := r.Fpaths(".pdf") if err != nil { - return err + return &standarderror.Error{Op: op, Err: err} } p := printer.NewMerge(fpaths, opts) - return convert(ctx, p) + if err := convert(ctx, p); err != nil { + return &standarderror.Error{Op: op, Err: err} + } + return nil } diff --git a/internal/app/api/pkg/handler/office.go b/internal/app/api/pkg/handler/office.go index 3cf5c315..09d294b4 100644 --- a/internal/app/api/pkg/handler/office.go +++ b/internal/app/api/pkg/handler/office.go @@ -4,16 +4,18 @@ import ( "github.com/labstack/echo/v4" "github.com/thecodingmachine/gotenberg/internal/app/api/pkg/context" "github.com/thecodingmachine/gotenberg/internal/pkg/printer" + "github.com/thecodingmachine/gotenberg/internal/pkg/standarderror" ) // Office is the endpoint for converting // Office files to PDF. func Office(c echo.Context) error { + const op = "handler.Office" ctx := context.MustCastFromEchoContext(c) r := ctx.Resource() opts, err := r.OfficePrinterOptions() if err != nil { - return err + return &standarderror.Error{Op: op, Err: err} } fpaths, err := r.Fpaths( ".txt", @@ -30,8 +32,11 @@ func Office(c echo.Context) error { ".odp", ) if err != nil { - return err + return &standarderror.Error{Op: op, Err: err} } p := printer.NewOffice(fpaths, opts) - return convert(ctx, p) + if err := convert(ctx, p); err != nil { + return &standarderror.Error{Op: op, Err: err} + } + return nil } diff --git a/internal/app/api/pkg/handler/url.go b/internal/app/api/pkg/handler/url.go index cf1bc75d..f38eda0a 100644 --- a/internal/app/api/pkg/handler/url.go +++ b/internal/app/api/pkg/handler/url.go @@ -5,21 +5,26 @@ import ( "github.com/thecodingmachine/gotenberg/internal/app/api/pkg/context" "github.com/thecodingmachine/gotenberg/internal/app/api/pkg/resource" "github.com/thecodingmachine/gotenberg/internal/pkg/printer" + "github.com/thecodingmachine/gotenberg/internal/pkg/standarderror" ) // URL is the endpoint for converting // a URL to PDF. func URL(c echo.Context) error { + const op = "handler.URL" ctx := context.MustCastFromEchoContext(c) r := ctx.Resource() opts, err := r.ChromePrinterOptions() if err != nil { - return err + return &standarderror.Error{Op: op, Err: err} } remoteURL, err := r.Get(resource.RemoteURLFormField) if err != nil { - return err + return &standarderror.Error{Op: op, Err: err} } p := printer.NewURL(remoteURL, opts) - return convert(ctx, p) + if err := convert(ctx, p); err != nil { + return &standarderror.Error{Op: op, Err: err} + } + return nil } diff --git a/internal/app/api/pkg/resource/resource.go b/internal/app/api/pkg/resource/resource.go index 67d0eda9..479eb38d 100644 --- a/internal/app/api/pkg/resource/resource.go +++ b/internal/app/api/pkg/resource/resource.go @@ -83,7 +83,7 @@ func New(c echo.Context, logger *logger.Logger, config *config.Config, dirPath s } func formValues(c echo.Context, logger *logger.Logger) map[string]string { - const debugOp = "resource.formValues" + const op = "resource.formValues" v := make(map[string]string) v[ResultFilenameFormField] = c.FormValue(ResultFilenameFormField) v[WaitTimeoutFormField] = c.FormValue(WaitTimeoutFormField) @@ -97,15 +97,12 @@ func formValues(c echo.Context, logger *logger.Logger) map[string]string { v[MarginLeftFormField] = c.FormValue(MarginLeftFormField) v[MarginRightFormField] = c.FormValue(MarginRightFormField) v[LandscapeFormField] = c.FormValue(LandscapeFormField) - logger.DebugfOp(debugOp, "%v", v) + logger.DebugfOp(op, "%v", v) return v } func formFiles(c echo.Context, logger *logger.Logger, dirPath string) error { - const ( - op = "formFiles" - debugOp = "resource.formFiles" - ) + const op = "resource.formFiles" form, err := c.MultipartForm() if err != nil { return &standarderror.Error{Op: op, Err: err} @@ -132,7 +129,7 @@ func formFiles(c echo.Context, logger *logger.Logger, dirPath string) error { if _, err := out.Seek(0, 0); err != nil { return &standarderror.Error{Op: op, Err: err} } - logger.DebugfOp(debugOp, "'%s' created", fh.Filename) + logger.DebugfOp(op, "'%s' created", fh.Filename) } } return nil @@ -293,7 +290,7 @@ func (r *Resource) Get(formField string) (string, error) { } func (r *Resource) value(formField string) (string, error) { - const op = "value" + const op = "resource.value" v, ok := r.formValues[formField] if !ok { return "", &standarderror.Error{ @@ -306,7 +303,7 @@ func (r *Resource) value(formField string) (string, error) { } func (r *Resource) float64(formField string, defaultValue float64) (float64, error) { - const op = "float64" + const op = "resource.float64" if !r.Has(formField) { return defaultValue, nil } @@ -326,7 +323,7 @@ func (r *Resource) float64(formField string, defaultValue float64) (float64, err } func (r *Resource) bool(formField string, defaultValue bool) (bool, error) { - const op = "bool" + const op = "resource.bool" if !r.Has(formField) { return defaultValue, nil } @@ -366,7 +363,7 @@ func (r *Resource) Fpath(filename string) (string, error) { } func (r *Resource) content(filename string, defaultValue string) (string, error) { - const op = "content" + const op = "resource.content" if !r.hasFile(filename) { return defaultValue, nil } @@ -387,7 +384,7 @@ func (r *Resource) Fpaths(exts ...string) ([]string, error) { const op = "resource.Fpaths" var fpaths []string err := filepath.Walk(r.formFilesDirPath, func(path string, info os.FileInfo, _ error) error { - const walkOp = "filepath.Walk" + const walkOp = "resource.filepath.Walk" if info.IsDir() { return nil } diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index 10168ff3..59246ede 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -103,7 +103,7 @@ func (c *Config) LogLevel() logrus.Level { } func defaultWaitTimeoutFromEnv(envVar string, defaultValue float64) (float64, error) { - const op = "defaultWaitTimeoutFromEnv" + const op = "config.defaultWaitTimeoutFromEnv" if v, ok := os.LookupEnv(envVar); ok { waitTimeout, err := strconv.ParseFloat(v, 64) if err != nil { @@ -119,7 +119,7 @@ func defaultWaitTimeoutFromEnv(envVar string, defaultValue float64) (float64, er } func defaultListenPortFromEnv(envVar string, defaultValue string) (string, error) { - const op = "defaultListenPortFromEnv" + const op = "config.defaultListenPortFromEnv" if v, ok := os.LookupEnv(envVar); ok { portAsUint, err := strconv.ParseUint(v, 10, 64) if err != nil { @@ -142,7 +142,7 @@ func defaultListenPortFromEnv(envVar string, defaultValue string) (string, error } func boolFromEnv(envVar string, defaultValue bool) (bool, error) { - const op = "boolFromEnv" + const op = "config.boolFromEnv" if v, ok := os.LookupEnv(envVar); ok { if v != "1" && v != "0" { return defaultValue, &standarderror.Error{ @@ -157,7 +157,7 @@ func boolFromEnv(envVar string, defaultValue bool) (bool, error) { } func logLevelFromEnv(envVar string, defaultValue logrus.Level) (logrus.Level, error) { - const op = "logLevelFromEnv" + const op = "config.logLevelFromEnv" if v, ok := os.LookupEnv(envVar); ok { switch v { case "DEBUG": diff --git a/internal/pkg/pm2/chrome.go b/internal/pkg/pm2/chrome.go index 07abc846..120baa50 100644 --- a/internal/pkg/pm2/chrome.go +++ b/internal/pkg/pm2/chrome.go @@ -28,7 +28,7 @@ func (p *chrome) Fullname() string { } func (p *chrome) Start() error { - const op = "chrome.Start" + const op = "pm2.chrome.Start" if err := p.manager.start(p); err != nil { return &standarderror.Error{Op: op, Err: err} } @@ -36,7 +36,7 @@ func (p *chrome) Start() error { } func (p *chrome) Shutdown() error { - const op = "chrome.Shutdown" + const op = "pm2.chrome.Shutdown" if err := p.manager.shutdown(p); err != nil { return &standarderror.Error{Op: op, Err: err} } @@ -67,25 +67,25 @@ func (p *chrome) name() string { } func (p *chrome) viable() bool { - const debugOp = "chrome.viable" + const op = "pm2.chrome.viable" // check if Google Chrome is correctly running. ctx, cancel := context.WithCancel(context.Background()) defer cancel() p.manager.logger.DebugfOp( - debugOp, + op, "checking liveness via debug version endpoint http://localhost:9222/json/version", ) v, err := devtool.New("http://localhost:9222").Version(ctx) if err != nil { p.manager.logger.DebugfOp( - debugOp, + op, "debug version endpoint returned error: %v", err, ) return false } p.manager.logger.DebugfOp( - debugOp, + op, "debug version endpoint returned version info: %+v", *v, ) @@ -93,7 +93,7 @@ func (p *chrome) viable() bool { } func (p *chrome) warmup() { - const debugOp = "chrome.warmup" + const debugOp = "pm2.chrome.warmup" p.manager.logger.DebugfOp( debugOp, "allowing %v to startup", diff --git a/internal/pkg/pm2/unoconv.go b/internal/pkg/pm2/unoconv.go index 87319718..48ec164d 100644 --- a/internal/pkg/pm2/unoconv.go +++ b/internal/pkg/pm2/unoconv.go @@ -22,7 +22,7 @@ func (p *unoconv) Fullname() string { } func (p *unoconv) Start() error { - const op = "unoconv.Start" + const op = "pm2.unoconv.Start" if err := p.manager.start(p); err != nil { return &standarderror.Error{Op: op, Err: err} } @@ -30,7 +30,7 @@ func (p *unoconv) Start() error { } func (p *unoconv) Shutdown() error { - const op = "unoconv.Shutdown" + const op = "pm2.unoconv.Shutdown" if err := p.manager.shutdown(p); err != nil { return &standarderror.Error{Op: op, Err: err} } diff --git a/internal/pkg/standarderror/standarderror.go b/internal/pkg/standarderror/standarderror.go index cda9cae9..4391374e 100644 --- a/internal/pkg/standarderror/standarderror.go +++ b/internal/pkg/standarderror/standarderror.go @@ -82,7 +82,6 @@ func Message(err error) string { // Op returns the logical operation of the error, if available. // Otherwise returns an empty string. -// FIXME: "resource.ChromePrinterOptions: float64: : " func Op(err error) string { if err == nil { return "" @@ -93,12 +92,10 @@ func Op(err error) string { } var buf bytes.Buffer if e.Op != "" { - fmt.Fprintf(&buf, "%s: ", e.Op) + fmt.Fprintf(&buf, "%s", e.Op) } - if e.Err != nil { - if wrappedOp := Op(e.Err); wrappedOp != "" { - fmt.Fprintf(&buf, "%s: ", wrappedOp) - } + if nestedOp := Op(e.Err); nestedOp != "" { + fmt.Fprintf(&buf, ": %s", nestedOp) } return buf.String() } From 70b185a37a809c3fb69a2ad25640a61cf86436e5 Mon Sep 17 00:00:00 2001 From: Julien Neuhart Date: Mon, 8 Jul 2019 11:06:26 +0200 Subject: [PATCH 14/34] improving logging --- cmd/gotenberg/main.go | 1 + internal/app/api/pkg/handler/html.go | 1 + internal/app/api/pkg/handler/markdown.go | 1 + internal/app/api/pkg/handler/merge.go | 1 + internal/app/api/pkg/handler/office.go | 1 + internal/app/api/pkg/handler/url.go | 1 + internal/app/api/pkg/middleware/context.go | 3 +- internal/app/api/pkg/middleware/error.go | 3 +- internal/app/api/pkg/resource/resource.go | 43 +++++++++++++--------- internal/pkg/pm2/chrome.go | 10 ++--- internal/pkg/pm2/unoconv.go | 12 +++++- 11 files changed, 49 insertions(+), 28 deletions(-) diff --git a/cmd/gotenberg/main.go b/cmd/gotenberg/main.go index 28a8e976..92131b52 100644 --- a/cmd/gotenberg/main.go +++ b/cmd/gotenberg/main.go @@ -26,6 +26,7 @@ func main() { systemLogger.FatalOp(op, err) } systemLogger.InfofOp(op, "Gotenberg %s", version) + systemLogger.DebugfOp(op, "configuration: %+v", config) // start PM2 processes. var processes []pm2.Process if config.EnableChromeEndpoints() { diff --git a/internal/app/api/pkg/handler/html.go b/internal/app/api/pkg/handler/html.go index 117e1749..eb1581bb 100644 --- a/internal/app/api/pkg/handler/html.go +++ b/internal/app/api/pkg/handler/html.go @@ -12,6 +12,7 @@ import ( func HTML(c echo.Context) error { const op = "handler.HTML" ctx := context.MustCastFromEchoContext(c) + ctx.StandardLogger().DebugfOp(op, "html request") r := ctx.Resource() opts, err := r.ChromePrinterOptions() if err != nil { diff --git a/internal/app/api/pkg/handler/markdown.go b/internal/app/api/pkg/handler/markdown.go index 6e22a556..a7f59379 100644 --- a/internal/app/api/pkg/handler/markdown.go +++ b/internal/app/api/pkg/handler/markdown.go @@ -12,6 +12,7 @@ import ( func Markdown(c echo.Context) error { const op = "handler.Markdown" ctx := context.MustCastFromEchoContext(c) + ctx.StandardLogger().DebugfOp(op, "markdown request") r := ctx.Resource() opts, err := r.ChromePrinterOptions() if err != nil { diff --git a/internal/app/api/pkg/handler/merge.go b/internal/app/api/pkg/handler/merge.go index 6bd74d27..381e7da2 100644 --- a/internal/app/api/pkg/handler/merge.go +++ b/internal/app/api/pkg/handler/merge.go @@ -12,6 +12,7 @@ import ( func Merge(c echo.Context) error { const op = "handler.Merge" ctx := context.MustCastFromEchoContext(c) + ctx.StandardLogger().DebugfOp(op, "merge request") r := ctx.Resource() opts, err := r.MergePrinterOptions() if err != nil { diff --git a/internal/app/api/pkg/handler/office.go b/internal/app/api/pkg/handler/office.go index 09d294b4..60ccca1c 100644 --- a/internal/app/api/pkg/handler/office.go +++ b/internal/app/api/pkg/handler/office.go @@ -12,6 +12,7 @@ import ( func Office(c echo.Context) error { const op = "handler.Office" ctx := context.MustCastFromEchoContext(c) + ctx.StandardLogger().DebugfOp(op, "office request") r := ctx.Resource() opts, err := r.OfficePrinterOptions() if err != nil { diff --git a/internal/app/api/pkg/handler/url.go b/internal/app/api/pkg/handler/url.go index f38eda0a..f7383a3c 100644 --- a/internal/app/api/pkg/handler/url.go +++ b/internal/app/api/pkg/handler/url.go @@ -13,6 +13,7 @@ import ( func URL(c echo.Context) error { const op = "handler.URL" ctx := context.MustCastFromEchoContext(c) + ctx.StandardLogger().DebugfOp(op, "url request") r := ctx.Resource() opts, err := r.ChromePrinterOptions() if err != nil { diff --git a/internal/app/api/pkg/middleware/context.go b/internal/app/api/pkg/middleware/context.go index 29dc26c0..c711683b 100644 --- a/internal/app/api/pkg/middleware/context.go +++ b/internal/app/api/pkg/middleware/context.go @@ -30,8 +30,7 @@ func Context(config *config.Config) echo.MiddlewareFunc { // if the endpoint is not for healthcheck, associate a // resource to our custom context. if err := ctx.WithResource(trace); err != nil { - // required to have a correct status code - // in the logs. + // required to have a correct status code. ctx.Error(err) return ctx.LogRequestResult(err, false) } diff --git a/internal/app/api/pkg/middleware/error.go b/internal/app/api/pkg/middleware/error.go index 48928e26..876459db 100644 --- a/internal/app/api/pkg/middleware/error.go +++ b/internal/app/api/pkg/middleware/error.go @@ -35,8 +35,7 @@ func Error() echo.MiddlewareFunc { default: httpErr = echo.NewHTTPError(http.StatusInternalServerError, errMessage) } - // required to have a correct status code - // in the logs. + // required to have a correct status code. ctx.Error(httpErr) return httpErr } diff --git a/internal/app/api/pkg/resource/resource.go b/internal/app/api/pkg/resource/resource.go index 479eb38d..69eb487a 100644 --- a/internal/app/api/pkg/resource/resource.go +++ b/internal/app/api/pkg/resource/resource.go @@ -85,19 +85,27 @@ func New(c echo.Context, logger *logger.Logger, config *config.Config, dirPath s func formValues(c echo.Context, logger *logger.Logger) map[string]string { const op = "resource.formValues" v := make(map[string]string) - v[ResultFilenameFormField] = c.FormValue(ResultFilenameFormField) - v[WaitTimeoutFormField] = c.FormValue(WaitTimeoutFormField) - v[WebhookURLFormField] = c.FormValue(WebhookURLFormField) - v[RemoteURLFormField] = c.FormValue(RemoteURLFormField) - v[WaitDelayFormField] = c.FormValue(WaitDelayFormField) - v[PaperWidthFormField] = c.FormValue(PaperWidthFormField) - v[PaperHeightFormField] = c.FormValue(PaperHeightFormField) - v[MarginTopFormField] = c.FormValue(MarginTopFormField) - v[MarginBottomFormField] = c.FormValue(MarginBottomFormField) - v[MarginLeftFormField] = c.FormValue(MarginLeftFormField) - v[MarginRightFormField] = c.FormValue(MarginRightFormField) - v[LandscapeFormField] = c.FormValue(LandscapeFormField) - logger.DebugfOp(op, "%v", v) + fetch := func(formField string) string { + value := c.FormValue(formField) + if value == "" { + logger.DebugfOp(op, "'%s' is empty", formField) + return value + } + logger.DebugfOp(op, "'%s' retrieved, got '%s'", formField, value) + return value + } + v[ResultFilenameFormField] = fetch(ResultFilenameFormField) + v[WaitTimeoutFormField] = fetch(WaitTimeoutFormField) + v[WebhookURLFormField] = fetch(WebhookURLFormField) + v[RemoteURLFormField] = fetch(RemoteURLFormField) + v[WaitDelayFormField] = fetch(WaitDelayFormField) + v[PaperWidthFormField] = fetch(PaperWidthFormField) + v[PaperHeightFormField] = fetch(PaperHeightFormField) + v[MarginTopFormField] = fetch(MarginTopFormField) + v[MarginBottomFormField] = fetch(MarginBottomFormField) + v[MarginLeftFormField] = fetch(MarginLeftFormField) + v[MarginRightFormField] = fetch(MarginRightFormField) + v[LandscapeFormField] = fetch(LandscapeFormField) return v } @@ -221,7 +229,7 @@ func (r *Resource) ChromePrinterOptions() (*printer.ChromeOptions, error) { MarginRight: marginRight, Landscape: landscape, } - r.logger.DebugfOp(op, "%v", opts) + r.logger.DebugfOp(op, "printer options: %+v", opts) return opts, nil } @@ -242,7 +250,7 @@ func (r *Resource) OfficePrinterOptions() (*printer.OfficeOptions, error) { WaitTimeout: waitTimeout, Landscape: landscape, } - r.logger.DebugfOp(op, "%v", opts) + r.logger.DebugfOp(op, "printer options: %+v", opts) return opts, nil } @@ -258,7 +266,7 @@ func (r *Resource) MergePrinterOptions() (*printer.MergeOptions, error) { opts := &printer.MergeOptions{ WaitTimeout: waitTimeout, } - r.logger.DebugfOp(op, "%v", opts) + r.logger.DebugfOp(op, "printer options: %+v", opts) return opts, nil } @@ -384,13 +392,12 @@ func (r *Resource) Fpaths(exts ...string) ([]string, error) { const op = "resource.Fpaths" var fpaths []string err := filepath.Walk(r.formFilesDirPath, func(path string, info os.FileInfo, _ error) error { - const walkOp = "resource.filepath.Walk" if info.IsDir() { return nil } fpath, err := r.Fpath(info.Name()) if err != nil { - return &standarderror.Error{Op: walkOp, Err: err} + return &standarderror.Error{Op: op, Err: err} } for _, ext := range exts { if filepath.Ext(fpath) == ext { diff --git a/internal/pkg/pm2/chrome.go b/internal/pkg/pm2/chrome.go index 120baa50..94492ede 100644 --- a/internal/pkg/pm2/chrome.go +++ b/internal/pkg/pm2/chrome.go @@ -9,7 +9,7 @@ import ( "github.com/thecodingmachine/gotenberg/internal/pkg/standarderror" ) -const warmupTime = 10 * time.Second +const chromeWarmupTime = 10 * time.Second type chrome struct { manager *processManager @@ -93,13 +93,13 @@ func (p *chrome) viable() bool { } func (p *chrome) warmup() { - const debugOp = "pm2.chrome.warmup" + const op = "pm2.chrome.warmup" p.manager.logger.DebugfOp( - debugOp, + op, "allowing %v to startup", - warmupTime, + chromeWarmupTime, ) - time.Sleep(warmupTime) + time.Sleep(chromeWarmupTime) } // Compile-time checks to ensure type implements desired interfaces. diff --git a/internal/pkg/pm2/unoconv.go b/internal/pkg/pm2/unoconv.go index 48ec164d..ab1f9cde 100644 --- a/internal/pkg/pm2/unoconv.go +++ b/internal/pkg/pm2/unoconv.go @@ -1,10 +1,14 @@ package pm2 import ( + "time" + "github.com/thecodingmachine/gotenberg/internal/pkg/logger" "github.com/thecodingmachine/gotenberg/internal/pkg/standarderror" ) +const unoconvWarmupTime = 5 * time.Second + type unoconv struct { manager *processManager } @@ -56,7 +60,13 @@ func (p *unoconv) viable() bool { } func (p *unoconv) warmup() { - // let's do nothing. + const op = "pm2.unoconv.warmup" + p.manager.logger.DebugfOp( + op, + "allowing %v to startup", + unoconvWarmupTime, + ) + time.Sleep(unoconvWarmupTime) } // Compile-time checks to ensure type implements desired interfaces. From 186c09f29bd8b32ec2332f130716bd64485d8f20 Mon Sep 17 00:00:00 2001 From: Julien Neuhart Date: Mon, 8 Jul 2019 11:16:47 +0200 Subject: [PATCH 15/34] adding op and standarderror in printer package --- internal/pkg/printer/chrome.go | 37 ++++++++++++++++++-------------- internal/pkg/printer/markdown.go | 11 ++++++---- internal/pkg/printer/merge.go | 7 ++++-- internal/pkg/printer/office.go | 18 ++++++++++++---- 4 files changed, 47 insertions(+), 26 deletions(-) diff --git a/internal/pkg/printer/chrome.go b/internal/pkg/printer/chrome.go index 82f05569..3e1a483d 100644 --- a/internal/pkg/printer/chrome.go +++ b/internal/pkg/printer/chrome.go @@ -12,6 +12,7 @@ import ( "github.com/mafredri/cdp/protocol/page" "github.com/mafredri/cdp/protocol/target" "github.com/mafredri/cdp/rpcc" + "github.com/thecodingmachine/gotenberg/internal/pkg/standarderror" "golang.org/x/sync/errgroup" ) @@ -37,24 +38,26 @@ type ChromeOptions struct { } func (p *chrome) Print(destination string) error { + const op = "printer.chrome.Print" + // FIXME duration not working with float duration := time.Duration(p.opts.WaitTimeout+p.opts.WaitDelay) * time.Second ctx, cancel := context.WithTimeout(context.Background(), duration) defer cancel() devt, err := devtool.New("http://localhost:9222").Version(ctx) if err != nil { - return err + return &standarderror.Error{Op: op, Err: err} } // connect to WebSocket URL (page) that speaks the Chrome DevTools Protocol. devtConn, err := rpcc.DialContext(ctx, devt.WebSocketDebuggerURL) if err != nil { - return err + return &standarderror.Error{Op: op, Err: err} } defer devtConn.Close() // nolint: errcheck // create a new CDP Client that uses conn. devtClient := cdp.NewClient(devtConn) newContextTarget, err := devtClient.Target.CreateBrowserContext(ctx) if err != nil { - return fmt.Errorf("creating new browser context: %v", err) + return &standarderror.Error{Op: op, Err: err} } // create a new blank target with the new browser context. createTargetArgs := target. @@ -62,13 +65,13 @@ func (p *chrome) Print(destination string) error { SetBrowserContextID(newContextTarget.BrowserContextID) newTarget, err := devtClient.Target.CreateTarget(ctx, createTargetArgs) if err != nil { - return fmt.Errorf("creating new blank target: %v", err) + return &standarderror.Error{Op: op, Err: err} } // connect the client to the new target. newTargetWsURL := fmt.Sprintf("ws://127.0.0.1:9222/devtools/page/%s", newTarget.TargetID) newContextConn, err := rpcc.DialContext(ctx, newTargetWsURL) if err != nil { - return fmt.Errorf("connecting client to blank target: %v", err) + return &standarderror.Error{Op: op, Err: err} } defer newContextConn.Close() // nolint: errcheck // create a new CDP Client that uses newContextConn. @@ -83,10 +86,10 @@ func (p *chrome) Print(destination string) error { func() error { return targetClient.Page.Enable(ctx) }, func() error { return targetClient.Runtime.Enable(ctx) }, ); err != nil { - return err + return &standarderror.Error{Op: op, Err: err} } if err := p.navigate(ctx, targetClient); err != nil { - return err + return &standarderror.Error{Op: op, Err: err} } print, err := targetClient.Page.PrintToPDF( ctx, @@ -104,41 +107,42 @@ func (p *chrome) Print(destination string) error { SetPrintBackground(true), ) if err != nil { - return fmt.Errorf("printing page to PDF: %v", err) + return &standarderror.Error{Op: op, Err: err} } if err := ioutil.WriteFile(destination, print.Data, 0644); err != nil { - return fmt.Errorf("%s: writing file: %v", destination, err) + return &standarderror.Error{Op: op, Err: err} } return nil } func (p *chrome) navigate(ctx context.Context, client *cdp.Client) error { + const op = "printer.chrome.navigate" // make sure Page events are enabled. if err := client.Page.Enable(ctx); err != nil { - return err + return &standarderror.Error{Op: op, Err: err} } // make sure Network events are enabled. if err := client.Network.Enable(ctx, nil); err != nil { - return err + return &standarderror.Error{Op: op, Err: err} } // create all clients for events. domContentEventFired, err := client.Page.DOMContentEventFired(ctx) if err != nil { - return err + return &standarderror.Error{Op: op, Err: err} } defer domContentEventFired.Close() // nolint: errcheck loadEventFired, err := client.Page.LoadEventFired(ctx) if err != nil { - return err + return &standarderror.Error{Op: op, Err: err} } defer loadEventFired.Close() // nolint: errcheck loadingFinished, err := client.Network.LoadingFinished(ctx) if err != nil { - return err + return &standarderror.Error{Op: op, Err: err} } defer loadingFinished.Close() // nolint: errcheck if _, err := client.Page.Navigate(ctx, page.NewNavigateArgs(p.url)); err != nil { - return err + return &standarderror.Error{Op: op, Err: err} } if err := runBatch( // wait for all events. @@ -146,9 +150,10 @@ func (p *chrome) navigate(ctx context.Context, client *cdp.Client) error { func() error { _, err := loadEventFired.Recv(); return err }, func() error { _, err := loadingFinished.Recv(); return err }, ); err != nil { - return err + return &standarderror.Error{Op: op, Err: err} } // wait for a given amount of time (useful for javascript delay). + // FIXME duration not working with float time.Sleep(time.Duration(p.opts.WaitDelay) * time.Second) return nil } diff --git a/internal/pkg/printer/markdown.go b/internal/pkg/printer/markdown.go index 9c84830c..07ea3b55 100644 --- a/internal/pkg/printer/markdown.go +++ b/internal/pkg/printer/markdown.go @@ -10,27 +10,29 @@ import ( "github.com/labstack/gommon/random" "github.com/microcosm-cc/bluemonday" "github.com/russross/blackfriday/v2" + "github.com/thecodingmachine/gotenberg/internal/pkg/standarderror" ) // NewMarkdown returns a Markdown printer. func NewMarkdown(fpath string, opts *ChromeOptions) (Printer, error) { + const op = "printer.NewMarkdown" tmpl, err := template. New(filepath.Base(fpath)). Funcs(template.FuncMap{"toHTML": markdownToHTML}). ParseFiles(fpath) if err != nil { - return nil, fmt.Errorf("%s: parsing template: %v", fpath, err) + return nil, &standarderror.Error{Op: op, Err: err} } dirPath := filepath.Dir(fpath) data := &templateData{DirPath: dirPath} var buffer bytes.Buffer if err := tmpl.Execute(&buffer, data); err != nil { - return nil, fmt.Errorf("%s: executing template: %v", fpath, err) + return nil, &standarderror.Error{Op: op, Err: err} } baseFilename := random.String(32) dst := fmt.Sprintf("%s/%s.html", dirPath, baseFilename) if err := ioutil.WriteFile(dst, buffer.Bytes(), 0644); err != nil { - return nil, fmt.Errorf("%s: writing file: %v", dst, err) + return nil, &standarderror.Error{Op: op, Err: err} } URL := fmt.Sprintf("file://%s", dst) return &chrome{ @@ -44,10 +46,11 @@ type templateData struct { } func markdownToHTML(dirPath, filename string) (template.HTML, error) { + const op = "printer.markdownToHTML" fpath := fmt.Sprintf("%s/%s", dirPath, filename) b, err := ioutil.ReadFile(fpath) if err != nil { - return "", fmt.Errorf("%s: reading file: %v", fpath, err) + return "", &standarderror.Error{Op: op, Err: err} } unsafe := blackfriday.Run(b) content := bluemonday.UGCPolicy().SanitizeBytes(unsafe) diff --git a/internal/pkg/printer/merge.go b/internal/pkg/printer/merge.go index d21c2420..6393f5cf 100644 --- a/internal/pkg/printer/merge.go +++ b/internal/pkg/printer/merge.go @@ -2,9 +2,10 @@ package printer import ( "context" - "fmt" "os/exec" "time" + + "github.com/thecodingmachine/gotenberg/internal/pkg/standarderror" ) type merge struct { @@ -28,7 +29,9 @@ func NewMerge(fpaths []string, opts *MergeOptions) Printer { } func (p *merge) Print(destination string) error { + const op = "printer.merge.Print" if p.ctx == nil { + // FIXME duration not working with float ctx, cancel := context.WithTimeout(context.Background(), time.Duration(p.opts.WaitTimeout)*time.Second) defer cancel() p.ctx = ctx @@ -39,7 +42,7 @@ func (p *merge) Print(destination string) error { cmd := exec.CommandContext(p.ctx, "pdftk", cmdArgs...) _, err := cmd.Output() if err != nil { - return fmt.Errorf("pdtk: %v", err) + return &standarderror.Error{Op: op, Err: err} } return nil } diff --git a/internal/pkg/printer/office.go b/internal/pkg/printer/office.go index 8e83824e..214da42b 100644 --- a/internal/pkg/printer/office.go +++ b/internal/pkg/printer/office.go @@ -10,6 +10,7 @@ import ( "time" "github.com/labstack/gommon/random" + "github.com/thecodingmachine/gotenberg/internal/pkg/standarderror" ) type office struct { @@ -33,6 +34,8 @@ func NewOffice(fpaths []string, opts *OfficeOptions) Printer { } func (p *office) Print(destination string) error { + const op = "printer.office.Print" + // FIXME duration not working with float ctx, cancel := context.WithTimeout(context.Background(), time.Duration(p.opts.WaitTimeout)*time.Second) defer cancel() fpaths := make([]string, len(p.fpaths)) @@ -41,24 +44,31 @@ func (p *office) Print(destination string) error { baseFilename := random.String(32) tmpDest := fmt.Sprintf("%s/%d%s.pdf", dirPath, i, baseFilename) if err := unoconv(ctx, fpath, tmpDest, p.opts); err != nil { - return err + return &standarderror.Error{Op: op, Err: err} } fpaths[i] = tmpDest } if len(fpaths) == 1 { - return os.Rename(fpaths[0], destination) + if err := os.Rename(fpaths[0], destination); err != nil { + return &standarderror.Error{Op: op, Err: err} + } + return nil } m := &merge{ ctx: ctx, fpaths: fpaths, } - return m.Print(destination) + if err := m.Print(destination); err != nil { + return &standarderror.Error{Op: op, Err: err} + } + return nil } // nolint: gochecknoglobals var mu sync.Mutex func unoconv(ctx context.Context, fpath, destination string, opts *OfficeOptions) error { + const op = "printer.unoconv" mu.Lock() defer mu.Unlock() cmdArgs := []string{ @@ -76,7 +86,7 @@ func unoconv(ctx context.Context, fpath, destination string, opts *OfficeOptions ) _, err := cmd.Output() if err != nil { - return fmt.Errorf("unoconv: %v", err) + return &standarderror.Error{Op: op, Err: err} } return nil } From 9af00d6d583ebfa549051fdbc867d34a4ce32453 Mon Sep 17 00:00:00 2001 From: Julien Neuhart Date: Mon, 8 Jul 2019 17:24:17 +0200 Subject: [PATCH 16/34] fixes waitTimeout, waitDelay not working with float --- .../docs/content/03-environment-variables.md | 5 ++- build/docs/content/04-html.md | 5 ++- docs/index.html | 5 ++- internal/app/api/pkg/context/context.go | 1 - internal/pkg/printer/chrome.go | 8 ++-- internal/pkg/printer/merge.go | 5 +-- internal/pkg/printer/office.go | 5 +-- internal/pkg/timeout/doc.go | 3 ++ internal/pkg/timeout/timeout.go | 43 +++++++++++++++++++ internal/pkg/timeout/timeout_test.go | 38 ++++++++++++++++ test/testfunc.go | 10 +++++ 11 files changed, 110 insertions(+), 18 deletions(-) create mode 100644 internal/pkg/timeout/doc.go create mode 100644 internal/pkg/timeout/timeout.go create mode 100644 internal/pkg/timeout/timeout_test.go diff --git a/build/docs/content/03-environment-variables.md b/build/docs/content/03-environment-variables.md index 70653291..a9f75d32 100644 --- a/build/docs/content/03-environment-variables.md +++ b/build/docs/content/03-environment-variables.md @@ -37,7 +37,8 @@ By default, the API will add a log entry when the [healthcheck endpoint](#ping) You may turn off this logging so as to avoid unnecessary entries in your logs with the environment variable `DISABLE_HEALTHCHECK_LOGGING`. -This environment variable operates in the same manner as the `DISABLE_GOOGLE_CHROME` and `DISABLE_UNOCONV` variables operate in that it accepts the strings `"0"` or `"1"` as values, where `1` is enabled. +This environment variable operates in the same manner as the `DISABLE_GOOGLE_CHROME` and `DISABLE_UNOCONV` variables operate in that it accepts the strings `"0"` or `"1"` as values, where `"1"` is enabled. + ## Default listen port By default, the API will listen on port `3000`. For most use cases this is perfectly fine, but at times there may be cases where you need to change this due to port conflicts. @@ -52,4 +53,4 @@ By default, `stdout` and `stderr` messages from the started processes are disabl You may enable some debug logging from starting the process by setting the environment variable `DEBUG_PROCESS_STARTUP`. -This environment variable operates in the same manner as the `DISABLE_GOOGLE_CHROME` and `DISABLE_UNOCONV` variables operate in that it accepts the strings `"0"` or `"1"` as values, where `1` means `true`. \ No newline at end of file +This environment variable operates in the same manner as the `DISABLE_GOOGLE_CHROME` and `DISABLE_UNOCONV` variables operate in that it accepts the strings `"0"` or `"1"` as values, where `1` means `true`. diff --git a/build/docs/content/04-html.md b/build/docs/content/04-html.md index b2661e3c..4fdb6741 100644 --- a/build/docs/content/04-html.md +++ b/build/docs/content/04-html.md @@ -296,7 +296,8 @@ $client->store($request, $dest); ## Wait delay In some cases, you may want to wait a certain amount of time to make sure the -page you're trying to generate is fully rendered. +page you're trying to generate is fully rendered. For instance, if your page relies +a lot on JavaScript for rendering. > The wait delay is a duration in **seconds** (e.g `2.5` for 2.5 seconds). @@ -339,4 +340,4 @@ $request = new HTMLRequest($index); $request->setWaitDelay(5.5); $dest = "result.pdf"; $client->store($request, $dest); -``` \ No newline at end of file +``` diff --git a/docs/index.html b/docs/index.html index 6e1a1677..47e58226 100755 --- a/docs/index.html +++ b/docs/index.html @@ -272,7 +272,7 @@ See the timeout section. You may turn off this logging so as to avoid unnecessary entries in your logs with the environment variable
-DISABLE_HEALTHCHECK_LOGGING.This environment variable operates in the same manner as the
+DISABLE_GOOGLE_CHROMEandDISABLE_UNOCONVvariables operate in that it accepts the strings"0"or"1"as values, where1is enabled.This environment variable operates in the same manner as the
DISABLE_GOOGLE_CHROMEandDISABLE_UNOCONVvariables operate in that it accepts the strings"0"or"1"as values, where"1"is enabled.Wait delay
In some cases, you may want to wait a certain amount of time to make sure the -page you’re trying to generate is fully rendered.
+page you’re trying to generate is fully rendered. For instance, if your page relies +a lot on JavaScript for rendering.The wait delay is a duration in seconds (e.g
diff --git a/internal/app/api/pkg/context/context.go b/internal/app/api/pkg/context/context.go index 1ee8ab5b..e3673c35 100644 --- a/internal/app/api/pkg/context/context.go +++ b/internal/app/api/pkg/context/context.go @@ -23,7 +23,6 @@ type Context struct { // New creates a new context. func New(c echo.Context, logger *logger.Logger, config *config.Config) *Context { - // TODO timeout context? return &Context{ c, logger, diff --git a/internal/pkg/printer/chrome.go b/internal/pkg/printer/chrome.go index 3e1a483d..5bf36a9c 100644 --- a/internal/pkg/printer/chrome.go +++ b/internal/pkg/printer/chrome.go @@ -13,6 +13,7 @@ import ( "github.com/mafredri/cdp/protocol/target" "github.com/mafredri/cdp/rpcc" "github.com/thecodingmachine/gotenberg/internal/pkg/standarderror" + "github.com/thecodingmachine/gotenberg/internal/pkg/timeout" "golang.org/x/sync/errgroup" ) @@ -39,9 +40,7 @@ type ChromeOptions struct { func (p *chrome) Print(destination string) error { const op = "printer.chrome.Print" - // FIXME duration not working with float - duration := time.Duration(p.opts.WaitTimeout+p.opts.WaitDelay) * time.Second - ctx, cancel := context.WithTimeout(context.Background(), duration) + ctx, cancel := timeout.Context(p.opts.WaitTimeout + p.opts.WaitDelay) defer cancel() devt, err := devtool.New("http://localhost:9222").Version(ctx) if err != nil { @@ -153,8 +152,7 @@ func (p *chrome) navigate(ctx context.Context, client *cdp.Client) error { return &standarderror.Error{Op: op, Err: err} } // wait for a given amount of time (useful for javascript delay). - // FIXME duration not working with float - time.Sleep(time.Duration(p.opts.WaitDelay) * time.Second) + time.Sleep(timeout.Duration(p.opts.WaitDelay)) return nil } diff --git a/internal/pkg/printer/merge.go b/internal/pkg/printer/merge.go index 6393f5cf..cc096801 100644 --- a/internal/pkg/printer/merge.go +++ b/internal/pkg/printer/merge.go @@ -3,9 +3,9 @@ package printer import ( "context" "os/exec" - "time" "github.com/thecodingmachine/gotenberg/internal/pkg/standarderror" + "github.com/thecodingmachine/gotenberg/internal/pkg/timeout" ) type merge struct { @@ -31,8 +31,7 @@ func NewMerge(fpaths []string, opts *MergeOptions) Printer { func (p *merge) Print(destination string) error { const op = "printer.merge.Print" if p.ctx == nil { - // FIXME duration not working with float - ctx, cancel := context.WithTimeout(context.Background(), time.Duration(p.opts.WaitTimeout)*time.Second) + ctx, cancel := timeout.Context(p.opts.WaitTimeout) defer cancel() p.ctx = ctx } diff --git a/internal/pkg/printer/office.go b/internal/pkg/printer/office.go index 214da42b..d3eaec61 100644 --- a/internal/pkg/printer/office.go +++ b/internal/pkg/printer/office.go @@ -7,10 +7,10 @@ import ( "os/exec" "path/filepath" "sync" - "time" "github.com/labstack/gommon/random" "github.com/thecodingmachine/gotenberg/internal/pkg/standarderror" + "github.com/thecodingmachine/gotenberg/internal/pkg/timeout" ) type office struct { @@ -35,8 +35,7 @@ func NewOffice(fpaths []string, opts *OfficeOptions) Printer { func (p *office) Print(destination string) error { const op = "printer.office.Print" - // FIXME duration not working with float - ctx, cancel := context.WithTimeout(context.Background(), time.Duration(p.opts.WaitTimeout)*time.Second) + ctx, cancel := timeout.Context(p.opts.WaitTimeout) defer cancel() fpaths := make([]string, len(p.fpaths)) dirPath := filepath.Dir(destination) diff --git a/internal/pkg/timeout/doc.go b/internal/pkg/timeout/doc.go new file mode 100644 index 00000000..e8c95004 --- /dev/null +++ b/internal/pkg/timeout/doc.go @@ -0,0 +1,3 @@ +// Package timeout helps managing +// context with timeout. +package timeout diff --git a/internal/pkg/timeout/timeout.go b/internal/pkg/timeout/timeout.go new file mode 100644 index 00000000..7228ece2 --- /dev/null +++ b/internal/pkg/timeout/timeout.go @@ -0,0 +1,43 @@ +package timeout + +import ( + "context" + "strings" + "time" + + "github.com/thecodingmachine/gotenberg/internal/pkg/standarderror" +) + +// Context creates a context with timeout for +// given second. +func Context(seconds float64) (context.Context, context.CancelFunc) { + return context.WithTimeout(context.Background(), Duration(seconds)) +} + +// Duration creates a duration from seconds. +func Duration(seconds float64) time.Duration { + return time.Duration(1000*seconds) * time.Millisecond +} + +// Err returns a standarderror.Error +// if the context has an error. +func Err(ctx context.Context) error { + const op = "timeout.Err" + err := ctx.Err() + if err == nil { + return nil + } + if strings.Contains(err.Error(), context.DeadlineExceeded.Error()) { + return &standarderror.Error{ + Code: standarderror.Timeout, + Message: "context has timed out", + Op: op, + Err: err, + } + } + return &standarderror.Error{ + Message: "context finished with an error", + Op: op, + Err: err, + } +} diff --git a/internal/pkg/timeout/timeout_test.go b/internal/pkg/timeout/timeout_test.go new file mode 100644 index 00000000..23d4c06a --- /dev/null +++ b/internal/pkg/timeout/timeout_test.go @@ -0,0 +1,38 @@ +package timeout + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/thecodingmachine/gotenberg/internal/pkg/standarderror" + "github.com/thecodingmachine/gotenberg/test" +) + +func TestDuration(t *testing.T) { + expected := time.Duration(1500) * time.Millisecond + result := Duration(1.5) + assert.Equal(t, expected.String(), result.String()) +} + +func TestErr(t *testing.T) { + // should be OK. + ctx, cancel := Context(5) + defer cancel() + assert.Nil(t, Err(ctx)) + // should timeout. + ctx, cancel = Context(0.5) + defer cancel() + time.Sleep(Duration(1)) + err := Err(ctx) + assert.NotNil(t, err) + standardized := test.RequireStandardError(t, err) + assert.Equal(t, standardized.Code, standarderror.Timeout) + // should failed. + ctx, cancel = Context(5) + cancel() + err = Err(ctx) + assert.NotNil(t, err) + standardized = test.RequireStandardError(t, err) + assert.Equal(t, standarderror.Code(err), standarderror.Internal) +} diff --git a/test/testfunc.go b/test/testfunc.go index acdbafa0..bf1103b2 100644 --- a/test/testfunc.go +++ b/test/testfunc.go @@ -16,6 +16,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/thecodingmachine/gotenberg/internal/pkg/standarderror" "golang.org/x/sync/errgroup" ) @@ -39,6 +40,15 @@ func AssertConcurrent(t *testing.T, fn func() error, amount int) { assert.NoError(t, err) } +// RequireStandardError validates that given error +// is of an instance of standarderror.Error. +// If so, returns the instance of standarderror.Error. +func RequireStandardError(t *testing.T, err error) *standarderror.Error { + standardized, ok := err.(*standarderror.Error) + require.Equal(t, ok, true) + return standardized +} + // HTMLTestMultipartForm returns the body // for a multipate/form-data request with all // files under "html" folder. From 9c6f23d7ccf7ad0104702394cee66754ccd90e2c Mon Sep 17 00:00:00 2001 From: Julien Neuhart2.5for 2.5 seconds).Date: Mon, 8 Jul 2019 17:50:31 +0200 Subject: [PATCH 17/34] typo in timeout tests + adding tests for standarderror --- build/tests/docker-entrypoint.sh | 1 + internal/pkg/standarderror/standarderror.go | 4 +- .../pkg/standarderror/standarderror_test.go | 70 +++++++++++++++++++ internal/pkg/timeout/timeout_test.go | 4 +- test/testfunc.go | 2 +- 5 files changed, 77 insertions(+), 4 deletions(-) create mode 100644 internal/pkg/standarderror/standarderror_test.go diff --git a/build/tests/docker-entrypoint.sh b/build/tests/docker-entrypoint.sh index 91cc7d79..b4b0ea1d 100755 --- a/build/tests/docker-entrypoint.sh +++ b/build/tests/docker-entrypoint.sh @@ -9,6 +9,7 @@ go test github.com/thecodingmachine/gotenberg/internal/pkg/pm2 -run TestChromeSt go test github.com/thecodingmachine/gotenberg/internal/pkg/pm2 -run TestUnoconvStart # Running others tests. +go test -race -cover -covermode=atomic github.com/thecodingmachine/gotenberg/internal/pkg go test -race -cover -covermode=atomic github.com/thecodingmachine/gotenberg/internal/app/api # Finally testing processes shutdown. diff --git a/internal/pkg/standarderror/standarderror.go b/internal/pkg/standarderror/standarderror.go index 4391374e..f5ad9ec8 100644 --- a/internal/pkg/standarderror/standarderror.go +++ b/internal/pkg/standarderror/standarderror.go @@ -64,6 +64,8 @@ func Code(err error) string { return Internal } +const defaultMessage = "an internal error has occurred: please contact technical support" + // Message returns the human-readable message of the error, if available. // Otherwise returns a generic error message. func Message(err error) string { @@ -77,7 +79,7 @@ func Message(err error) string { if ok && e.Err != nil { return Message(e.Err) } - return "An internal error has occurred. Please contact technical support." + return defaultMessage } // Op returns the logical operation of the error, if available. diff --git a/internal/pkg/standarderror/standarderror_test.go b/internal/pkg/standarderror/standarderror_test.go new file mode 100644 index 00000000..eecd5631 --- /dev/null +++ b/internal/pkg/standarderror/standarderror_test.go @@ -0,0 +1,70 @@ +package standarderror + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" +) + +func scenario1() error { + rootErr := errors.New("root error") + nestedErr := &Error{ + Code: Invalid, + Op: "bar", + Message: "nested error", + Err: rootErr, + } + err := &Error{ + Op: "foo", + Err: nestedErr, + } + return err +} + +func scenario2() error { + nestedErr := &Error{ + Code: Invalid, + Op: "bar", + Message: "nested error", + } + err := &Error{ + Code: Internal, + Op: "foo", + Err: nestedErr, + } + return err +} + +func TestError(t *testing.T) { + err := scenario1() + assert.Equal(t, "root error", err.Error()) + err = scenario2() + assert.Equal(t, " nested error", err.Error()) +} + +func TestCode(t *testing.T) { + assert.Equal(t, "", Code(nil)) + err := scenario1() + assert.Equal(t, Invalid, Code(err)) + err = scenario2() + assert.Equal(t, Internal, Code(err)) + err = errors.New("some error") + assert.Equal(t, Internal, Code(err)) +} + +func TestMessage(t *testing.T) { + assert.Equal(t, "", Message(nil)) + err := scenario1() + assert.Equal(t, "nested error", Message(err)) + err = errors.New("some error") + assert.Equal(t, defaultMessage, Message(err)) +} + +func TestOp(t *testing.T) { + assert.Equal(t, "", Op(nil)) + err := scenario1() + assert.Equal(t, "foo: bar", Op(err)) + err = errors.New("some error") + assert.Equal(t, "", Op(err)) +} diff --git a/internal/pkg/timeout/timeout_test.go b/internal/pkg/timeout/timeout_test.go index 23d4c06a..9ab855e9 100644 --- a/internal/pkg/timeout/timeout_test.go +++ b/internal/pkg/timeout/timeout_test.go @@ -27,12 +27,12 @@ func TestErr(t *testing.T) { err := Err(ctx) assert.NotNil(t, err) standardized := test.RequireStandardError(t, err) - assert.Equal(t, standardized.Code, standarderror.Timeout) + assert.Equal(t, standarderror.Timeout, standardized.Code) // should failed. ctx, cancel = Context(5) cancel() err = Err(ctx) assert.NotNil(t, err) standardized = test.RequireStandardError(t, err) - assert.Equal(t, standarderror.Code(err), standarderror.Internal) + assert.Equal(t, standarderror.Internal, standarderror.Code(err)) } diff --git a/test/testfunc.go b/test/testfunc.go index bf1103b2..5fb43bb9 100644 --- a/test/testfunc.go +++ b/test/testfunc.go @@ -45,7 +45,7 @@ func AssertConcurrent(t *testing.T, fn func() error, amount int) { // If so, returns the instance of standarderror.Error. func RequireStandardError(t *testing.T, err error) *standarderror.Error { standardized, ok := err.(*standarderror.Error) - require.Equal(t, ok, true) + require.Equal(t, true, ok) return standardized } From 9ab8d036973189faafee91fd66a152aa4f171718 Mon Sep 17 00:00:00 2001 From: Julien Neuhart Date: Mon, 8 Jul 2019 18:06:43 +0200 Subject: [PATCH 18/34] adding tests for random pkg --- build/tests/docker-entrypoint.sh | 4 +++- internal/pkg/random/random_test.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 internal/pkg/random/random_test.go diff --git a/build/tests/docker-entrypoint.sh b/build/tests/docker-entrypoint.sh index b4b0ea1d..476b166f 100755 --- a/build/tests/docker-entrypoint.sh +++ b/build/tests/docker-entrypoint.sh @@ -9,7 +9,9 @@ go test github.com/thecodingmachine/gotenberg/internal/pkg/pm2 -run TestChromeSt go test github.com/thecodingmachine/gotenberg/internal/pkg/pm2 -run TestUnoconvStart # Running others tests. -go test -race -cover -covermode=atomic github.com/thecodingmachine/gotenberg/internal/pkg +go test -race -cover -covermode=atomic github.com/thecodingmachine/gotenberg/internal/pkg/random +go test -race -cover -covermode=atomic github.com/thecodingmachine/gotenberg/internal/pkg/standarderror +go test -race -cover -covermode=atomic github.com/thecodingmachine/gotenberg/internal/pkg/timeout go test -race -cover -covermode=atomic github.com/thecodingmachine/gotenberg/internal/app/api # Finally testing processes shutdown. diff --git a/internal/pkg/random/random_test.go b/internal/pkg/random/random_test.go new file mode 100644 index 00000000..b7a37060 --- /dev/null +++ b/internal/pkg/random/random_test.go @@ -0,0 +1,28 @@ +package random + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGet(t *testing.T) { + var rands []string + // use case: for 1 000 concurrent + // requests (which is a big Gotenberg instance), + // none should have the identifier. + for i := 0; i < 1000; i++ { + rands = append(rands, Get()) + } + unique := func() bool { + for i, rand := range rands { + for j, current := range rands { + if i != j && rand == current { + return false + } + } + } + return true + } + assert.Equal(t, true, unique()) +} From acd47b870f10206b27a1cadc3afe0484ae15a4f7 Mon Sep 17 00:00:00 2001 From: Julien Neuhart Date: Mon, 8 Jul 2019 18:07:44 +0200 Subject: [PATCH 19/34] fixing typo in godoc in pkg random --- internal/pkg/random/random_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/pkg/random/random_test.go b/internal/pkg/random/random_test.go index b7a37060..aaf94f17 100644 --- a/internal/pkg/random/random_test.go +++ b/internal/pkg/random/random_test.go @@ -10,7 +10,7 @@ func TestGet(t *testing.T) { var rands []string // use case: for 1 000 concurrent // requests (which is a big Gotenberg instance), - // none should have the identifier. + // none should have the same identifier. for i := 0; i < 1000; i++ { rands = append(rands, Get()) } From 1b4eed1a6e87e742d324ceba4093e40d7806a245 Mon Sep 17 00:00:00 2001 From: Julien Neuhart Date: Mon, 8 Jul 2019 18:26:17 +0200 Subject: [PATCH 20/34] fixing pm2 tests + adding pkg config tests --- internal/pkg/config/config_test.go | 108 +++++++++++++++++++++++++++++ internal/pkg/pm2/chrome_test.go | 5 +- internal/pkg/pm2/unoconv_test.go | 5 +- test/testfunc.go | 8 +++ 4 files changed, 122 insertions(+), 4 deletions(-) create mode 100644 internal/pkg/config/config_test.go diff --git a/internal/pkg/config/config_test.go b/internal/pkg/config/config_test.go new file mode 100644 index 00000000..83dc4478 --- /dev/null +++ b/internal/pkg/config/config_test.go @@ -0,0 +1,108 @@ +package config + +import ( + "os" + "testing" + + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/thecodingmachine/gotenberg/internal/pkg/standarderror" + "github.com/thecodingmachine/gotenberg/test" +) + +func TestDefaultWaitTimeout(t *testing.T) { + // should be OK. + os.Setenv(defaultWaitTimeoutEnvVar, "1.5") + config, err := FromEnv() + assert.Nil(t, err) + assert.Equal(t, 1.5, config.DefaultWaitTimeout()) + // should failed. + os.Setenv(defaultWaitTimeoutEnvVar, "foo") + _, err = FromEnv() + assert.NotNil(t, err) + standardized := test.RequireStandardError(t, err) + assert.Equal(t, standarderror.Invalid, standarderror.Code(standardized)) + os.Unsetenv(defaultWaitTimeoutEnvVar) +} + +func TestDefaultListenPort(t *testing.T) { + // should be OK. + os.Setenv(defaultListenPortEnvVar, "4000") + config, err := FromEnv() + assert.Nil(t, err) + assert.Equal(t, "4000", config.DefaultListenPort()) + // should failed. + os.Setenv(defaultListenPortEnvVar, "foo") + _, err = FromEnv() + assert.NotNil(t, err) + standardized := test.RequireStandardError(t, err) + assert.Equal(t, standarderror.Invalid, standarderror.Code(standardized)) + os.Setenv(defaultListenPortEnvVar, "100000000") + _, err = FromEnv() + assert.NotNil(t, err) + standardized = test.RequireStandardError(t, err) + assert.Equal(t, standarderror.Invalid, standarderror.Code(standardized)) + os.Unsetenv(defaultListenPortEnvVar) +} + +func TestEnableChromeEndpoints(t *testing.T) { + // should be OK. + os.Setenv(disableGoogleChromeEnvVar, "1") + config, err := FromEnv() + assert.Nil(t, err) + assert.Equal(t, false, config.EnableChromeEndpoints()) + os.Setenv(disableGoogleChromeEnvVar, "0") + config, err = FromEnv() + assert.Nil(t, err) + assert.Equal(t, true, config.EnableChromeEndpoints()) + // should failed. + os.Setenv(disableGoogleChromeEnvVar, "true") + _, err = FromEnv() + assert.NotNil(t, err) + standardized := test.RequireStandardError(t, err) + assert.Equal(t, standarderror.Invalid, standarderror.Code(standardized)) + os.Unsetenv(disableGoogleChromeEnvVar) +} + +func TestEnableUnoconvEndpoints(t *testing.T) { + // should be OK. + os.Setenv(disableUnoconvEnvVar, "1") + config, err := FromEnv() + assert.Nil(t, err) + assert.Equal(t, false, config.EnableUnoconvEndpoints()) + os.Setenv(disableUnoconvEnvVar, "0") + config, err = FromEnv() + assert.Nil(t, err) + assert.Equal(t, true, config.EnableUnoconvEndpoints()) + // should failed. + os.Setenv(disableUnoconvEnvVar, "true") + _, err = FromEnv() + assert.NotNil(t, err) + standardized := test.RequireStandardError(t, err) + assert.Equal(t, standarderror.Invalid, standarderror.Code(standardized)) + os.Unsetenv(disableUnoconvEnvVar) +} + +func TestLogLevel(t *testing.T) { + // should be OK. + os.Setenv(logLevelEnvVar, "DEBUG") + config, err := FromEnv() + assert.Nil(t, err) + assert.Equal(t, logrus.DebugLevel, config.LogLevel()) + os.Setenv(logLevelEnvVar, "INFO") + config, err = FromEnv() + assert.Nil(t, err) + assert.Equal(t, logrus.InfoLevel, config.LogLevel()) + os.Setenv(logLevelEnvVar, "ERROR") + config, err = FromEnv() + assert.Nil(t, err) + assert.Equal(t, logrus.ErrorLevel, config.LogLevel()) + // should failed. + os.Setenv(logLevelEnvVar, "foo") + config, err = FromEnv() + assert.Equal(t, logrus.InfoLevel, config.LogLevel()) + assert.NotNil(t, err) + standardized := test.RequireStandardError(t, err) + assert.Equal(t, standarderror.Invalid, standarderror.Code(standardized)) + os.Unsetenv(logLevelEnvVar) +} diff --git a/internal/pkg/pm2/chrome_test.go b/internal/pkg/pm2/chrome_test.go index edb8b5af..2fea8458 100644 --- a/internal/pkg/pm2/chrome_test.go +++ b/internal/pkg/pm2/chrome_test.go @@ -4,16 +4,17 @@ import ( "testing" "github.com/stretchr/testify/require" + "github.com/thecodingmachine/gotenberg/test" ) func TestChromeStart(t *testing.T) { - p := NewChrome(false) + p := NewChrome(test.CreateTestLogger()) err := p.Start() require.Nil(t, err) } func TestChromeShutdown(t *testing.T) { - p := NewChrome(false) + p := NewChrome(test.CreateTestLogger()) err := p.Shutdown() require.Nil(t, err) } diff --git a/internal/pkg/pm2/unoconv_test.go b/internal/pkg/pm2/unoconv_test.go index 6995109e..4c5fb8bf 100644 --- a/internal/pkg/pm2/unoconv_test.go +++ b/internal/pkg/pm2/unoconv_test.go @@ -4,16 +4,17 @@ import ( "testing" "github.com/stretchr/testify/require" + "github.com/thecodingmachine/gotenberg/test" ) func TestUnoconvStart(t *testing.T) { - p := NewUnoconv(false) + p := NewUnoconv(test.CreateTestLogger()) err := p.Start() require.Nil(t, err) } func TestUnoconvShutdown(t *testing.T) { - p := NewUnoconv(false) + p := NewUnoconv(test.CreateTestLogger()) err := p.Shutdown() require.Nil(t, err) } diff --git a/test/testfunc.go b/test/testfunc.go index 5fb43bb9..428d209a 100644 --- a/test/testfunc.go +++ b/test/testfunc.go @@ -14,8 +14,10 @@ import ( "runtime" "testing" + "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/thecodingmachine/gotenberg/internal/pkg/logger" "github.com/thecodingmachine/gotenberg/internal/pkg/standarderror" "golang.org/x/sync/errgroup" ) @@ -49,6 +51,12 @@ func RequireStandardError(t *testing.T, err error) *standarderror.Error { return standardized } +// CreateTestLogger create a default logger +// for our tests. +func CreateTestLogger() *logger.Logger { + return logger.New(logrus.DebugLevel, "tests") +} + // HTMLTestMultipartForm returns the body // for a multipate/form-data request with all // files under "html" folder. From 957b9b1cf42ba82f2ddab3dcc4acffb195bc7051 Mon Sep 17 00:00:00 2001 From: Julien Neuhart Date: Mon, 8 Jul 2019 18:28:13 +0200 Subject: [PATCH 21/34] adding config to test suite in the Dockerfile entrypoint --- build/tests/docker-entrypoint.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/build/tests/docker-entrypoint.sh b/build/tests/docker-entrypoint.sh index 476b166f..21a86c41 100755 --- a/build/tests/docker-entrypoint.sh +++ b/build/tests/docker-entrypoint.sh @@ -9,6 +9,7 @@ go test github.com/thecodingmachine/gotenberg/internal/pkg/pm2 -run TestChromeSt go test github.com/thecodingmachine/gotenberg/internal/pkg/pm2 -run TestUnoconvStart # Running others tests. +go test -race -cover -covermode=atomic github.com/thecodingmachine/gotenberg/internal/pkg/config go test -race -cover -covermode=atomic github.com/thecodingmachine/gotenberg/internal/pkg/random go test -race -cover -covermode=atomic github.com/thecodingmachine/gotenberg/internal/pkg/standarderror go test -race -cover -covermode=atomic github.com/thecodingmachine/gotenberg/internal/pkg/timeout From c7ecdcf6256805610f69555cfa99c7f5860e3970 Mon Sep 17 00:00:00 2001 From: Julien Neuhart Date: Tue, 9 Jul 2019 19:39:14 +0200 Subject: [PATCH 22/34] handling context timeout/cancelled in printer package --- build/tests/docker-entrypoint.sh | 1 + internal/app/api/pkg/context/context.go | 4 ++- internal/pkg/printer/chrome.go | 16 +++++------ internal/pkg/printer/merge.go | 2 +- internal/pkg/printer/office.go | 2 +- internal/pkg/printer/printer.go | 32 ++++++++++++++++++++++ internal/pkg/printer/printer_test.go | 35 +++++++++++++++++++++++++ internal/pkg/timeout/timeout.go | 26 ------------------ internal/pkg/timeout/timeout_test.go | 24 ----------------- 9 files changed, 81 insertions(+), 61 deletions(-) create mode 100644 internal/pkg/printer/printer_test.go diff --git a/build/tests/docker-entrypoint.sh b/build/tests/docker-entrypoint.sh index 21a86c41..49aa729b 100755 --- a/build/tests/docker-entrypoint.sh +++ b/build/tests/docker-entrypoint.sh @@ -13,6 +13,7 @@ go test -race -cover -covermode=atomic github.com/thecodingmachine/gotenberg/int go test -race -cover -covermode=atomic github.com/thecodingmachine/gotenberg/internal/pkg/random go test -race -cover -covermode=atomic github.com/thecodingmachine/gotenberg/internal/pkg/standarderror go test -race -cover -covermode=atomic github.com/thecodingmachine/gotenberg/internal/pkg/timeout +go test -race -cover -covermode=atomic github.com/thecodingmachine/gotenberg/internal/pkg/printer go test -race -cover -covermode=atomic github.com/thecodingmachine/gotenberg/internal/app/api # Finally testing processes shutdown. diff --git a/internal/app/api/pkg/context/context.go b/internal/app/api/pkg/context/context.go index e3673c35..99e70532 100644 --- a/internal/app/api/pkg/context/context.go +++ b/internal/app/api/pkg/context/context.go @@ -1,6 +1,7 @@ package context import ( + "fmt" "net/http" "strconv" "time" @@ -35,9 +36,10 @@ func New(c echo.Context, logger *logger.Logger, config *config.Config) *Context // MustCastFromEchoContext cast an echo.Context to our custom // context. If something goes wrong, panic. func MustCastFromEchoContext(c echo.Context) *Context { + const op = "MustCastFromEchoContext" ctx, ok := c.(*Context) if !ok { - panic("unable to cast an echo.Context to a custom context") + panic(fmt.Sprintf("%s: unable to cast an echo.Context to a custom context", op)) } return ctx } diff --git a/internal/pkg/printer/chrome.go b/internal/pkg/printer/chrome.go index 5bf36a9c..fe1d181a 100644 --- a/internal/pkg/printer/chrome.go +++ b/internal/pkg/printer/chrome.go @@ -44,19 +44,19 @@ func (p *chrome) Print(destination string) error { defer cancel() devt, err := devtool.New("http://localhost:9222").Version(ctx) if err != nil { - return &standarderror.Error{Op: op, Err: err} + return handleErrContext(ctx, &standarderror.Error{Op: op, Err: err}) } // connect to WebSocket URL (page) that speaks the Chrome DevTools Protocol. devtConn, err := rpcc.DialContext(ctx, devt.WebSocketDebuggerURL) if err != nil { - return &standarderror.Error{Op: op, Err: err} + return handleErrContext(ctx, &standarderror.Error{Op: op, Err: err}) } defer devtConn.Close() // nolint: errcheck // create a new CDP Client that uses conn. devtClient := cdp.NewClient(devtConn) newContextTarget, err := devtClient.Target.CreateBrowserContext(ctx) if err != nil { - return &standarderror.Error{Op: op, Err: err} + return handleErrContext(ctx, &standarderror.Error{Op: op, Err: err}) } // create a new blank target with the new browser context. createTargetArgs := target. @@ -64,13 +64,13 @@ func (p *chrome) Print(destination string) error { SetBrowserContextID(newContextTarget.BrowserContextID) newTarget, err := devtClient.Target.CreateTarget(ctx, createTargetArgs) if err != nil { - return &standarderror.Error{Op: op, Err: err} + return handleErrContext(ctx, &standarderror.Error{Op: op, Err: err}) } // connect the client to the new target. newTargetWsURL := fmt.Sprintf("ws://127.0.0.1:9222/devtools/page/%s", newTarget.TargetID) newContextConn, err := rpcc.DialContext(ctx, newTargetWsURL) if err != nil { - return &standarderror.Error{Op: op, Err: err} + return handleErrContext(ctx, &standarderror.Error{Op: op, Err: err}) } defer newContextConn.Close() // nolint: errcheck // create a new CDP Client that uses newContextConn. @@ -85,10 +85,10 @@ func (p *chrome) Print(destination string) error { func() error { return targetClient.Page.Enable(ctx) }, func() error { return targetClient.Runtime.Enable(ctx) }, ); err != nil { - return &standarderror.Error{Op: op, Err: err} + return handleErrContext(ctx, &standarderror.Error{Op: op, Err: err}) } if err := p.navigate(ctx, targetClient); err != nil { - return &standarderror.Error{Op: op, Err: err} + return handleErrContext(ctx, &standarderror.Error{Op: op, Err: err}) } print, err := targetClient.Page.PrintToPDF( ctx, @@ -106,7 +106,7 @@ func (p *chrome) Print(destination string) error { SetPrintBackground(true), ) if err != nil { - return &standarderror.Error{Op: op, Err: err} + return handleErrContext(ctx, &standarderror.Error{Op: op, Err: err}) } if err := ioutil.WriteFile(destination, print.Data, 0644); err != nil { return &standarderror.Error{Op: op, Err: err} diff --git a/internal/pkg/printer/merge.go b/internal/pkg/printer/merge.go index cc096801..746a5ee1 100644 --- a/internal/pkg/printer/merge.go +++ b/internal/pkg/printer/merge.go @@ -41,7 +41,7 @@ func (p *merge) Print(destination string) error { cmd := exec.CommandContext(p.ctx, "pdftk", cmdArgs...) _, err := cmd.Output() if err != nil { - return &standarderror.Error{Op: op, Err: err} + return handleErrContext(p.ctx, &standarderror.Error{Op: op, Err: err}) } return nil } diff --git a/internal/pkg/printer/office.go b/internal/pkg/printer/office.go index d3eaec61..26db09e9 100644 --- a/internal/pkg/printer/office.go +++ b/internal/pkg/printer/office.go @@ -43,7 +43,7 @@ func (p *office) Print(destination string) error { baseFilename := random.String(32) tmpDest := fmt.Sprintf("%s/%d%s.pdf", dirPath, i, baseFilename) if err := unoconv(ctx, fpath, tmpDest, p.opts); err != nil { - return &standarderror.Error{Op: op, Err: err} + return handleErrContext(ctx, &standarderror.Error{Op: op, Err: err}) } fpaths[i] = tmpDest } diff --git a/internal/pkg/printer/printer.go b/internal/pkg/printer/printer.go index e1e72497..5fff84c7 100644 --- a/internal/pkg/printer/printer.go +++ b/internal/pkg/printer/printer.go @@ -1,7 +1,39 @@ package printer +import ( + "context" + "fmt" + "strings" + + "github.com/thecodingmachine/gotenberg/internal/pkg/standarderror" +) + // Printer is a type that can create a PDF file from a source. // The source is defined in the underlying implementation. type Printer interface { Print(destination string) error } + +func handleErrContext(ctx context.Context, previousErr error) error { + const op = "printer.handleErrContext" + if previousErr == nil { + panic(fmt.Sprintf("%s: previous error should not be nil", op)) + } + err := ctx.Err() + if err == nil { + return previousErr + } + if strings.Contains(err.Error(), context.DeadlineExceeded.Error()) { + return &standarderror.Error{ + Code: standarderror.Timeout, + Message: "context has timed out", + Op: op, + Err: previousErr, + } + } + return &standarderror.Error{ + Message: "context finished with an error", + Op: op, + Err: previousErr, + } +} diff --git a/internal/pkg/printer/printer_test.go b/internal/pkg/printer/printer_test.go new file mode 100644 index 00000000..d844ac72 --- /dev/null +++ b/internal/pkg/printer/printer_test.go @@ -0,0 +1,35 @@ +package printer + +import ( + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/thecodingmachine/gotenberg/internal/pkg/standarderror" + "github.com/thecodingmachine/gotenberg/internal/pkg/timeout" + "github.com/thecodingmachine/gotenberg/test" +) + +func TestHandlerErr(t *testing.T) { + previousErr := errors.New("previous error") + // should be OK. + ctx, cancel := timeout.Context(5) + defer cancel() + assert.NotNil(t, handleErrContext(ctx, previousErr)) + // should timeout. + ctx, cancel = timeout.Context(0.5) + defer cancel() + time.Sleep(timeout.Duration(1)) + err := handleErrContext(ctx, previousErr) + assert.NotNil(t, err) + standardized := test.RequireStandardError(t, err) + assert.Equal(t, standarderror.Timeout, standardized.Code) + // should failed. + ctx, cancel = timeout.Context(5) + cancel() + err = handleErrContext(ctx, previousErr) + assert.NotNil(t, err) + standardized = test.RequireStandardError(t, err) + assert.Equal(t, standarderror.Internal, standarderror.Code(err)) +} diff --git a/internal/pkg/timeout/timeout.go b/internal/pkg/timeout/timeout.go index 7228ece2..f29a03d0 100644 --- a/internal/pkg/timeout/timeout.go +++ b/internal/pkg/timeout/timeout.go @@ -2,10 +2,7 @@ package timeout import ( "context" - "strings" "time" - - "github.com/thecodingmachine/gotenberg/internal/pkg/standarderror" ) // Context creates a context with timeout for @@ -18,26 +15,3 @@ func Context(seconds float64) (context.Context, context.CancelFunc) { func Duration(seconds float64) time.Duration { return time.Duration(1000*seconds) * time.Millisecond } - -// Err returns a standarderror.Error -// if the context has an error. -func Err(ctx context.Context) error { - const op = "timeout.Err" - err := ctx.Err() - if err == nil { - return nil - } - if strings.Contains(err.Error(), context.DeadlineExceeded.Error()) { - return &standarderror.Error{ - Code: standarderror.Timeout, - Message: "context has timed out", - Op: op, - Err: err, - } - } - return &standarderror.Error{ - Message: "context finished with an error", - Op: op, - Err: err, - } -} diff --git a/internal/pkg/timeout/timeout_test.go b/internal/pkg/timeout/timeout_test.go index 9ab855e9..43f21e5c 100644 --- a/internal/pkg/timeout/timeout_test.go +++ b/internal/pkg/timeout/timeout_test.go @@ -5,8 +5,6 @@ import ( "time" "github.com/stretchr/testify/assert" - "github.com/thecodingmachine/gotenberg/internal/pkg/standarderror" - "github.com/thecodingmachine/gotenberg/test" ) func TestDuration(t *testing.T) { @@ -14,25 +12,3 @@ func TestDuration(t *testing.T) { result := Duration(1.5) assert.Equal(t, expected.String(), result.String()) } - -func TestErr(t *testing.T) { - // should be OK. - ctx, cancel := Context(5) - defer cancel() - assert.Nil(t, Err(ctx)) - // should timeout. - ctx, cancel = Context(0.5) - defer cancel() - time.Sleep(Duration(1)) - err := Err(ctx) - assert.NotNil(t, err) - standardized := test.RequireStandardError(t, err) - assert.Equal(t, standarderror.Timeout, standardized.Code) - // should failed. - ctx, cancel = Context(5) - cancel() - err = Err(ctx) - assert.NotNil(t, err) - standardized = test.RequireStandardError(t, err) - assert.Equal(t, standarderror.Internal, standarderror.Code(err)) -} From 0c1e4e6888b9086b2e787063e5938113f297d9b1 Mon Sep 17 00:00:00 2001 From: Julien Neuhart Date: Tue, 9 Jul 2019 22:22:22 +0200 Subject: [PATCH 23/34] WIP: tests of api pkg --- internal/app/api/pkg/context/context.go | 2 +- internal/app/api/pkg/middleware/context.go | 24 +++++++++++++++++++-- internal/app/api/pkg/middleware/error.go | 5 +++++ internal/pkg/config/config.go | 4 ++-- internal/pkg/config/config_test.go | 25 +++++++++++++++++----- internal/pkg/timeout/doc.go | 2 +- internal/pkg/timeout/timeout_test.go | 6 ++++++ test/testfunc.go | 10 +++++++++ 8 files changed, 67 insertions(+), 11 deletions(-) diff --git a/internal/app/api/pkg/context/context.go b/internal/app/api/pkg/context/context.go index 99e70532..2b32c949 100644 --- a/internal/app/api/pkg/context/context.go +++ b/internal/app/api/pkg/context/context.go @@ -36,7 +36,7 @@ func New(c echo.Context, logger *logger.Logger, config *config.Config) *Context // MustCastFromEchoContext cast an echo.Context to our custom // context. If something goes wrong, panic. func MustCastFromEchoContext(c echo.Context) *Context { - const op = "MustCastFromEchoContext" + const op = "context.MustCastFromEchoContext" ctx, ok := c.(*Context) if !ok { panic(fmt.Sprintf("%s: unable to cast an echo.Context to a custom context", op)) diff --git a/internal/app/api/pkg/middleware/context.go b/internal/app/api/pkg/middleware/context.go index c711683b..d4da5100 100644 --- a/internal/app/api/pkg/middleware/context.go +++ b/internal/app/api/pkg/middleware/context.go @@ -1,6 +1,9 @@ package middleware import ( + "fmt" + "os" + "github.com/labstack/echo/v4" "github.com/thecodingmachine/gotenberg/internal/app/api/pkg/context" "github.com/thecodingmachine/gotenberg/internal/app/api/pkg/handler" @@ -9,13 +12,30 @@ import ( "github.com/thecodingmachine/gotenberg/internal/pkg/random" ) +const ( + // TestingTraceEnvVar is an environment + // variable used in some tests. + TestingTraceEnvVar = "TESTING_TRACE" + // TestsTracePrefix helps + // creating all resources inside a prefix. + // Only used in some tests + // to check if the resources + // have been removed. + TestsTracePrefix = "tmp" +) + // Context helps extending the default echo.Context with // our custom context. func Context(config *config.Config) echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { - // generate a unique identifier for the request. - trace := random.Get() + var trace string + if os.Getenv(TestingTraceEnvVar) == "1" { + trace = fmt.Sprintf("%s/%s", TestsTracePrefix, random.Get()) + } else { + // generate a unique identifier for the request. + trace = random.Get() + } // create the logger for this request using // the previous identifier as trace. logger := logger.New(config.LogLevel(), trace) diff --git a/internal/app/api/pkg/middleware/error.go b/internal/app/api/pkg/middleware/error.go index 876459db..f5254e02 100644 --- a/internal/app/api/pkg/middleware/error.go +++ b/internal/app/api/pkg/middleware/error.go @@ -18,6 +18,11 @@ func Error() echo.MiddlewareFunc { // so far so good! return nil } + // if it's an error from echo + // like 404 not found and so on. + if echoHTTPErr, ok := err.(*echo.HTTPError); ok { + return echoHTTPErr + } // we log the initial error before returning // the HTTP error. errOp := standarderror.Op(err) diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index 59246ede..6379a18e 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -52,12 +52,12 @@ func FromEnv() (*Config, error) { if err != nil { return c, &standarderror.Error{Op: op, Err: err} } - disableChromeEndpoints, err := boolFromEnv(disableGoogleChromeEnvVar, c.EnableChromeEndpoints()) + disableChromeEndpoints, err := boolFromEnv(disableGoogleChromeEnvVar, !c.EnableChromeEndpoints()) c.enableChromeEndpoints = !disableChromeEndpoints if err != nil { return c, &standarderror.Error{Op: op, Err: err} } - disableUnoconvEndpoints, err := boolFromEnv(disableUnoconvEnvVar, c.EnableUnoconvEndpoints()) + disableUnoconvEndpoints, err := boolFromEnv(disableUnoconvEnvVar, !c.EnableUnoconvEndpoints()) c.enableUnoconvEndpoints = !disableUnoconvEndpoints if err != nil { return c, &standarderror.Error{Op: op, Err: err} diff --git a/internal/pkg/config/config_test.go b/internal/pkg/config/config_test.go index 83dc4478..72efa520 100644 --- a/internal/pkg/config/config_test.go +++ b/internal/pkg/config/config_test.go @@ -12,9 +12,12 @@ import ( func TestDefaultWaitTimeout(t *testing.T) { // should be OK. - os.Setenv(defaultWaitTimeoutEnvVar, "1.5") config, err := FromEnv() assert.Nil(t, err) + assert.Equal(t, 10.0, config.DefaultWaitTimeout()) + os.Setenv(defaultWaitTimeoutEnvVar, "1.5") + config, err = FromEnv() + assert.Nil(t, err) assert.Equal(t, 1.5, config.DefaultWaitTimeout()) // should failed. os.Setenv(defaultWaitTimeoutEnvVar, "foo") @@ -27,9 +30,12 @@ func TestDefaultWaitTimeout(t *testing.T) { func TestDefaultListenPort(t *testing.T) { // should be OK. - os.Setenv(defaultListenPortEnvVar, "4000") config, err := FromEnv() assert.Nil(t, err) + assert.Equal(t, "3000", config.DefaultListenPort()) + os.Setenv(defaultListenPortEnvVar, "4000") + config, err = FromEnv() + assert.Nil(t, err) assert.Equal(t, "4000", config.DefaultListenPort()) // should failed. os.Setenv(defaultListenPortEnvVar, "foo") @@ -47,9 +53,12 @@ func TestDefaultListenPort(t *testing.T) { func TestEnableChromeEndpoints(t *testing.T) { // should be OK. - os.Setenv(disableGoogleChromeEnvVar, "1") config, err := FromEnv() assert.Nil(t, err) + assert.Equal(t, true, config.EnableChromeEndpoints()) + os.Setenv(disableGoogleChromeEnvVar, "1") + config, err = FromEnv() + assert.Nil(t, err) assert.Equal(t, false, config.EnableChromeEndpoints()) os.Setenv(disableGoogleChromeEnvVar, "0") config, err = FromEnv() @@ -66,9 +75,12 @@ func TestEnableChromeEndpoints(t *testing.T) { func TestEnableUnoconvEndpoints(t *testing.T) { // should be OK. - os.Setenv(disableUnoconvEnvVar, "1") config, err := FromEnv() assert.Nil(t, err) + assert.Equal(t, true, config.EnableUnoconvEndpoints()) + os.Setenv(disableUnoconvEnvVar, "1") + config, err = FromEnv() + assert.Nil(t, err) assert.Equal(t, false, config.EnableUnoconvEndpoints()) os.Setenv(disableUnoconvEnvVar, "0") config, err = FromEnv() @@ -85,9 +97,12 @@ func TestEnableUnoconvEndpoints(t *testing.T) { func TestLogLevel(t *testing.T) { // should be OK. - os.Setenv(logLevelEnvVar, "DEBUG") config, err := FromEnv() assert.Nil(t, err) + assert.Equal(t, logrus.InfoLevel, config.LogLevel()) + os.Setenv(logLevelEnvVar, "DEBUG") + config, err = FromEnv() + assert.Nil(t, err) assert.Equal(t, logrus.DebugLevel, config.LogLevel()) os.Setenv(logLevelEnvVar, "INFO") config, err = FromEnv() diff --git a/internal/pkg/timeout/doc.go b/internal/pkg/timeout/doc.go index e8c95004..771db613 100644 --- a/internal/pkg/timeout/doc.go +++ b/internal/pkg/timeout/doc.go @@ -1,3 +1,3 @@ -// Package timeout helps managing +// Package timeout helps creating // context with timeout. package timeout diff --git a/internal/pkg/timeout/timeout_test.go b/internal/pkg/timeout/timeout_test.go index 43f21e5c..7fa27881 100644 --- a/internal/pkg/timeout/timeout_test.go +++ b/internal/pkg/timeout/timeout_test.go @@ -7,6 +7,12 @@ import ( "github.com/stretchr/testify/assert" ) +func TestContext(t *testing.T) { + ctx, cancel := Context(1.5) + assert.NotNil(t, ctx) + assert.NotNil(t, cancel) +} + func TestDuration(t *testing.T) { expected := time.Duration(1500) * time.Millisecond result := Duration(1.5) diff --git a/test/testfunc.go b/test/testfunc.go index 428d209a..c21ba9d5 100644 --- a/test/testfunc.go +++ b/test/testfunc.go @@ -30,6 +30,16 @@ func AssertStatusCode(t *testing.T, expectedStatusCode int, srv http.Handler, re assert.Equal(t, expectedStatusCode, rec.Code) } +// AssertDirectoryEmpty checks if given directory +// is empty. +func AssertDirectoryEmpty(t *testing.T, directory string) { + f, err := os.Open(directory) + assert.Nil(t, err) + defer f.Close() // nolint: errcheck + _, err = f.Readdir(1) + assert.Equal(t, io.EOF, err) +} + // AssertConcurrent runs all functions simultaneously // and wait until execution has completed // or an error is encountered. From f6b357691ce09eb8ecea58ef1ef569ceb575de76 Mon Sep 17 00:00:00 2001 From: Julien Neuhart Date: Wed, 10 Jul 2019 11:46:58 +0200 Subject: [PATCH 24/34] minor refactoring of const + better timeout handling in pinter package + api package tests --- build/tests/docker-entrypoint.sh | 1 - internal/app/api/api_test.go | 388 ++++++++++++++++++++ internal/app/api/pkg/context/context.go | 6 +- internal/app/api/pkg/handler/handler.go | 2 +- internal/app/api/pkg/handler/html.go | 2 +- internal/app/api/pkg/handler/markdown.go | 2 +- internal/app/api/pkg/handler/merge.go | 2 +- internal/app/api/pkg/handler/office.go | 2 +- internal/app/api/pkg/handler/url.go | 2 +- internal/app/api/pkg/middleware/cleanup.go | 2 +- internal/app/api/pkg/middleware/context.go | 4 +- internal/app/api/pkg/resource/resource.go | 28 +- internal/pkg/config/config.go | 20 +- internal/pkg/pm2/chrome.go | 10 +- internal/pkg/pm2/pm2.go | 8 +- internal/pkg/pm2/unoconv.go | 8 +- internal/pkg/printer/chrome.go | 144 ++++---- internal/pkg/printer/markdown.go | 4 +- internal/pkg/printer/merge.go | 22 +- internal/pkg/printer/office.go | 24 +- internal/pkg/printer/printer.go | 32 -- internal/pkg/printer/printer_test.go | 35 -- internal/pkg/standarderror/standarderror.go | 2 +- internal/pkg/timeout/doc.go | 2 +- internal/pkg/timeout/timeout.go | 30 ++ internal/pkg/timeout/timeout_test.go | 32 +- test/testfunc.go | 3 + 27 files changed, 604 insertions(+), 213 deletions(-) create mode 100644 internal/app/api/api_test.go delete mode 100644 internal/pkg/printer/printer_test.go diff --git a/build/tests/docker-entrypoint.sh b/build/tests/docker-entrypoint.sh index 49aa729b..21a86c41 100755 --- a/build/tests/docker-entrypoint.sh +++ b/build/tests/docker-entrypoint.sh @@ -13,7 +13,6 @@ go test -race -cover -covermode=atomic github.com/thecodingmachine/gotenberg/int go test -race -cover -covermode=atomic github.com/thecodingmachine/gotenberg/internal/pkg/random go test -race -cover -covermode=atomic github.com/thecodingmachine/gotenberg/internal/pkg/standarderror go test -race -cover -covermode=atomic github.com/thecodingmachine/gotenberg/internal/pkg/timeout -go test -race -cover -covermode=atomic github.com/thecodingmachine/gotenberg/internal/pkg/printer go test -race -cover -covermode=atomic github.com/thecodingmachine/gotenberg/internal/app/api # Finally testing processes shutdown. diff --git a/internal/app/api/api_test.go b/internal/app/api/api_test.go new file mode 100644 index 00000000..7a4f5dce --- /dev/null +++ b/internal/app/api/api_test.go @@ -0,0 +1,388 @@ +package api + +import ( + "errors" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/assert" + "github.com/thecodingmachine/gotenberg/internal/app/api/pkg/handler" + "github.com/thecodingmachine/gotenberg/internal/app/api/pkg/middleware" + "github.com/thecodingmachine/gotenberg/internal/app/api/pkg/resource" + "github.com/thecodingmachine/gotenberg/internal/pkg/config" + "github.com/thecodingmachine/gotenberg/test" +) + +func TestPing(t *testing.T) { + endpoint := handler.PingEndpoint + config, err := config.FromEnv() + assert.Nil(t, err) + srv := New(config) + // should be OK. + req := httptest.NewRequest(http.MethodGet, endpoint, nil) + test.AssertStatusCode(t, http.StatusOK, srv, req) +} + +func TestMerge(t *testing.T) { + os.Setenv(middleware.TestingTraceEnvVar, "1") + endpoint := handler.MergeEndpoint + config, err := config.FromEnv() + assert.Nil(t, err) + srv := New(config) + // should be OK. + body, contentType := test.PDFTestMultipartForm(t, nil) + req := httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusOK, srv, req) + // bad request. + body, contentType = test.PDFTestMultipartForm(t, map[string]string{resource.WaitTimeoutFormField: "not a float"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + // timeout. + body, contentType = test.PDFTestMultipartForm(t, map[string]string{resource.WaitTimeoutFormField: "0"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusRequestTimeout, srv, req) + // should have no more resources. + test.AssertDirectoryEmpty(t, middleware.TestsTracePrefix) + err = os.RemoveAll(middleware.TestsTracePrefix) + assert.Nil(t, err) + os.Unsetenv(middleware.TestingTraceEnvVar) +} + +func TestHTML(t *testing.T) { + os.Setenv(middleware.TestingTraceEnvVar, "1") + endpoint := fmt.Sprintf("%s%s", handler.ConvertGroupEndpoint, handler.HTMLEndpoint) + config, err := config.FromEnv() + assert.Nil(t, err) + srv := New(config) + // should be OK. + body, contentType := test.HTMLTestMultipartForm(t, nil) + req := httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusOK, srv, req) + // bad request. + body, contentType = test.HTMLTestMultipartForm(t, map[string]string{resource.WaitTimeoutFormField: "not a float"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + body, contentType = test.HTMLTestMultipartForm(t, map[string]string{resource.WaitDelayFormField: "not a float"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + body, contentType = test.HTMLTestMultipartForm(t, map[string]string{resource.PaperWidthFormField: "not a float"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + body, contentType = test.HTMLTestMultipartForm(t, map[string]string{resource.PaperHeightFormField: "not a float"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + body, contentType = test.HTMLTestMultipartForm(t, map[string]string{resource.MarginTopFormField: "not a float"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + body, contentType = test.HTMLTestMultipartForm(t, map[string]string{resource.MarginBottomFormField: "not a float"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + body, contentType = test.HTMLTestMultipartForm(t, map[string]string{resource.MarginLeftFormField: "not a float"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + body, contentType = test.HTMLTestMultipartForm(t, map[string]string{resource.MarginRightFormField: "not a float"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + body, contentType = test.HTMLTestMultipartForm(t, map[string]string{resource.LandscapeFormField: "not a bool"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + // timeout. + body, contentType = test.HTMLTestMultipartForm(t, map[string]string{resource.WaitTimeoutFormField: "0"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusRequestTimeout, srv, req) + // should have no more resources. + test.AssertDirectoryEmpty(t, middleware.TestsTracePrefix) + err = os.RemoveAll(middleware.TestsTracePrefix) + assert.Nil(t, err) + os.Unsetenv(middleware.TestingTraceEnvVar) +} + +func TestMarkdown(t *testing.T) { + os.Setenv(middleware.TestingTraceEnvVar, "1") + endpoint := fmt.Sprintf("%s%s", handler.ConvertGroupEndpoint, handler.MarkdownEndpoint) + config, err := config.FromEnv() + assert.Nil(t, err) + srv := New(config) + // should be OK. + body, contentType := test.MarkdownTestMultipartForm(t, nil) + req := httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusOK, srv, req) + // bad request. + body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{resource.WaitTimeoutFormField: "not a float"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{resource.WaitDelayFormField: "not a float"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{resource.PaperWidthFormField: "not a float"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{resource.PaperHeightFormField: "not a float"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{resource.MarginTopFormField: "not a float"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{resource.MarginBottomFormField: "not a float"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{resource.MarginLeftFormField: "not a float"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{resource.MarginRightFormField: "not a float"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{resource.LandscapeFormField: "not a bool"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + // timeout. + body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{resource.WaitTimeoutFormField: "0"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusRequestTimeout, srv, req) + // should have no more resources. + test.AssertDirectoryEmpty(t, middleware.TestsTracePrefix) + err = os.RemoveAll(middleware.TestsTracePrefix) + assert.Nil(t, err) + os.Unsetenv(middleware.TestingTraceEnvVar) +} + +func TestURL(t *testing.T) { + os.Setenv(middleware.TestingTraceEnvVar, "1") + endpoint := fmt.Sprintf("%s%s", handler.ConvertGroupEndpoint, handler.URLEndpoint) + config, err := config.FromEnv() + assert.Nil(t, err) + srv := New(config) + // should be OK. + body, contentType := test.URLTestMultipartForm(t, nil) + req := httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusOK, srv, req) + // bad request. + body, contentType = test.URLTestMultipartForm(t, map[string]string{resource.WaitTimeoutFormField: "not a float"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + body, contentType = test.URLTestMultipartForm(t, map[string]string{resource.WaitDelayFormField: "not a float"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + body, contentType = test.URLTestMultipartForm(t, map[string]string{resource.PaperWidthFormField: "not a float"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + body, contentType = test.URLTestMultipartForm(t, map[string]string{resource.PaperHeightFormField: "not a float"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + body, contentType = test.URLTestMultipartForm(t, map[string]string{resource.MarginTopFormField: "not a float"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + body, contentType = test.URLTestMultipartForm(t, map[string]string{resource.MarginBottomFormField: "not a float"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + body, contentType = test.URLTestMultipartForm(t, map[string]string{resource.MarginLeftFormField: "not a float"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + body, contentType = test.URLTestMultipartForm(t, map[string]string{resource.MarginRightFormField: "not a float"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + body, contentType = test.URLTestMultipartForm(t, map[string]string{resource.LandscapeFormField: "not a bool"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + // timeout. + body, contentType = test.URLTestMultipartForm(t, map[string]string{resource.WaitTimeoutFormField: "0"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusRequestTimeout, srv, req) + // should have no more resources. + test.AssertDirectoryEmpty(t, middleware.TestsTracePrefix) + err = os.RemoveAll(middleware.TestsTracePrefix) + assert.Nil(t, err) + os.Unsetenv(middleware.TestingTraceEnvVar) +} + +func TestConcurrent(t *testing.T) { + const concurrentRequests int = 4 + os.Setenv(middleware.TestingTraceEnvVar, "1") + config, err := config.FromEnv() + assert.Nil(t, err) + srv := New(config) + // Merge. + test.AssertConcurrent( + t, + func() error { + body, contentType := test.PDFTestMultipartForm(t, map[string]string{resource.WaitTimeoutFormField: "120"}) + req := httptest.NewRequest(http.MethodPost, handler.MergeEndpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + return fmt.Errorf("wrong status code: want '%d' got '%d'", http.StatusOK, rec.Code) + } + return nil + }, + concurrentRequests, + ) + // HTML. + test.AssertConcurrent( + t, + func() error { + body, contentType := test.HTMLTestMultipartForm(t, map[string]string{resource.WaitTimeoutFormField: "120"}) + req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("%s%s", handler.ConvertGroupEndpoint, handler.HTMLEndpoint), body) + req.Header.Set(echo.HeaderContentType, contentType) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + return fmt.Errorf("wrong status code: want '%d' got '%d'", http.StatusOK, rec.Code) + } + return nil + }, + concurrentRequests, + ) + // Markdown. + test.AssertConcurrent( + t, + func() error { + body, contentType := test.MarkdownTestMultipartForm(t, map[string]string{resource.WaitTimeoutFormField: "120"}) + req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("%s%s", handler.ConvertGroupEndpoint, handler.MarkdownEndpoint), body) + req.Header.Set(echo.HeaderContentType, contentType) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + return fmt.Errorf("wrong status code: want '%d' got '%d'", http.StatusOK, rec.Code) + } + return nil + }, + concurrentRequests, + ) + // URL. + test.AssertConcurrent( + t, + func() error { + body, contentType := test.URLTestMultipartForm(t, map[string]string{resource.WaitTimeoutFormField: "120"}) + req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("%s%s", handler.ConvertGroupEndpoint, handler.URLEndpoint), body) + req.Header.Set(echo.HeaderContentType, contentType) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + return fmt.Errorf("wrong status code: want '%d' got '%d'", http.StatusOK, rec.Code) + } + return nil + }, + concurrentRequests, + ) + // Office. + test.AssertConcurrent( + t, + func() error { + body, contentType := test.OfficeTestMultipartForm(t, map[string]string{resource.WaitTimeoutFormField: "120"}) + req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("%s%s", handler.ConvertGroupEndpoint, handler.OfficeEndpoint), body) + req.Header.Set(echo.HeaderContentType, contentType) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + return fmt.Errorf("wrong status code: want '%d' got '%d'", http.StatusOK, rec.Code) + } + return nil + }, + concurrentRequests, + ) + // should have no more resources. + test.AssertDirectoryEmpty(t, middleware.TestsTracePrefix) + err = os.RemoveAll(middleware.TestsTracePrefix) + assert.Nil(t, err) + os.Unsetenv(middleware.TestingTraceEnvVar) +} + +func TestWebhook(t *testing.T) { + status := make(chan error, 2) + rcv := echo.New() + rcv.POST("/foo", func(c echo.Context) error { + if c.Request().Header.Get("Content-type") != "application/pdf" { + status <- fmt.Errorf("wrong Content-type: got '%s' want '%s'", c.Request().Header.Get("Content-type"), "application/pdf") + return nil + } + body, err := ioutil.ReadAll(c.Request().Body) + if err != nil { + status <- err + return nil + } + if body == nil || len(body) == 0 { + status <- errors.New("empty body") + return nil + } + status <- nil + return nil + }) + go func() { + rcv.Start(":3001") + }() + os.Setenv(middleware.TestingTraceEnvVar, "1") + config, err := config.FromEnv() + assert.Nil(t, err) + srv := New(config) + body, contentType := test.PDFTestMultipartForm(t, map[string]string{resource.WebhookURLFormField: "http://localhost:3001/foo"}) + req := httptest.NewRequest(http.MethodPost, "/merge", body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusOK, srv, req) + err = <-status + assert.NoError(t, err) + // should have no more resources. + test.AssertDirectoryEmpty(t, middleware.TestsTracePrefix) + err = os.RemoveAll(middleware.TestsTracePrefix) + assert.Nil(t, err) + os.Unsetenv(middleware.TestingTraceEnvVar) +} + +func TestResultFilename(t *testing.T) { + os.Setenv(middleware.TestingTraceEnvVar, "1") + config, err := config.FromEnv() + assert.Nil(t, err) + srv := New(config) + body, contentType := test.PDFTestMultipartForm(t, map[string]string{resource.ResultFilenameFormField: "foo.pdf"}) + req := httptest.NewRequest(http.MethodPost, "/merge", body) + req.Header.Set(echo.HeaderContentType, contentType) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + assert.Equal(t, "attachment; filename=\"foo.pdf\"", rec.Header().Get("Content-Disposition")) + // should have no more resources. + test.AssertDirectoryEmpty(t, middleware.TestsTracePrefix) + err = os.RemoveAll(middleware.TestsTracePrefix) + assert.Nil(t, err) + os.Unsetenv(middleware.TestingTraceEnvVar) +} diff --git a/internal/app/api/pkg/context/context.go b/internal/app/api/pkg/context/context.go index 2b32c949..f8437f95 100644 --- a/internal/app/api/pkg/context/context.go +++ b/internal/app/api/pkg/context/context.go @@ -36,7 +36,7 @@ func New(c echo.Context, logger *logger.Logger, config *config.Config) *Context // MustCastFromEchoContext cast an echo.Context to our custom // context. If something goes wrong, panic. func MustCastFromEchoContext(c echo.Context) *Context { - const op = "context.MustCastFromEchoContext" + const op string = "context.MustCastFromEchoContext" ctx, ok := c.(*Context) if !ok { panic(fmt.Sprintf("%s: unable to cast an echo.Context to a custom context", op)) @@ -60,7 +60,7 @@ func (ctx *Context) Resource() *resource.Resource { // WithResource adds a resource to the context. func (ctx *Context) WithResource(resourceDirPath string) error { - const op = "context.WithResource" + const op string = "context.WithResource" r, err := resource.New(ctx, ctx.logger, ctx.config, resourceDirPath) ctx.resource = r if err != nil { @@ -75,7 +75,7 @@ func (ctx *Context) WithResource(resourceDirPath string) error { // LogRequestResult logs the result of a request. // This method should only be used by a middleware! func (ctx *Context) LogRequestResult(err error, isDebug bool) error { - const op = "context.LogRequestResult" + const op string = "context.LogRequestResult" req := ctx.Request() resp := ctx.Response() stopTime := time.Now() diff --git a/internal/app/api/pkg/handler/handler.go b/internal/app/api/pkg/handler/handler.go index b511818c..92d70f45 100644 --- a/internal/app/api/pkg/handler/handler.go +++ b/internal/app/api/pkg/handler/handler.go @@ -35,7 +35,7 @@ const ( ) func convert(ctx *context.Context, p printer.Printer) error { - const op = "handler.convert" + const op string = "handler.convert" r := ctx.Resource() logger := ctx.StandardLogger() baseFilename := random.Get() diff --git a/internal/app/api/pkg/handler/html.go b/internal/app/api/pkg/handler/html.go index eb1581bb..c64f70c7 100644 --- a/internal/app/api/pkg/handler/html.go +++ b/internal/app/api/pkg/handler/html.go @@ -10,7 +10,7 @@ import ( // HTML is the endpoint for converting // HTML to PDF. func HTML(c echo.Context) error { - const op = "handler.HTML" + const op string = "handler.HTML" ctx := context.MustCastFromEchoContext(c) ctx.StandardLogger().DebugfOp(op, "html request") r := ctx.Resource() diff --git a/internal/app/api/pkg/handler/markdown.go b/internal/app/api/pkg/handler/markdown.go index a7f59379..6283d4c3 100644 --- a/internal/app/api/pkg/handler/markdown.go +++ b/internal/app/api/pkg/handler/markdown.go @@ -10,7 +10,7 @@ import ( // Markdown is the endpoint for converting // Markdown to PDF. func Markdown(c echo.Context) error { - const op = "handler.Markdown" + const op string = "handler.Markdown" ctx := context.MustCastFromEchoContext(c) ctx.StandardLogger().DebugfOp(op, "markdown request") r := ctx.Resource() diff --git a/internal/app/api/pkg/handler/merge.go b/internal/app/api/pkg/handler/merge.go index 381e7da2..e3422673 100644 --- a/internal/app/api/pkg/handler/merge.go +++ b/internal/app/api/pkg/handler/merge.go @@ -10,7 +10,7 @@ import ( // Merge is the endpoint for // merging PDF files. func Merge(c echo.Context) error { - const op = "handler.Merge" + const op string = "handler.Merge" ctx := context.MustCastFromEchoContext(c) ctx.StandardLogger().DebugfOp(op, "merge request") r := ctx.Resource() diff --git a/internal/app/api/pkg/handler/office.go b/internal/app/api/pkg/handler/office.go index 60ccca1c..2532e21e 100644 --- a/internal/app/api/pkg/handler/office.go +++ b/internal/app/api/pkg/handler/office.go @@ -10,7 +10,7 @@ import ( // Office is the endpoint for converting // Office files to PDF. func Office(c echo.Context) error { - const op = "handler.Office" + const op string = "handler.Office" ctx := context.MustCastFromEchoContext(c) ctx.StandardLogger().DebugfOp(op, "office request") r := ctx.Resource() diff --git a/internal/app/api/pkg/handler/url.go b/internal/app/api/pkg/handler/url.go index f7383a3c..f4264f00 100644 --- a/internal/app/api/pkg/handler/url.go +++ b/internal/app/api/pkg/handler/url.go @@ -11,7 +11,7 @@ import ( // URL is the endpoint for converting // a URL to PDF. func URL(c echo.Context) error { - const op = "handler.URL" + const op string = "handler.URL" ctx := context.MustCastFromEchoContext(c) ctx.StandardLogger().DebugfOp(op, "url request") r := ctx.Resource() diff --git a/internal/app/api/pkg/middleware/cleanup.go b/internal/app/api/pkg/middleware/cleanup.go index ee21c9fb..6f6c57f7 100644 --- a/internal/app/api/pkg/middleware/cleanup.go +++ b/internal/app/api/pkg/middleware/cleanup.go @@ -11,7 +11,7 @@ import ( func Cleanup() echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { - const op = "middleware.Cleanup" + const op string = "middleware.Cleanup" err := next(c) ctx := context.MustCastFromEchoContext(c) r := ctx.Resource() diff --git a/internal/app/api/pkg/middleware/context.go b/internal/app/api/pkg/middleware/context.go index d4da5100..d917ba7b 100644 --- a/internal/app/api/pkg/middleware/context.go +++ b/internal/app/api/pkg/middleware/context.go @@ -15,13 +15,13 @@ import ( const ( // TestingTraceEnvVar is an environment // variable used in some tests. - TestingTraceEnvVar = "TESTING_TRACE" + TestingTraceEnvVar string = "TESTING_TRACE" // TestsTracePrefix helps // creating all resources inside a prefix. // Only used in some tests // to check if the resources // have been removed. - TestsTracePrefix = "tmp" + TestsTracePrefix string = "tmp" ) // Context helps extending the default echo.Context with diff --git a/internal/app/api/pkg/resource/resource.go b/internal/app/api/pkg/resource/resource.go index 69eb487a..c8eba0d9 100644 --- a/internal/app/api/pkg/resource/resource.go +++ b/internal/app/api/pkg/resource/resource.go @@ -65,7 +65,7 @@ type Resource struct { // New creates a new resource. func New(c echo.Context, logger *logger.Logger, config *config.Config, dirPath string) (*Resource, error) { - const op = "resource.New" + const op string = "resource.New" r := &Resource{ logger: logger, config: config, @@ -83,7 +83,7 @@ func New(c echo.Context, logger *logger.Logger, config *config.Config, dirPath s } func formValues(c echo.Context, logger *logger.Logger) map[string]string { - const op = "resource.formValues" + const op string = "resource.formValues" v := make(map[string]string) fetch := func(formField string) string { value := c.FormValue(formField) @@ -110,7 +110,7 @@ func formValues(c echo.Context, logger *logger.Logger) map[string]string { } func formFiles(c echo.Context, logger *logger.Logger, dirPath string) error { - const op = "resource.formFiles" + const op string = "resource.formFiles" form, err := c.MultipartForm() if err != nil { return &standarderror.Error{Op: op, Err: err} @@ -153,7 +153,7 @@ func (r *Resource) DirPath() string { // Close deletes the working directory of the // resource if it exists. func (r *Resource) Close() error { - const op = "resource.Close" + const op string = "resource.Close" if _, err := os.Stat(r.formFilesDirPath); os.IsNotExist(err) { r.logger.DebugfOp(op, "directory '%s' does not exist, nothing to remove", r.formFilesDirPath) return nil @@ -171,7 +171,7 @@ const defaultHeaderFooterHTML string = "" // thanks to the form values and form files from the request // plus the default values from the configuration. func (r *Resource) ChromePrinterOptions() (*printer.ChromeOptions, error) { - const op = "resource.ChromePrinterOptions" + const op string = "resource.ChromePrinterOptions" waitTimeout, err := r.float64(WaitTimeoutFormField, r.config.DefaultWaitTimeout()) if err != nil { return nil, &standarderror.Error{Op: op, Err: err} @@ -237,7 +237,7 @@ func (r *Resource) ChromePrinterOptions() (*printer.ChromeOptions, error) { // thanks to the form values from the request // plus the default values from the configuration. func (r *Resource) OfficePrinterOptions() (*printer.OfficeOptions, error) { - const op = "resource.OfficePrinterOptions" + const op string = "resource.OfficePrinterOptions" waitTimeout, err := r.float64(WaitTimeoutFormField, r.config.DefaultWaitTimeout()) if err != nil { return nil, &standarderror.Error{Op: op, Err: err} @@ -258,7 +258,7 @@ func (r *Resource) OfficePrinterOptions() (*printer.OfficeOptions, error) { // thanks to the form values from the request // plus the default values from the configuration. func (r *Resource) MergePrinterOptions() (*printer.MergeOptions, error) { - const op = "resource.MergePrinterOptions" + const op string = "resource.MergePrinterOptions" waitTimeout, err := r.float64(WaitTimeoutFormField, r.config.DefaultWaitTimeout()) if err != nil { return nil, &standarderror.Error{Op: op, Err: err} @@ -289,7 +289,7 @@ func (r *Resource) hasFile(filename string) bool { // Get returns the form field value. func (r *Resource) Get(formField string) (string, error) { - const op = "resource.Get" + const op string = "resource.Get" v, err := r.value(formField) if err != nil { return "", &standarderror.Error{Op: op, Err: err} @@ -298,7 +298,7 @@ func (r *Resource) Get(formField string) (string, error) { } func (r *Resource) value(formField string) (string, error) { - const op = "resource.value" + const op string = "resource.value" v, ok := r.formValues[formField] if !ok { return "", &standarderror.Error{ @@ -311,7 +311,7 @@ func (r *Resource) value(formField string) (string, error) { } func (r *Resource) float64(formField string, defaultValue float64) (float64, error) { - const op = "resource.float64" + const op string = "resource.float64" if !r.Has(formField) { return defaultValue, nil } @@ -331,7 +331,7 @@ func (r *Resource) float64(formField string, defaultValue float64) (float64, err } func (r *Resource) bool(formField string, defaultValue bool) (bool, error) { - const op = "resource.bool" + const op string = "resource.bool" if !r.Has(formField) { return defaultValue, nil } @@ -353,7 +353,7 @@ func (r *Resource) bool(formField string, defaultValue bool) (bool, error) { // Fpath returns the path of the given filename. // This filename should be the name of a form file. func (r *Resource) Fpath(filename string) (string, error) { - const op = "resource.Fpath" + const op string = "resource.Fpath" fpath := fmt.Sprintf("%s/%s", r.formFilesDirPath, filename) _, err := os.Stat(fpath) if os.IsNotExist(err) { @@ -371,7 +371,7 @@ func (r *Resource) Fpath(filename string) (string, error) { } func (r *Resource) content(filename string, defaultValue string) (string, error) { - const op = "resource.content" + const op string = "resource.content" if !r.hasFile(filename) { return defaultValue, nil } @@ -389,7 +389,7 @@ func (r *Resource) content(filename string, defaultValue string) (string, error) // Fpaths returns the list of files of the resource // according to given file extensions. func (r *Resource) Fpaths(exts ...string) ([]string, error) { - const op = "resource.Fpaths" + const op string = "resource.Fpaths" var fpaths []string err := filepath.Walk(r.formFilesDirPath, func(path string, info os.FileInfo, _ error) error { if info.IsDir() { diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index 6379a18e..41be1df9 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -10,11 +10,11 @@ import ( ) const ( - defaultWaitTimeoutEnvVar = "DEFAULT_WAIT_TIMEOUT" - defaultListenPortEnvVar = "DEFAULT_LISTEN_PORT" - disableGoogleChromeEnvVar = "DISABLE_GOOGLE_CHROME" - disableUnoconvEnvVar = "DISABLE_UNOCONV" - logLevelEnvVar = "LOG_LEVEL" + defaultWaitTimeoutEnvVar string = "DEFAULT_WAIT_TIMEOUT" + defaultListenPortEnvVar string = "DEFAULT_LISTEN_PORT" + disableGoogleChromeEnvVar string = "DISABLE_GOOGLE_CHROME" + disableUnoconvEnvVar string = "DISABLE_UNOCONV" + logLevelEnvVar string = "LOG_LEVEL" ) // Config contains the application @@ -40,7 +40,7 @@ func defaultConfig() *Config { // FromEnv fetches configuration // from environment variables. func FromEnv() (*Config, error) { - const op = "config.FromEnv" + const op string = "config.FromEnv" c := defaultConfig() defaultWaitTimeout, err := defaultWaitTimeoutFromEnv(defaultWaitTimeoutEnvVar, c.DefaultWaitTimeout()) c.defaultWaitTimeout = defaultWaitTimeout @@ -103,7 +103,7 @@ func (c *Config) LogLevel() logrus.Level { } func defaultWaitTimeoutFromEnv(envVar string, defaultValue float64) (float64, error) { - const op = "config.defaultWaitTimeoutFromEnv" + const op string = "config.defaultWaitTimeoutFromEnv" if v, ok := os.LookupEnv(envVar); ok { waitTimeout, err := strconv.ParseFloat(v, 64) if err != nil { @@ -119,7 +119,7 @@ func defaultWaitTimeoutFromEnv(envVar string, defaultValue float64) (float64, er } func defaultListenPortFromEnv(envVar string, defaultValue string) (string, error) { - const op = "config.defaultListenPortFromEnv" + const op string = "config.defaultListenPortFromEnv" if v, ok := os.LookupEnv(envVar); ok { portAsUint, err := strconv.ParseUint(v, 10, 64) if err != nil { @@ -142,7 +142,7 @@ func defaultListenPortFromEnv(envVar string, defaultValue string) (string, error } func boolFromEnv(envVar string, defaultValue bool) (bool, error) { - const op = "config.boolFromEnv" + const op string = "config.boolFromEnv" if v, ok := os.LookupEnv(envVar); ok { if v != "1" && v != "0" { return defaultValue, &standarderror.Error{ @@ -157,7 +157,7 @@ func boolFromEnv(envVar string, defaultValue bool) (bool, error) { } func logLevelFromEnv(envVar string, defaultValue logrus.Level) (logrus.Level, error) { - const op = "config.logLevelFromEnv" + const op string = "config.logLevelFromEnv" if v, ok := os.LookupEnv(envVar); ok { switch v { case "DEBUG": diff --git a/internal/pkg/pm2/chrome.go b/internal/pkg/pm2/chrome.go index 94492ede..52a639c9 100644 --- a/internal/pkg/pm2/chrome.go +++ b/internal/pkg/pm2/chrome.go @@ -9,7 +9,7 @@ import ( "github.com/thecodingmachine/gotenberg/internal/pkg/standarderror" ) -const chromeWarmupTime = 10 * time.Second +const chromeWarmupTime time.Duration = 10 * time.Second type chrome struct { manager *processManager @@ -28,7 +28,7 @@ func (p *chrome) Fullname() string { } func (p *chrome) Start() error { - const op = "pm2.chrome.Start" + const op string = "pm2.chrome.Start" if err := p.manager.start(p); err != nil { return &standarderror.Error{Op: op, Err: err} } @@ -36,7 +36,7 @@ func (p *chrome) Start() error { } func (p *chrome) Shutdown() error { - const op = "pm2.chrome.Shutdown" + const op string = "pm2.chrome.Shutdown" if err := p.manager.shutdown(p); err != nil { return &standarderror.Error{Op: op, Err: err} } @@ -67,7 +67,7 @@ func (p *chrome) name() string { } func (p *chrome) viable() bool { - const op = "pm2.chrome.viable" + const op string = "pm2.chrome.viable" // check if Google Chrome is correctly running. ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -93,7 +93,7 @@ func (p *chrome) viable() bool { } func (p *chrome) warmup() { - const op = "pm2.chrome.warmup" + const op string = "pm2.chrome.warmup" p.manager.logger.DebugfOp( op, "allowing %v to startup", diff --git a/internal/pkg/pm2/pm2.go b/internal/pkg/pm2/pm2.go index 065ff5eb..7f1113f0 100644 --- a/internal/pkg/pm2/pm2.go +++ b/internal/pkg/pm2/pm2.go @@ -12,7 +12,7 @@ import ( ) const ( - stoppedState = iota + stoppedState int32 = iota runningState errorState ) @@ -35,7 +35,7 @@ type processManager struct { } func (m *processManager) start(p Process) error { - const op = "pm2.start" + const op string = "pm2.start" if err := m.pm2(p, "start"); err != nil { return &standarderror.Error{Op: op, Err: err} } @@ -63,7 +63,7 @@ func (m *processManager) start(p Process) error { } func (m *processManager) shutdown(p Process) error { - const op = "pm2.shutdown" + const op string = "pm2.shutdown" if m.heuristicState != runningState { return nil } @@ -76,7 +76,7 @@ func (m *processManager) shutdown(p Process) error { } func (m *processManager) pm2(p Process, cmdName string) error { - const op = "pm2.pm2" + const op string = "pm2.pm2" cmdArgs := []string{ cmdName, p.name(), diff --git a/internal/pkg/pm2/unoconv.go b/internal/pkg/pm2/unoconv.go index ab1f9cde..6c67b3f6 100644 --- a/internal/pkg/pm2/unoconv.go +++ b/internal/pkg/pm2/unoconv.go @@ -7,7 +7,7 @@ import ( "github.com/thecodingmachine/gotenberg/internal/pkg/standarderror" ) -const unoconvWarmupTime = 5 * time.Second +const unoconvWarmupTime time.Duration = 5 * time.Second type unoconv struct { manager *processManager @@ -26,7 +26,7 @@ func (p *unoconv) Fullname() string { } func (p *unoconv) Start() error { - const op = "pm2.unoconv.Start" + const op string = "pm2.unoconv.Start" if err := p.manager.start(p); err != nil { return &standarderror.Error{Op: op, Err: err} } @@ -34,7 +34,7 @@ func (p *unoconv) Start() error { } func (p *unoconv) Shutdown() error { - const op = "pm2.unoconv.Shutdown" + const op string = "pm2.unoconv.Shutdown" if err := p.manager.shutdown(p); err != nil { return &standarderror.Error{Op: op, Err: err} } @@ -60,7 +60,7 @@ func (p *unoconv) viable() bool { } func (p *unoconv) warmup() { - const op = "pm2.unoconv.warmup" + const op string = "pm2.unoconv.warmup" p.manager.logger.DebugfOp( op, "allowing %v to startup", diff --git a/internal/pkg/printer/chrome.go b/internal/pkg/printer/chrome.go index fe1d181a..968030dd 100644 --- a/internal/pkg/printer/chrome.go +++ b/internal/pkg/printer/chrome.go @@ -39,83 +39,89 @@ type ChromeOptions struct { } func (p *chrome) Print(destination string) error { - const op = "printer.chrome.Print" + const op string = "printer.chrome.Print" ctx, cancel := timeout.Context(p.opts.WaitTimeout + p.opts.WaitDelay) defer cancel() - devt, err := devtool.New("http://localhost:9222").Version(ctx) - if err != nil { - return handleErrContext(ctx, &standarderror.Error{Op: op, Err: err}) + resolver := func() error { + devt, err := devtool.New("http://localhost:9222").Version(ctx) + if err != nil { + return &standarderror.Error{Op: op, Err: err} + } + // connect to WebSocket URL (page) that speaks the Chrome DevTools Protocol. + devtConn, err := rpcc.DialContext(ctx, devt.WebSocketDebuggerURL) + if err != nil { + return &standarderror.Error{Op: op, Err: err} + } + defer devtConn.Close() // nolint: errcheck + // create a new CDP Client that uses conn. + devtClient := cdp.NewClient(devtConn) + newContextTarget, err := devtClient.Target.CreateBrowserContext(ctx) + if err != nil { + return &standarderror.Error{Op: op, Err: err} + } + // create a new blank target with the new browser context. + createTargetArgs := target. + NewCreateTargetArgs("about:blank"). + SetBrowserContextID(newContextTarget.BrowserContextID) + newTarget, err := devtClient.Target.CreateTarget(ctx, createTargetArgs) + if err != nil { + return &standarderror.Error{Op: op, Err: err} + } + // connect the client to the new target. + newTargetWsURL := fmt.Sprintf("ws://127.0.0.1:9222/devtools/page/%s", newTarget.TargetID) + newContextConn, err := rpcc.DialContext(ctx, newTargetWsURL) + if err != nil { + return &standarderror.Error{Op: op, Err: err} + } + defer newContextConn.Close() // nolint: errcheck + // create a new CDP Client that uses newContextConn. + targetClient := cdp.NewClient(newContextConn) + closeTargetArgs := target.NewCloseTargetArgs(newTarget.TargetID) + // close the target when done. + defer targetClient.Target.CloseTarget(ctx, closeTargetArgs) // nolint: errcheck + if err := runBatch( + // enable all the domain events that we're interested in. + func() error { return targetClient.DOM.Enable(ctx) }, + func() error { return targetClient.Network.Enable(ctx, network.NewEnableArgs()) }, + func() error { return targetClient.Page.Enable(ctx) }, + func() error { return targetClient.Runtime.Enable(ctx) }, + ); err != nil { + return &standarderror.Error{Op: op, Err: err} + } + if err := p.navigate(ctx, targetClient); err != nil { + return &standarderror.Error{Op: op, Err: err} + } + print, err := targetClient.Page.PrintToPDF( + ctx, + page.NewPrintToPDFArgs(). + SetPaperWidth(p.opts.PaperWidth). + SetPaperHeight(p.opts.PaperHeight). + SetMarginTop(p.opts.MarginTop). + SetMarginBottom(p.opts.MarginBottom). + SetMarginLeft(p.opts.MarginLeft). + SetMarginRight(p.opts.MarginRight). + SetLandscape(p.opts.Landscape). + SetDisplayHeaderFooter(true). + SetHeaderTemplate(p.opts.HeaderHTML). + SetFooterTemplate(p.opts.FooterHTML). + SetPrintBackground(true), + ) + if err != nil { + return &standarderror.Error{Op: op, Err: err} + } + if err := ioutil.WriteFile(destination, print.Data, 0644); err != nil { + return &standarderror.Error{Op: op, Err: err} + } + return nil } - // connect to WebSocket URL (page) that speaks the Chrome DevTools Protocol. - devtConn, err := rpcc.DialContext(ctx, devt.WebSocketDebuggerURL) - if err != nil { - return handleErrContext(ctx, &standarderror.Error{Op: op, Err: err}) - } - defer devtConn.Close() // nolint: errcheck - // create a new CDP Client that uses conn. - devtClient := cdp.NewClient(devtConn) - newContextTarget, err := devtClient.Target.CreateBrowserContext(ctx) - if err != nil { - return handleErrContext(ctx, &standarderror.Error{Op: op, Err: err}) - } - // create a new blank target with the new browser context. - createTargetArgs := target. - NewCreateTargetArgs("about:blank"). - SetBrowserContextID(newContextTarget.BrowserContextID) - newTarget, err := devtClient.Target.CreateTarget(ctx, createTargetArgs) - if err != nil { - return handleErrContext(ctx, &standarderror.Error{Op: op, Err: err}) - } - // connect the client to the new target. - newTargetWsURL := fmt.Sprintf("ws://127.0.0.1:9222/devtools/page/%s", newTarget.TargetID) - newContextConn, err := rpcc.DialContext(ctx, newTargetWsURL) - if err != nil { - return handleErrContext(ctx, &standarderror.Error{Op: op, Err: err}) - } - defer newContextConn.Close() // nolint: errcheck - // create a new CDP Client that uses newContextConn. - targetClient := cdp.NewClient(newContextConn) - closeTargetArgs := target.NewCloseTargetArgs(newTarget.TargetID) - // close the target when done. - defer targetClient.Target.CloseTarget(ctx, closeTargetArgs) // nolint: errcheck - if err := runBatch( - // enable all the domain events that we're interested in. - func() error { return targetClient.DOM.Enable(ctx) }, - func() error { return targetClient.Network.Enable(ctx, network.NewEnableArgs()) }, - func() error { return targetClient.Page.Enable(ctx) }, - func() error { return targetClient.Runtime.Enable(ctx) }, - ); err != nil { - return handleErrContext(ctx, &standarderror.Error{Op: op, Err: err}) - } - if err := p.navigate(ctx, targetClient); err != nil { - return handleErrContext(ctx, &standarderror.Error{Op: op, Err: err}) - } - print, err := targetClient.Page.PrintToPDF( - ctx, - page.NewPrintToPDFArgs(). - SetPaperWidth(p.opts.PaperWidth). - SetPaperHeight(p.opts.PaperHeight). - SetMarginTop(p.opts.MarginTop). - SetMarginBottom(p.opts.MarginBottom). - SetMarginLeft(p.opts.MarginLeft). - SetMarginRight(p.opts.MarginRight). - SetLandscape(p.opts.Landscape). - SetDisplayHeaderFooter(true). - SetHeaderTemplate(p.opts.HeaderHTML). - SetFooterTemplate(p.opts.FooterHTML). - SetPrintBackground(true), - ) - if err != nil { - return handleErrContext(ctx, &standarderror.Error{Op: op, Err: err}) - } - if err := ioutil.WriteFile(destination, print.Data, 0644); err != nil { - return &standarderror.Error{Op: op, Err: err} + if err := resolver(); err != nil { + return timeout.Err(ctx, err) } return nil } func (p *chrome) navigate(ctx context.Context, client *cdp.Client) error { - const op = "printer.chrome.navigate" + const op string = "printer.chrome.navigate" // make sure Page events are enabled. if err := client.Page.Enable(ctx); err != nil { return &standarderror.Error{Op: op, Err: err} diff --git a/internal/pkg/printer/markdown.go b/internal/pkg/printer/markdown.go index 07ea3b55..199ebeea 100644 --- a/internal/pkg/printer/markdown.go +++ b/internal/pkg/printer/markdown.go @@ -15,7 +15,7 @@ import ( // NewMarkdown returns a Markdown printer. func NewMarkdown(fpath string, opts *ChromeOptions) (Printer, error) { - const op = "printer.NewMarkdown" + const op string = "printer.NewMarkdown" tmpl, err := template. New(filepath.Base(fpath)). Funcs(template.FuncMap{"toHTML": markdownToHTML}). @@ -46,7 +46,7 @@ type templateData struct { } func markdownToHTML(dirPath, filename string) (template.HTML, error) { - const op = "printer.markdownToHTML" + const op string = "printer.markdownToHTML" fpath := fmt.Sprintf("%s/%s", dirPath, filename) b, err := ioutil.ReadFile(fpath) if err != nil { diff --git a/internal/pkg/printer/merge.go b/internal/pkg/printer/merge.go index 746a5ee1..4bf5e2e5 100644 --- a/internal/pkg/printer/merge.go +++ b/internal/pkg/printer/merge.go @@ -29,19 +29,25 @@ func NewMerge(fpaths []string, opts *MergeOptions) Printer { } func (p *merge) Print(destination string) error { - const op = "printer.merge.Print" + const op string = "printer.merge.Print" if p.ctx == nil { ctx, cancel := timeout.Context(p.opts.WaitTimeout) defer cancel() p.ctx = ctx } - var cmdArgs []string - cmdArgs = append(cmdArgs, p.fpaths...) - cmdArgs = append(cmdArgs, "cat", "output", destination) - cmd := exec.CommandContext(p.ctx, "pdftk", cmdArgs...) - _, err := cmd.Output() - if err != nil { - return handleErrContext(p.ctx, &standarderror.Error{Op: op, Err: err}) + resolver := func() error { + var cmdArgs []string + cmdArgs = append(cmdArgs, p.fpaths...) + cmdArgs = append(cmdArgs, "cat", "output", destination) + cmd := exec.CommandContext(p.ctx, "pdftk", cmdArgs...) + _, err := cmd.Output() + if err != nil { + return &standarderror.Error{Op: op, Err: err} + } + return nil + } + if err := resolver(); err != nil { + return timeout.Err(p.ctx, err) } return nil } diff --git a/internal/pkg/printer/office.go b/internal/pkg/printer/office.go index 26db09e9..d7a9520f 100644 --- a/internal/pkg/printer/office.go +++ b/internal/pkg/printer/office.go @@ -34,18 +34,24 @@ func NewOffice(fpaths []string, opts *OfficeOptions) Printer { } func (p *office) Print(destination string) error { - const op = "printer.office.Print" + const op string = "printer.office.Print" ctx, cancel := timeout.Context(p.opts.WaitTimeout) defer cancel() fpaths := make([]string, len(p.fpaths)) - dirPath := filepath.Dir(destination) - for i, fpath := range p.fpaths { - baseFilename := random.String(32) - tmpDest := fmt.Sprintf("%s/%d%s.pdf", dirPath, i, baseFilename) - if err := unoconv(ctx, fpath, tmpDest, p.opts); err != nil { - return handleErrContext(ctx, &standarderror.Error{Op: op, Err: err}) + resolver := func() error { + dirPath := filepath.Dir(destination) + for i, fpath := range p.fpaths { + baseFilename := random.String(32) + tmpDest := fmt.Sprintf("%s/%d%s.pdf", dirPath, i, baseFilename) + if err := unoconv(ctx, fpath, tmpDest, p.opts); err != nil { + return &standarderror.Error{Op: op, Err: err} + } + fpaths[i] = tmpDest } - fpaths[i] = tmpDest + return nil + } + if err := resolver(); err != nil { + return timeout.Err(ctx, err) } if len(fpaths) == 1 { if err := os.Rename(fpaths[0], destination); err != nil { @@ -67,7 +73,7 @@ func (p *office) Print(destination string) error { var mu sync.Mutex func unoconv(ctx context.Context, fpath, destination string, opts *OfficeOptions) error { - const op = "printer.unoconv" + const op string = "printer.unoconv" mu.Lock() defer mu.Unlock() cmdArgs := []string{ diff --git a/internal/pkg/printer/printer.go b/internal/pkg/printer/printer.go index 5fff84c7..e1e72497 100644 --- a/internal/pkg/printer/printer.go +++ b/internal/pkg/printer/printer.go @@ -1,39 +1,7 @@ package printer -import ( - "context" - "fmt" - "strings" - - "github.com/thecodingmachine/gotenberg/internal/pkg/standarderror" -) - // Printer is a type that can create a PDF file from a source. // The source is defined in the underlying implementation. type Printer interface { Print(destination string) error } - -func handleErrContext(ctx context.Context, previousErr error) error { - const op = "printer.handleErrContext" - if previousErr == nil { - panic(fmt.Sprintf("%s: previous error should not be nil", op)) - } - err := ctx.Err() - if err == nil { - return previousErr - } - if strings.Contains(err.Error(), context.DeadlineExceeded.Error()) { - return &standarderror.Error{ - Code: standarderror.Timeout, - Message: "context has timed out", - Op: op, - Err: previousErr, - } - } - return &standarderror.Error{ - Message: "context finished with an error", - Op: op, - Err: previousErr, - } -} diff --git a/internal/pkg/printer/printer_test.go b/internal/pkg/printer/printer_test.go deleted file mode 100644 index d844ac72..00000000 --- a/internal/pkg/printer/printer_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package printer - -import ( - "errors" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/thecodingmachine/gotenberg/internal/pkg/standarderror" - "github.com/thecodingmachine/gotenberg/internal/pkg/timeout" - "github.com/thecodingmachine/gotenberg/test" -) - -func TestHandlerErr(t *testing.T) { - previousErr := errors.New("previous error") - // should be OK. - ctx, cancel := timeout.Context(5) - defer cancel() - assert.NotNil(t, handleErrContext(ctx, previousErr)) - // should timeout. - ctx, cancel = timeout.Context(0.5) - defer cancel() - time.Sleep(timeout.Duration(1)) - err := handleErrContext(ctx, previousErr) - assert.NotNil(t, err) - standardized := test.RequireStandardError(t, err) - assert.Equal(t, standarderror.Timeout, standardized.Code) - // should failed. - ctx, cancel = timeout.Context(5) - cancel() - err = handleErrContext(ctx, previousErr) - assert.NotNil(t, err) - standardized = test.RequireStandardError(t, err) - assert.Equal(t, standarderror.Internal, standarderror.Code(err)) -} diff --git a/internal/pkg/standarderror/standarderror.go b/internal/pkg/standarderror/standarderror.go index f5ad9ec8..d39cc2f0 100644 --- a/internal/pkg/standarderror/standarderror.go +++ b/internal/pkg/standarderror/standarderror.go @@ -64,7 +64,7 @@ func Code(err error) string { return Internal } -const defaultMessage = "an internal error has occurred: please contact technical support" +const defaultMessage string = "an internal error has occurred: please contact technical support" // Message returns the human-readable message of the error, if available. // Otherwise returns a generic error message. diff --git a/internal/pkg/timeout/doc.go b/internal/pkg/timeout/doc.go index 771db613..e8c95004 100644 --- a/internal/pkg/timeout/doc.go +++ b/internal/pkg/timeout/doc.go @@ -1,3 +1,3 @@ -// Package timeout helps creating +// Package timeout helps managing // context with timeout. package timeout diff --git a/internal/pkg/timeout/timeout.go b/internal/pkg/timeout/timeout.go index f29a03d0..37a61325 100644 --- a/internal/pkg/timeout/timeout.go +++ b/internal/pkg/timeout/timeout.go @@ -2,7 +2,11 @@ package timeout import ( "context" + "fmt" + "strings" "time" + + "github.com/thecodingmachine/gotenberg/internal/pkg/standarderror" ) // Context creates a context with timeout for @@ -15,3 +19,29 @@ func Context(seconds float64) (context.Context, context.CancelFunc) { func Duration(seconds float64) time.Duration { return time.Duration(1000*seconds) * time.Millisecond } + +// Err checks if there is an error in the given context +// and wraps the previous error inside a standarderror.Error. +func Err(ctx context.Context, previousErr error) error { + const op string = "timeout.Err" + if previousErr == nil { + panic(fmt.Sprintf("%s: previous error should not be nil", op)) + } + err := ctx.Err() + if err == nil { + return previousErr + } + if strings.Contains(err.Error(), context.DeadlineExceeded.Error()) { + return &standarderror.Error{ + Code: standarderror.Timeout, + Message: "context has timed out", + Op: op, + Err: previousErr, + } + } + return &standarderror.Error{ + Message: "context finished with an error", + Op: op, + Err: previousErr, + } +} diff --git a/internal/pkg/timeout/timeout_test.go b/internal/pkg/timeout/timeout_test.go index 7fa27881..3ff7f724 100644 --- a/internal/pkg/timeout/timeout_test.go +++ b/internal/pkg/timeout/timeout_test.go @@ -1,20 +1,40 @@ package timeout import ( + "errors" "testing" "time" "github.com/stretchr/testify/assert" + "github.com/thecodingmachine/gotenberg/internal/pkg/standarderror" + "github.com/thecodingmachine/gotenberg/test" ) -func TestContext(t *testing.T) { - ctx, cancel := Context(1.5) - assert.NotNil(t, ctx) - assert.NotNil(t, cancel) -} - func TestDuration(t *testing.T) { expected := time.Duration(1500) * time.Millisecond result := Duration(1.5) assert.Equal(t, expected.String(), result.String()) } + +func TestErr(t *testing.T) { + previousErr := errors.New("previous error") + // should be OK. + ctx, cancel := Context(5) + defer cancel() + assert.NotNil(t, Err(ctx, previousErr)) + // should timeout. + ctx, cancel = Context(0.5) + defer cancel() + time.Sleep(Duration(1)) + err := Err(ctx, previousErr) + assert.NotNil(t, err) + standardized := test.RequireStandardError(t, err) + assert.Equal(t, standarderror.Timeout, standardized.Code) + // should failed. + ctx, cancel = Context(5) + cancel() + err = Err(ctx, previousErr) + assert.NotNil(t, err) + standardized = test.RequireStandardError(t, err) + assert.Equal(t, standarderror.Internal, standarderror.Code(err)) +} diff --git a/test/testfunc.go b/test/testfunc.go index c21ba9d5..ba597b25 100644 --- a/test/testfunc.go +++ b/test/testfunc.go @@ -37,6 +37,9 @@ func AssertDirectoryEmpty(t *testing.T, directory string) { assert.Nil(t, err) defer f.Close() // nolint: errcheck _, err = f.Readdir(1) + if err == nil { + return + } assert.Equal(t, io.EOF, err) } From 7bfbda44907af5f60f7690c3f27d920b52e9560a Mon Sep 17 00:00:00 2001 From: Julien Neuhart Date: Tue, 23 Jul 2019 13:57:18 +0200 Subject: [PATCH 25/34] huge refactoring --- .dockerignore | 2 - .travis.yml | 4 +- Makefile | 49 +- build/base/Dockerfile | 6 +- build/docs/Dockerfile | 6 +- build/lint/Dockerfile | 17 +- build/package/Dockerfile | 11 +- build/tests/Dockerfile | 50 +-- build/tests/docker-entrypoint.sh | 32 +- build/workspace/Dockerfile | 52 +++ cmd/gotenberg/main.go | 41 +- internal/app/api/api.go | 34 -- internal/app/api/api_test.go | 388 ---------------- internal/app/api/doc.go | 2 - internal/app/api/pkg/context/context.go | 134 ------ internal/app/api/pkg/context/doc.go | 3 - internal/app/api/pkg/handler/doc.go | 3 - internal/app/api/pkg/handler/handler.go | 142 ------ internal/app/api/pkg/handler/html.go | 30 -- internal/app/api/pkg/handler/markdown.go | 33 -- internal/app/api/pkg/handler/merge.go | 30 -- internal/app/api/pkg/handler/office.go | 43 -- internal/app/api/pkg/handler/ping.go | 10 - internal/app/api/pkg/handler/url.go | 31 -- internal/app/api/pkg/middleware/cleanup.go | 38 -- internal/app/api/pkg/middleware/context.go | 60 --- internal/app/api/pkg/middleware/doc.go | 3 - internal/app/api/pkg/middleware/error.go | 48 -- internal/app/api/pkg/middleware/logger.go | 21 - internal/app/api/pkg/resource/doc.go | 5 - internal/app/api/pkg/resource/resource.go | 421 ------------------ internal/app/xhttp/doc.go | 3 + internal/app/xhttp/handler.go | 298 +++++++++++++ internal/app/xhttp/middleware.go | 128 ++++++ internal/app/xhttp/option.go | 93 ++++ internal/app/xhttp/pkg/context/context.go | 210 +++++++++ internal/app/xhttp/pkg/context/doc.go | 7 + internal/app/xhttp/pkg/resource/arg.go | 253 +++++++++++ internal/app/xhttp/pkg/resource/doc.go | 8 + internal/app/xhttp/pkg/resource/file.go | 91 ++++ internal/app/xhttp/pkg/resource/resource.go | 227 ++++++++++ internal/app/xhttp/xhttp.go | 33 ++ internal/pkg/conf/conf.go | 206 +++++++++ internal/pkg/conf/conf_test.go | 334 ++++++++++++++ internal/pkg/conf/doc.go | 3 + internal/pkg/config/config.go | 178 -------- internal/pkg/config/config_test.go | 123 ----- internal/pkg/config/doc.go | 3 - internal/pkg/logger/doc.go | 3 - internal/pkg/logger/logger.go | 63 --- internal/pkg/pm2/chrome.go | 111 ++--- internal/pkg/pm2/chrome_test.go | 20 - internal/pkg/pm2/doc.go | 2 +- internal/pkg/pm2/pm2.go | 170 +++---- internal/pkg/pm2/unoconv.go | 88 ++-- internal/pkg/pm2/unoconv_test.go | 20 - internal/pkg/printer/chrome.go | 129 +++--- internal/pkg/printer/doc.go | 6 +- internal/pkg/printer/html.go | 14 +- internal/pkg/printer/markdown.go | 61 +-- internal/pkg/printer/merge.go | 60 ++- internal/pkg/printer/office.go | 120 ++--- internal/pkg/printer/printer.go | 9 + internal/pkg/printer/url.go | 16 +- internal/pkg/random/doc.go | 3 - internal/pkg/standarderror/doc.go | 7 - internal/pkg/standarderror/standarderror.go | 108 ----- .../pkg/standarderror/standarderror_test.go | 70 --- internal/pkg/timeout/doc.go | 3 - internal/pkg/timeout/timeout.go | 47 -- internal/pkg/timeout/timeout_test.go | 40 -- internal/pkg/xassert/doc.go | 8 + internal/pkg/xassert/float64.go | 88 ++++ internal/pkg/xassert/float64_test.go | 32 ++ internal/pkg/xassert/int64.go | 88 ++++ internal/pkg/xassert/int64_test.go | 32 ++ internal/pkg/xassert/string.go | 60 +++ internal/pkg/xassert/string_test.go | 20 + internal/pkg/xassert/xassert.go | 185 ++++++++ internal/pkg/xassert/xassert_test.go | 289 ++++++++++++ internal/pkg/xcontext/doc.go | 3 + internal/pkg/xcontext/xcontext.go | 56 +++ internal/pkg/xcontext/xcontext_test.go | 43 ++ internal/pkg/xerror/doc.go | 7 + internal/pkg/xerror/xerror.go | 152 +++++++ internal/pkg/xerror/xerror_test.go | 97 ++++ internal/pkg/xexec/doc.go | 8 + internal/pkg/xexec/xexec.go | 99 ++++ internal/pkg/xexec/xexec_test.go | 39 ++ internal/pkg/xlog/doc.go | 17 + internal/pkg/xlog/xlog.go | 141 ++++++ internal/pkg/xrand/doc.go | 3 + .../pkg/{random/random.go => xrand/xrand.go} | 2 +- .../random_test.go => xrand/xrand_test.go} | 2 +- internal/pkg/xtime/doc.go | 6 + internal/pkg/xtime/xtime.go | 10 + internal/pkg/xtime/xtime_test.go | 14 + scripts/publish.sh | 2 - test/cmd/pm2/pm2.go | 18 + test/doc.go | 3 + test/internalpkg/xerrortest/doc.go | 6 + test/internalpkg/xerrortest/xerrortest.go | 18 + test/internalpkg/xlogtest/doc.go | 6 + test/internalpkg/xlogtest/xlogtest.go | 23 + test/testfunc.go | 24 +- 105 files changed, 4051 insertions(+), 2667 deletions(-) delete mode 100644 .dockerignore create mode 100644 build/workspace/Dockerfile delete mode 100644 internal/app/api/api.go delete mode 100644 internal/app/api/api_test.go delete mode 100644 internal/app/api/doc.go delete mode 100644 internal/app/api/pkg/context/context.go delete mode 100644 internal/app/api/pkg/context/doc.go delete mode 100644 internal/app/api/pkg/handler/doc.go delete mode 100644 internal/app/api/pkg/handler/handler.go delete mode 100644 internal/app/api/pkg/handler/html.go delete mode 100644 internal/app/api/pkg/handler/markdown.go delete mode 100644 internal/app/api/pkg/handler/merge.go delete mode 100644 internal/app/api/pkg/handler/office.go delete mode 100644 internal/app/api/pkg/handler/ping.go delete mode 100644 internal/app/api/pkg/handler/url.go delete mode 100644 internal/app/api/pkg/middleware/cleanup.go delete mode 100644 internal/app/api/pkg/middleware/context.go delete mode 100644 internal/app/api/pkg/middleware/doc.go delete mode 100644 internal/app/api/pkg/middleware/error.go delete mode 100644 internal/app/api/pkg/middleware/logger.go delete mode 100644 internal/app/api/pkg/resource/doc.go delete mode 100644 internal/app/api/pkg/resource/resource.go create mode 100644 internal/app/xhttp/doc.go create mode 100644 internal/app/xhttp/handler.go create mode 100644 internal/app/xhttp/middleware.go create mode 100644 internal/app/xhttp/option.go create mode 100644 internal/app/xhttp/pkg/context/context.go create mode 100644 internal/app/xhttp/pkg/context/doc.go create mode 100644 internal/app/xhttp/pkg/resource/arg.go create mode 100644 internal/app/xhttp/pkg/resource/doc.go create mode 100644 internal/app/xhttp/pkg/resource/file.go create mode 100644 internal/app/xhttp/pkg/resource/resource.go create mode 100644 internal/app/xhttp/xhttp.go create mode 100644 internal/pkg/conf/conf.go create mode 100644 internal/pkg/conf/conf_test.go create mode 100644 internal/pkg/conf/doc.go delete mode 100644 internal/pkg/config/config.go delete mode 100644 internal/pkg/config/config_test.go delete mode 100644 internal/pkg/config/doc.go delete mode 100644 internal/pkg/logger/doc.go delete mode 100644 internal/pkg/logger/logger.go delete mode 100644 internal/pkg/pm2/chrome_test.go delete mode 100644 internal/pkg/pm2/unoconv_test.go delete mode 100644 internal/pkg/random/doc.go delete mode 100644 internal/pkg/standarderror/doc.go delete mode 100644 internal/pkg/standarderror/standarderror.go delete mode 100644 internal/pkg/standarderror/standarderror_test.go delete mode 100644 internal/pkg/timeout/doc.go delete mode 100644 internal/pkg/timeout/timeout.go delete mode 100644 internal/pkg/timeout/timeout_test.go create mode 100644 internal/pkg/xassert/doc.go create mode 100644 internal/pkg/xassert/float64.go create mode 100644 internal/pkg/xassert/float64_test.go create mode 100644 internal/pkg/xassert/int64.go create mode 100644 internal/pkg/xassert/int64_test.go create mode 100644 internal/pkg/xassert/string.go create mode 100644 internal/pkg/xassert/string_test.go create mode 100644 internal/pkg/xassert/xassert.go create mode 100644 internal/pkg/xassert/xassert_test.go create mode 100644 internal/pkg/xcontext/doc.go create mode 100644 internal/pkg/xcontext/xcontext.go create mode 100644 internal/pkg/xcontext/xcontext_test.go create mode 100644 internal/pkg/xerror/doc.go create mode 100644 internal/pkg/xerror/xerror.go create mode 100644 internal/pkg/xerror/xerror_test.go create mode 100644 internal/pkg/xexec/doc.go create mode 100644 internal/pkg/xexec/xexec.go create mode 100644 internal/pkg/xexec/xexec_test.go create mode 100644 internal/pkg/xlog/doc.go create mode 100644 internal/pkg/xlog/xlog.go create mode 100644 internal/pkg/xrand/doc.go rename internal/pkg/{random/random.go => xrand/xrand.go} (89%) rename internal/pkg/{random/random_test.go => xrand/xrand_test.go} (97%) create mode 100644 internal/pkg/xtime/doc.go create mode 100644 internal/pkg/xtime/xtime.go create mode 100644 internal/pkg/xtime/xtime_test.go create mode 100644 test/cmd/pm2/pm2.go create mode 100644 test/doc.go create mode 100644 test/internalpkg/xerrortest/doc.go create mode 100644 test/internalpkg/xerrortest/xerrortest.go create mode 100644 test/internalpkg/xlogtest/doc.go create mode 100644 test/internalpkg/xlogtest/xlogtest.go diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index a3a0fce8..00000000 --- a/.dockerignore +++ /dev/null @@ -1,2 +0,0 @@ -scripts -test \ No newline at end of file diff --git a/.travis.yml b/.travis.yml index 8003da9f..c832bdc7 100644 --- a/.travis.yml +++ b/.travis.yml @@ -17,9 +17,7 @@ stages: jobs: include: - stage: tests - script: make lint - - stage: tests - script: make tests + script: make lint tests - stage: publish if: tag IS present script: make publish VERSION=$TRAVIS_TAG DOCKER_USER=$DOCKER_USER DOCKER_PASSWORD=$DOCKER_PASS \ No newline at end of file diff --git a/Makefile b/Makefile index 0651d971..3a72b36e 100644 --- a/Makefile +++ b/Makefile @@ -1,18 +1,27 @@ -GOLANG_VERSION=1.12 +GOLANG_VERSION=1.12.7 VERSION=snapshot DOCKER_USER= DOCKER_PASSWORD= DOCKER_REPOSITORY=thecodingmachine -DEFAULT_WAIT_TIMEOUT=10 +GOLANGCI_LINT_VERSION=1.17.1 +MAXIMUM_WAIT_TIMEOUT=30.0 +MAXIMUM_WAIT_DELAY=10.0 +MAXIMUM_WEBHOOK_URL_TIMEOUT=30.0 +DEFAULT_WAIT_TIMEOUT=10.0 +DEFAULT_WEBHOOK_URL_TIMEOUT=10.0 DEFAULT_LISTEN_PORT=3000 DISABLE_GOOGLE_CHROME=0 DISABLE_UNOCONV=0 LOG_LEVEL=INFO -# generate documentation. -doc: - docker build --build-arg GOLANG_VERSION=$(GOLANG_VERSION) -t $(DOCKER_REPOSITORY)/gotenberg:docs -f build/docs/Dockerfile . - docker run --rm -it -v "$(PWD):/docs" $(DOCKER_REPOSITORY)/gotenberg:docs +# build the base Docker image. +base: + docker build -t $(DOCKER_REPOSITORY)/gotenberg:base -f build/base/Dockerfile . + +# build the workspace Docker image. +workspace: + make base + docker build --build-arg GOLANG_VERSION=$(GOLANG_VERSION) -t $(DOCKER_REPOSITORY)/gotenberg:workspace -f build/workspace/Dockerfile . # gofmt and goimports all go files. fmt: @@ -21,24 +30,32 @@ fmt: # run all linters. lint: - docker build --build-arg GOLANG_VERSION=$(GOLANG_VERSION) -t $(DOCKER_REPOSITORY)/gotenberg:lint -f build/lint/Dockerfile . - docker run --rm -it -v "$(PWD):/lint" $(DOCKER_REPOSITORY)/gotenberg:lint + make workspace + docker build --build-arg GOLANGCI_LINT_VERSION=$(GOLANGCI_LINT_VERSION) -t $(DOCKER_REPOSITORY)/gotenberg:lint -f build/lint/Dockerfile . + docker run --rm -it $(DOCKER_REPOSITORY)/gotenberg:lint # run all tests. tests: - docker build -t $(DOCKER_REPOSITORY)/gotenberg:base -f build/base/Dockerfile . - docker build --build-arg GOLANG_VERSION=$(GOLANG_VERSION) -t $(DOCKER_REPOSITORY)/gotenberg:tests -f build/tests/Dockerfile . - docker run --rm -it -v "$(PWD):/tests" $(DOCKER_REPOSITORY)/gotenberg:tests + make workspace + docker build -t $(DOCKER_REPOSITORY)/gotenberg:tests -f build/tests/Dockerfile . + docker run --rm -it $(DOCKER_REPOSITORY)/gotenberg:tests -# build Docker image. +# generate documentation. +doc: + make workspace + docker build -t $(DOCKER_REPOSITORY)/gotenberg:docs -f build/docs/Dockerfile . + docker run --rm -it -v "$(PWD):/gotenberg/docs" $(DOCKER_REPOSITORY)/gotenberg:docs + +# build Gotenberg Docker image. image: - docker build -t $(DOCKER_REPOSITORY)/gotenberg:base -f build/base/Dockerfile . - docker build --build-arg GOLANG_VERSION=$(GOLANG_VERSION) --build-arg VERSION=$(VERSION) -t $(DOCKER_REPOSITORY)/gotenberg:$(VERSION) -f build/package/Dockerfile . + make workspace + docker build --build-arg VERSION=$(VERSION) -t $(DOCKER_REPOSITORY)/gotenberg:$(VERSION) -f build/package/Dockerfile . # start the API using previously built Docker image. gotenberg: - docker run -it --rm -e DEFAULT_WAIT_TIMEOUT=$(DEFAULT_WAIT_TIMEOUT) -e DEFAULT_LISTEN_PORT=$(DEFAULT_LISTEN_PORT) -e DISABLE_GOOGLE_CHROME=$(DISABLE_GOOGLE_CHROME) -e DISABLE_UNOCONV=$(DISABLE_UNOCONV) -e LOG_LEVEL=$(LOG_LEVEL) -p "3000:$(DEFAULT_LISTEN_PORT)" $(DOCKER_REPOSITORY)/gotenberg:$(VERSION) + docker run -it --rm -e MAXIMUM_WAIT_TIMEOUT=$(MAXIMUM_WAIT_TIMEOUT) -e MAXIMUM_WAIT_DELAY=$(MAXIMUM_WAIT_DELAY) -e MAXIMUM_WEBHOOK_URL_TIMEOUT=$(MAXIMUM_WEBHOOK_URL_TIMEOUT) -e DEFAULT_WEBHOOK_URL_TIMEOUT=$(DEFAULT_WEBHOOK_URL_TIMEOUT) -e MAXIMUM_WEBHOOK_URL_TIMEOUT=$(MAXIMUM_WEBHOOK_URL_TIMEOUT) -e DEFAULT_LISTEN_PORT=$(DEFAULT_LISTEN_PORT) -e DISABLE_GOOGLE_CHROME=$(DISABLE_GOOGLE_CHROME) -e DISABLE_UNOCONV=$(DISABLE_UNOCONV) -e LOG_LEVEL=$(LOG_LEVEL) -p "3000:$(DEFAULT_LISTEN_PORT)" $(DOCKER_REPOSITORY)/gotenberg:$(VERSION) # publish Gotenberg images according to version. publish: - ./scripts/publish.sh $(GOLANG_VERSION) $(VERSION) $(DOCKER_USER) $(DOCKER_PASSWORD) \ No newline at end of file + make workspace + ./scripts/publish.sh $(VERSION) $(DOCKER_USER) $(DOCKER_PASSWORD) \ No newline at end of file diff --git a/build/base/Dockerfile b/build/base/Dockerfile index d6cbbb64..078ac731 100644 --- a/build/base/Dockerfile +++ b/build/base/Dockerfile @@ -16,7 +16,7 @@ RUN echo "deb http://httpredir.debian.org/debian/ stretch main contrib non-free" # |-------------------------------------------------------------------------- # | # | Installs PM2 for launching programs in background and with failure -# | recovering. In our case: Chrome (headless) and Office (headless). +# | recovering. In our case: Google Chrome (headless) and unoconv. # | RUN curl -sL https://deb.nodesource.com/setup_9.x | bash - &&\ @@ -101,6 +101,8 @@ COPY build/base/fonts.conf /etc/fonts/conf.d/100-gotenberg.conf # | RUN groupadd --gid 1001 gotenberg \ - && useradd --uid 1001 --gid gotenberg --shell /bin/bash --no-create-home gotenberg \ + && useradd --uid 1001 --gid gotenberg --shell /bin/bash --home /gotenberg --no-create-home gotenberg \ && mkdir /gotenberg \ && chown gotenberg: /gotenberg + +ENV PM2_HOME=/gotenberg/.pm2 \ No newline at end of file diff --git a/build/docs/Dockerfile b/build/docs/Dockerfile index 27ca474e..6c6a2df8 100644 --- a/build/docs/Dockerfile +++ b/build/docs/Dockerfile @@ -1,6 +1,4 @@ -ARG GOLANG_VERSION - -FROM golang:${GOLANG_VERSION}-stretch +FROM thecodingmachine/gotenberg:workspace # |-------------------------------------------------------------------------- # | static @@ -19,6 +17,6 @@ RUN go get github.com/apex/static/cmd/static-docs # | Last instructions of this build. # | -WORKDIR /docs +WORKDIR /gotenberg/docs CMD [ "static-docs", "--in", "build/docs/content", "--out", "docs", "--theme", "gotenberg", "--title", "Gotenberg", "--subtitle", "A Docker-powered stateless API for converting HTML, Markdown and Office documents to PDF." ] \ No newline at end of file diff --git a/build/lint/Dockerfile b/build/lint/Dockerfile index 87fc8096..82b67453 100644 --- a/build/lint/Dockerfile +++ b/build/lint/Dockerfile @@ -1,6 +1,4 @@ -ARG GOLANG_VERSION - -FROM golang:${GOLANG_VERSION}-stretch +FROM thecodingmachine/gotenberg:workspace # |-------------------------------------------------------------------------- # | GolangCI-Lint @@ -10,7 +8,7 @@ FROM golang:${GOLANG_VERSION}-stretch # | than gometalinter. # | -ENV GOLANGCI_LINT_VERSION 1.16.0 +ARG GOLANGCI_LINT_VERSION RUN curl -sfL https://install.goreleaser.com/github.com/golangci/golangci-lint.sh | sh -s -- -b /usr/local/bin v${GOLANGCI_LINT_VERSION} &&\ golangci-lint --version @@ -23,13 +21,14 @@ RUN curl -sfL https://install.goreleaser.com/github.com/golangci/golangci-lint.s # | # Define our working directory outside of $GOPATH (we're using go modules). -WORKDIR /lint +USER gotenberg +WORKDIR /gotenberg/lint -# Copy our module dependencies definitions. -COPY go.mod . -COPY go.sum . +# Copy our code source. +COPY --chown=gotenberg:gotenberg . . # Install module dependencies. -RUN go mod download +RUN go mod download &&\ + go mod verify CMD ["golangci-lint", "run" ,"--tests=false", "--enable-all", "--disable=dupl" ] \ No newline at end of file diff --git a/build/package/Dockerfile b/build/package/Dockerfile index 532a55a2..164eca29 100644 --- a/build/package/Dockerfile +++ b/build/package/Dockerfile @@ -1,5 +1,3 @@ -ARG GOLANG_VERSION - # |-------------------------------------------------------------------------- # | Binary # |-------------------------------------------------------------------------- @@ -7,7 +5,7 @@ ARG GOLANG_VERSION # | Buils Gotenberg binary. # | -FROM golang:${GOLANG_VERSION}-stretch AS golang +FROM thecodingmachine/gotenberg:workspace AS workspace ARG VERSION @@ -16,7 +14,7 @@ ENV GOOS=linux \ CGO_ENABLED=0 # Define our workding outside of $GOPATH (we're using go modules). -WORKDIR /gotenberg +WORKDIR /gotenberg/package # Copy our source code. COPY internal ./internal @@ -25,7 +23,7 @@ COPY go.sum go.sum COPY go.mod go.mod # Build our binary. -RUN go build -o /gotenberg/gotenberg -ldflags "-X main.version=${VERSION}" cmd/gotenberg/main.go +RUN go build -o gotenberg -ldflags "-X main.version=${VERSION}" cmd/gotenberg/main.go # |-------------------------------------------------------------------------- # | Final touch @@ -38,9 +36,8 @@ FROM thecodingmachine/gotenberg:base LABEL authors="Julien Neuhart " -COPY --from=golang /gotenberg/gotenberg /usr/local/bin/ +COPY --from=workspace /gotenberg/package/gotenberg /usr/local/bin/ -ENV PM2_HOME=/gotenberg/.pm2 USER gotenberg WORKDIR /gotenberg diff --git a/build/tests/Dockerfile b/build/tests/Dockerfile index 7aaa9586..55599671 100644 --- a/build/tests/Dockerfile +++ b/build/tests/Dockerfile @@ -1,48 +1,14 @@ -ARG GOLANG_VERSION +FROM thecodingmachine/gotenberg:workspace -FROM golang:${GOLANG_VERSION}-stretch AS golang +# Define our workding outside of $GOPATH (we're using go modules). +USER gotenberg +WORKDIR /gotenberg/tests -FROM thecodingmachine/gotenberg:base - -# |-------------------------------------------------------------------------- -# | Common libraries -# |-------------------------------------------------------------------------- -# | -# | Libraries used in the build process of this image. -# | - -RUN apt-get install -y git gcc - -# |-------------------------------------------------------------------------- -# | Golang -# |-------------------------------------------------------------------------- -# | -# | Installs Golang. -# | - -COPY --from=golang /usr/local/go /usr/local/go - -RUN export PATH="/usr/local/go/bin:$PATH" &&\ - go version - -ENV GOPATH /go -ENV PATH $GOPATH/bin:/usr/local/go/bin:$PATH - -# |-------------------------------------------------------------------------- -# | Final touch -# |-------------------------------------------------------------------------- -# | -# | Last instructions of this build. -# | - -# Define our working directory outside of $GOPATH (we're using go modules). -WORKDIR /tests - -# Copy our module dependencies definitions. -COPY go.mod . -COPY go.sum . +# Copy our code source. +COPY --chown=gotenberg:gotenberg . . # Install module dependencies. -RUN go mod download +RUN go mod download &&\ + go mod verify ENTRYPOINT [ "build/tests/docker-entrypoint.sh" ] \ No newline at end of file diff --git a/build/tests/docker-entrypoint.sh b/build/tests/docker-entrypoint.sh index 21a86c41..de0361d9 100755 --- a/build/tests/docker-entrypoint.sh +++ b/build/tests/docker-entrypoint.sh @@ -2,19 +2,33 @@ set -xe +# Make sure the user running the +# tests is the Gotenberg user. +CURRENT_USER=$(whoami) +if [ "$CURRENT_USER" != "gotenberg" ]; then + exit 1 +fi + +# Start the PM2 processes +# (Google Chrome headless & unoconv listener). +go run github.com/thecodingmachine/gotenberg/test/cmd/pm2 + +# Run our tests. +go test -race -cover ./... + # Testing PM2 processes launch separatly for avoiding # spending to much time on each tests depending on # them. -go test github.com/thecodingmachine/gotenberg/internal/pkg/pm2 -run TestChromeStart -go test github.com/thecodingmachine/gotenberg/internal/pkg/pm2 -run TestUnoconvStart +#go test github.com/thecodingmachine/gotenberg/internal/pkg/pm2 -run TestChromeStart +#go test github.com/thecodingmachine/gotenberg/internal/pkg/pm2 -run TestUnoconvStart # Running others tests. -go test -race -cover -covermode=atomic github.com/thecodingmachine/gotenberg/internal/pkg/config -go test -race -cover -covermode=atomic github.com/thecodingmachine/gotenberg/internal/pkg/random -go test -race -cover -covermode=atomic github.com/thecodingmachine/gotenberg/internal/pkg/standarderror -go test -race -cover -covermode=atomic github.com/thecodingmachine/gotenberg/internal/pkg/timeout -go test -race -cover -covermode=atomic github.com/thecodingmachine/gotenberg/internal/app/api +#go test -race -cover -covermode=atomic github.com/thecodingmachine/gotenberg/internal/pkg/config +#go test -race -cover -covermode=atomic github.com/thecodingmachine/gotenberg/internal/pkg/random +#go test -race -cover -covermode=atomic github.com/thecodingmachine/gotenberg/internal/pkg/standarderror +#go test -race -cover -covermode=atomic github.com/thecodingmachine/gotenberg/internal/pkg/timeout +#go test -race -cover -covermode=atomic github.com/thecodingmachine/gotenberg/internal/app/api # Finally testing processes shutdown. -go test github.com/thecodingmachine/gotenberg/internal/pkg/pm2 -run TestChromeShutdown -go test github.com/thecodingmachine/gotenberg/internal/pkg/pm2 -run TestUnoconvShutdown \ No newline at end of file +#go test github.com/thecodingmachine/gotenberg/internal/pkg/pm2 -run TestChromeShutdown +#go test github.com/thecodingmachine/gotenberg/internal/pkg/pm2 -run TestUnoconvShutdown \ No newline at end of file diff --git a/build/workspace/Dockerfile b/build/workspace/Dockerfile new file mode 100644 index 00000000..2ae20d7e --- /dev/null +++ b/build/workspace/Dockerfile @@ -0,0 +1,52 @@ +FROM thecodingmachine/gotenberg:base + +ARG GOLANG_VERSION + +# |-------------------------------------------------------------------------- +# | Common libraries +# |-------------------------------------------------------------------------- +# | +# | Libraries used in the build process of this image. +# | + +RUN apt-get update && apt-get install -y --no-install-recommends \ + git \ + g++ \ + gcc \ + libc6-dev \ + make \ + pkg-config \ + && rm -rf /var/lib/apt/lists/* + +# |-------------------------------------------------------------------------- +# | Golang +# |-------------------------------------------------------------------------- +# | +# | Installs Golang. +# | + +RUN wget https://dl.google.com/go/go${GOLANG_VERSION}.linux-amd64.tar.gz &&\ + tar -xvf go${GOLANG_VERSION}.linux-amd64.tar.gz &&\ + mv go /usr/local + +ENV GOPATH /gotenberg/go +ENV PATH $GOPATH/bin:/usr/local/go/bin:$PATH + +RUN mkdir -p "$GOPATH/src" "$GOPATH/bin" &&\ + chmod -R 777 "$GOPATH" + +# |-------------------------------------------------------------------------- +# | Final touch +# |-------------------------------------------------------------------------- +# | +# | Last instructions of this build. +# | + +# Make sure the Gotenber user is able to +# call the Go binary. +USER gotenberg + +RUN go version &&\ + go env + +USER root \ No newline at end of file diff --git a/cmd/gotenberg/main.go b/cmd/gotenberg/main.go index 92131b52..328ad8a9 100644 --- a/cmd/gotenberg/main.go +++ b/cmd/gotenberg/main.go @@ -1,17 +1,16 @@ package main import ( - "context" "fmt" "net/http" "os" "os/signal" - "time" - "github.com/thecodingmachine/gotenberg/internal/app/api" - "github.com/thecodingmachine/gotenberg/internal/pkg/config" - "github.com/thecodingmachine/gotenberg/internal/pkg/logger" + "github.com/thecodingmachine/gotenberg/internal/app/xhttp" + "github.com/thecodingmachine/gotenberg/internal/pkg/conf" "github.com/thecodingmachine/gotenberg/internal/pkg/pm2" + "github.com/thecodingmachine/gotenberg/internal/pkg/xcontext" + "github.com/thecodingmachine/gotenberg/internal/pkg/xlog" ) // version will be set on build time. @@ -20,8 +19,8 @@ var version = "snapshot" func main() { const op = "main" - config, err := config.FromEnv() - systemLogger := logger.New(config.LogLevel(), "system") + config, err := conf.FromEnv() + systemLogger := xlog.New(config.LogLevel(), "system") if err != nil { systemLogger.FatalOp(op, err) } @@ -29,24 +28,24 @@ func main() { systemLogger.DebugfOp(op, "configuration: %+v", config) // start PM2 processes. var processes []pm2.Process - if config.EnableChromeEndpoints() { - processes = append(processes, pm2.NewChrome(systemLogger)) + if !config.DisableGoogleChrome() { + processes = append(processes, pm2.NewChromeProcess(systemLogger)) } - if config.EnableUnoconvEndpoints() { - processes = append(processes, pm2.NewUnoconv(systemLogger)) + if !config.DisableUnoconv() { + processes = append(processes, pm2.NewUnoconvProcess(systemLogger)) } for _, p := range processes { - systemLogger.InfofOp(op, "starting %s with PM2...", p.Fullname()) + systemLogger.InfofOp(op, "starting '%s' with PM2...", p.Fullname()) if err := p.Start(); err != nil { systemLogger.FatalOp(op, err) } } - // run our API in a goroutine so that it doesn't block. // create our API. - srv := api.New(config) + srv := xhttp.New(config, processes...) + // run our API in a goroutine so that it doesn't block. go func() { - systemLogger.InfofOp(op, "http server started on port %s", config.DefaultListenPort()) - if err := srv.Start(fmt.Sprintf(":%s", config.DefaultListenPort())); err != nil { + systemLogger.InfofOp(op, "http server started on port '%d'", config.DefaultListenPort()) + if err := srv.Start(fmt.Sprintf(":%d", config.DefaultListenPort())); err != nil { if err != http.ErrServerClosed { systemLogger.FatalOp(op, err) } @@ -59,21 +58,21 @@ func main() { // block until we receive our signal. <-quit // create a deadline to wait for. - ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + ctx, cancel := xcontext.WithTimeout(systemLogger, 120) defer cancel() // doesn't block if no connections, but will otherwise wait // until the timeout deadline. - systemLogger.InfofOp(op, "shutting down http server...") + systemLogger.InfoOp(op, "shutting down http server...") if err := srv.Shutdown(ctx); err != nil { systemLogger.FatalOp(op, err) } // shutdown PM2 processes. for _, p := range processes { - systemLogger.InfofOp(op, "shutting down %s with PM2...", p.Fullname()) - if err := p.Shutdown(); err != nil { + systemLogger.InfofOp(op, "shutting down '%s' with PM2...", p.Fullname()) + if err := p.Stop(); err != nil { systemLogger.FatalOp(op, err) } } - systemLogger.InfofOp(op, "bye!") + systemLogger.InfoOp(op, "bye!") os.Exit(0) } diff --git a/internal/app/api/api.go b/internal/app/api/api.go deleted file mode 100644 index 3a711782..00000000 --- a/internal/app/api/api.go +++ /dev/null @@ -1,34 +0,0 @@ -package api - -import ( - "github.com/labstack/echo/v4" - "github.com/thecodingmachine/gotenberg/internal/app/api/pkg/handler" - "github.com/thecodingmachine/gotenberg/internal/app/api/pkg/middleware" - "github.com/thecodingmachine/gotenberg/internal/pkg/config" -) - -// New returns an API. -func New(config *config.Config) *echo.Echo { - api := echo.New() - api.HideBanner = true - api.HidePort = true - api.Use(middleware.Context(config)) - api.Use(middleware.Logger()) - api.Use(middleware.Cleanup()) - api.Use(middleware.Error()) - api.GET(handler.PingEndpoint, handler.Ping) - api.POST(handler.MergeEndpoint, handler.Merge) - if !config.EnableChromeEndpoints() && !config.EnableUnoconvEndpoints() { - return api - } - g := api.Group(handler.ConvertGroupEndpoint) - if config.EnableChromeEndpoints() { - g.POST(handler.HTMLEndpoint, handler.HTML) - g.POST(handler.URLEndpoint, handler.URL) - g.POST(handler.MarkdownEndpoint, handler.Markdown) - } - if config.EnableUnoconvEndpoints() { - g.POST(handler.OfficeEndpoint, handler.Office) - } - return api -} diff --git a/internal/app/api/api_test.go b/internal/app/api/api_test.go deleted file mode 100644 index 7a4f5dce..00000000 --- a/internal/app/api/api_test.go +++ /dev/null @@ -1,388 +0,0 @@ -package api - -import ( - "errors" - "fmt" - "io/ioutil" - "net/http" - "net/http/httptest" - "os" - "testing" - - "github.com/labstack/echo/v4" - "github.com/stretchr/testify/assert" - "github.com/thecodingmachine/gotenberg/internal/app/api/pkg/handler" - "github.com/thecodingmachine/gotenberg/internal/app/api/pkg/middleware" - "github.com/thecodingmachine/gotenberg/internal/app/api/pkg/resource" - "github.com/thecodingmachine/gotenberg/internal/pkg/config" - "github.com/thecodingmachine/gotenberg/test" -) - -func TestPing(t *testing.T) { - endpoint := handler.PingEndpoint - config, err := config.FromEnv() - assert.Nil(t, err) - srv := New(config) - // should be OK. - req := httptest.NewRequest(http.MethodGet, endpoint, nil) - test.AssertStatusCode(t, http.StatusOK, srv, req) -} - -func TestMerge(t *testing.T) { - os.Setenv(middleware.TestingTraceEnvVar, "1") - endpoint := handler.MergeEndpoint - config, err := config.FromEnv() - assert.Nil(t, err) - srv := New(config) - // should be OK. - body, contentType := test.PDFTestMultipartForm(t, nil) - req := httptest.NewRequest(http.MethodPost, endpoint, body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusOK, srv, req) - // bad request. - body, contentType = test.PDFTestMultipartForm(t, map[string]string{resource.WaitTimeoutFormField: "not a float"}) - req = httptest.NewRequest(http.MethodPost, endpoint, body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - // timeout. - body, contentType = test.PDFTestMultipartForm(t, map[string]string{resource.WaitTimeoutFormField: "0"}) - req = httptest.NewRequest(http.MethodPost, endpoint, body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusRequestTimeout, srv, req) - // should have no more resources. - test.AssertDirectoryEmpty(t, middleware.TestsTracePrefix) - err = os.RemoveAll(middleware.TestsTracePrefix) - assert.Nil(t, err) - os.Unsetenv(middleware.TestingTraceEnvVar) -} - -func TestHTML(t *testing.T) { - os.Setenv(middleware.TestingTraceEnvVar, "1") - endpoint := fmt.Sprintf("%s%s", handler.ConvertGroupEndpoint, handler.HTMLEndpoint) - config, err := config.FromEnv() - assert.Nil(t, err) - srv := New(config) - // should be OK. - body, contentType := test.HTMLTestMultipartForm(t, nil) - req := httptest.NewRequest(http.MethodPost, endpoint, body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusOK, srv, req) - // bad request. - body, contentType = test.HTMLTestMultipartForm(t, map[string]string{resource.WaitTimeoutFormField: "not a float"}) - req = httptest.NewRequest(http.MethodPost, endpoint, body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.HTMLTestMultipartForm(t, map[string]string{resource.WaitDelayFormField: "not a float"}) - req = httptest.NewRequest(http.MethodPost, endpoint, body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.HTMLTestMultipartForm(t, map[string]string{resource.PaperWidthFormField: "not a float"}) - req = httptest.NewRequest(http.MethodPost, endpoint, body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.HTMLTestMultipartForm(t, map[string]string{resource.PaperHeightFormField: "not a float"}) - req = httptest.NewRequest(http.MethodPost, endpoint, body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.HTMLTestMultipartForm(t, map[string]string{resource.MarginTopFormField: "not a float"}) - req = httptest.NewRequest(http.MethodPost, endpoint, body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.HTMLTestMultipartForm(t, map[string]string{resource.MarginBottomFormField: "not a float"}) - req = httptest.NewRequest(http.MethodPost, endpoint, body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.HTMLTestMultipartForm(t, map[string]string{resource.MarginLeftFormField: "not a float"}) - req = httptest.NewRequest(http.MethodPost, endpoint, body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.HTMLTestMultipartForm(t, map[string]string{resource.MarginRightFormField: "not a float"}) - req = httptest.NewRequest(http.MethodPost, endpoint, body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.HTMLTestMultipartForm(t, map[string]string{resource.LandscapeFormField: "not a bool"}) - req = httptest.NewRequest(http.MethodPost, endpoint, body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - // timeout. - body, contentType = test.HTMLTestMultipartForm(t, map[string]string{resource.WaitTimeoutFormField: "0"}) - req = httptest.NewRequest(http.MethodPost, endpoint, body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusRequestTimeout, srv, req) - // should have no more resources. - test.AssertDirectoryEmpty(t, middleware.TestsTracePrefix) - err = os.RemoveAll(middleware.TestsTracePrefix) - assert.Nil(t, err) - os.Unsetenv(middleware.TestingTraceEnvVar) -} - -func TestMarkdown(t *testing.T) { - os.Setenv(middleware.TestingTraceEnvVar, "1") - endpoint := fmt.Sprintf("%s%s", handler.ConvertGroupEndpoint, handler.MarkdownEndpoint) - config, err := config.FromEnv() - assert.Nil(t, err) - srv := New(config) - // should be OK. - body, contentType := test.MarkdownTestMultipartForm(t, nil) - req := httptest.NewRequest(http.MethodPost, endpoint, body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusOK, srv, req) - // bad request. - body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{resource.WaitTimeoutFormField: "not a float"}) - req = httptest.NewRequest(http.MethodPost, endpoint, body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{resource.WaitDelayFormField: "not a float"}) - req = httptest.NewRequest(http.MethodPost, endpoint, body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{resource.PaperWidthFormField: "not a float"}) - req = httptest.NewRequest(http.MethodPost, endpoint, body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{resource.PaperHeightFormField: "not a float"}) - req = httptest.NewRequest(http.MethodPost, endpoint, body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{resource.MarginTopFormField: "not a float"}) - req = httptest.NewRequest(http.MethodPost, endpoint, body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{resource.MarginBottomFormField: "not a float"}) - req = httptest.NewRequest(http.MethodPost, endpoint, body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{resource.MarginLeftFormField: "not a float"}) - req = httptest.NewRequest(http.MethodPost, endpoint, body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{resource.MarginRightFormField: "not a float"}) - req = httptest.NewRequest(http.MethodPost, endpoint, body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{resource.LandscapeFormField: "not a bool"}) - req = httptest.NewRequest(http.MethodPost, endpoint, body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - // timeout. - body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{resource.WaitTimeoutFormField: "0"}) - req = httptest.NewRequest(http.MethodPost, endpoint, body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusRequestTimeout, srv, req) - // should have no more resources. - test.AssertDirectoryEmpty(t, middleware.TestsTracePrefix) - err = os.RemoveAll(middleware.TestsTracePrefix) - assert.Nil(t, err) - os.Unsetenv(middleware.TestingTraceEnvVar) -} - -func TestURL(t *testing.T) { - os.Setenv(middleware.TestingTraceEnvVar, "1") - endpoint := fmt.Sprintf("%s%s", handler.ConvertGroupEndpoint, handler.URLEndpoint) - config, err := config.FromEnv() - assert.Nil(t, err) - srv := New(config) - // should be OK. - body, contentType := test.URLTestMultipartForm(t, nil) - req := httptest.NewRequest(http.MethodPost, endpoint, body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusOK, srv, req) - // bad request. - body, contentType = test.URLTestMultipartForm(t, map[string]string{resource.WaitTimeoutFormField: "not a float"}) - req = httptest.NewRequest(http.MethodPost, endpoint, body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.URLTestMultipartForm(t, map[string]string{resource.WaitDelayFormField: "not a float"}) - req = httptest.NewRequest(http.MethodPost, endpoint, body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.URLTestMultipartForm(t, map[string]string{resource.PaperWidthFormField: "not a float"}) - req = httptest.NewRequest(http.MethodPost, endpoint, body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.URLTestMultipartForm(t, map[string]string{resource.PaperHeightFormField: "not a float"}) - req = httptest.NewRequest(http.MethodPost, endpoint, body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.URLTestMultipartForm(t, map[string]string{resource.MarginTopFormField: "not a float"}) - req = httptest.NewRequest(http.MethodPost, endpoint, body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.URLTestMultipartForm(t, map[string]string{resource.MarginBottomFormField: "not a float"}) - req = httptest.NewRequest(http.MethodPost, endpoint, body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.URLTestMultipartForm(t, map[string]string{resource.MarginLeftFormField: "not a float"}) - req = httptest.NewRequest(http.MethodPost, endpoint, body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.URLTestMultipartForm(t, map[string]string{resource.MarginRightFormField: "not a float"}) - req = httptest.NewRequest(http.MethodPost, endpoint, body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.URLTestMultipartForm(t, map[string]string{resource.LandscapeFormField: "not a bool"}) - req = httptest.NewRequest(http.MethodPost, endpoint, body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - // timeout. - body, contentType = test.URLTestMultipartForm(t, map[string]string{resource.WaitTimeoutFormField: "0"}) - req = httptest.NewRequest(http.MethodPost, endpoint, body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusRequestTimeout, srv, req) - // should have no more resources. - test.AssertDirectoryEmpty(t, middleware.TestsTracePrefix) - err = os.RemoveAll(middleware.TestsTracePrefix) - assert.Nil(t, err) - os.Unsetenv(middleware.TestingTraceEnvVar) -} - -func TestConcurrent(t *testing.T) { - const concurrentRequests int = 4 - os.Setenv(middleware.TestingTraceEnvVar, "1") - config, err := config.FromEnv() - assert.Nil(t, err) - srv := New(config) - // Merge. - test.AssertConcurrent( - t, - func() error { - body, contentType := test.PDFTestMultipartForm(t, map[string]string{resource.WaitTimeoutFormField: "120"}) - req := httptest.NewRequest(http.MethodPost, handler.MergeEndpoint, body) - req.Header.Set(echo.HeaderContentType, contentType) - rec := httptest.NewRecorder() - srv.ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - return fmt.Errorf("wrong status code: want '%d' got '%d'", http.StatusOK, rec.Code) - } - return nil - }, - concurrentRequests, - ) - // HTML. - test.AssertConcurrent( - t, - func() error { - body, contentType := test.HTMLTestMultipartForm(t, map[string]string{resource.WaitTimeoutFormField: "120"}) - req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("%s%s", handler.ConvertGroupEndpoint, handler.HTMLEndpoint), body) - req.Header.Set(echo.HeaderContentType, contentType) - rec := httptest.NewRecorder() - srv.ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - return fmt.Errorf("wrong status code: want '%d' got '%d'", http.StatusOK, rec.Code) - } - return nil - }, - concurrentRequests, - ) - // Markdown. - test.AssertConcurrent( - t, - func() error { - body, contentType := test.MarkdownTestMultipartForm(t, map[string]string{resource.WaitTimeoutFormField: "120"}) - req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("%s%s", handler.ConvertGroupEndpoint, handler.MarkdownEndpoint), body) - req.Header.Set(echo.HeaderContentType, contentType) - rec := httptest.NewRecorder() - srv.ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - return fmt.Errorf("wrong status code: want '%d' got '%d'", http.StatusOK, rec.Code) - } - return nil - }, - concurrentRequests, - ) - // URL. - test.AssertConcurrent( - t, - func() error { - body, contentType := test.URLTestMultipartForm(t, map[string]string{resource.WaitTimeoutFormField: "120"}) - req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("%s%s", handler.ConvertGroupEndpoint, handler.URLEndpoint), body) - req.Header.Set(echo.HeaderContentType, contentType) - rec := httptest.NewRecorder() - srv.ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - return fmt.Errorf("wrong status code: want '%d' got '%d'", http.StatusOK, rec.Code) - } - return nil - }, - concurrentRequests, - ) - // Office. - test.AssertConcurrent( - t, - func() error { - body, contentType := test.OfficeTestMultipartForm(t, map[string]string{resource.WaitTimeoutFormField: "120"}) - req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("%s%s", handler.ConvertGroupEndpoint, handler.OfficeEndpoint), body) - req.Header.Set(echo.HeaderContentType, contentType) - rec := httptest.NewRecorder() - srv.ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - return fmt.Errorf("wrong status code: want '%d' got '%d'", http.StatusOK, rec.Code) - } - return nil - }, - concurrentRequests, - ) - // should have no more resources. - test.AssertDirectoryEmpty(t, middleware.TestsTracePrefix) - err = os.RemoveAll(middleware.TestsTracePrefix) - assert.Nil(t, err) - os.Unsetenv(middleware.TestingTraceEnvVar) -} - -func TestWebhook(t *testing.T) { - status := make(chan error, 2) - rcv := echo.New() - rcv.POST("/foo", func(c echo.Context) error { - if c.Request().Header.Get("Content-type") != "application/pdf" { - status <- fmt.Errorf("wrong Content-type: got '%s' want '%s'", c.Request().Header.Get("Content-type"), "application/pdf") - return nil - } - body, err := ioutil.ReadAll(c.Request().Body) - if err != nil { - status <- err - return nil - } - if body == nil || len(body) == 0 { - status <- errors.New("empty body") - return nil - } - status <- nil - return nil - }) - go func() { - rcv.Start(":3001") - }() - os.Setenv(middleware.TestingTraceEnvVar, "1") - config, err := config.FromEnv() - assert.Nil(t, err) - srv := New(config) - body, contentType := test.PDFTestMultipartForm(t, map[string]string{resource.WebhookURLFormField: "http://localhost:3001/foo"}) - req := httptest.NewRequest(http.MethodPost, "/merge", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusOK, srv, req) - err = <-status - assert.NoError(t, err) - // should have no more resources. - test.AssertDirectoryEmpty(t, middleware.TestsTracePrefix) - err = os.RemoveAll(middleware.TestsTracePrefix) - assert.Nil(t, err) - os.Unsetenv(middleware.TestingTraceEnvVar) -} - -func TestResultFilename(t *testing.T) { - os.Setenv(middleware.TestingTraceEnvVar, "1") - config, err := config.FromEnv() - assert.Nil(t, err) - srv := New(config) - body, contentType := test.PDFTestMultipartForm(t, map[string]string{resource.ResultFilenameFormField: "foo.pdf"}) - req := httptest.NewRequest(http.MethodPost, "/merge", body) - req.Header.Set(echo.HeaderContentType, contentType) - rec := httptest.NewRecorder() - srv.ServeHTTP(rec, req) - assert.Equal(t, "attachment; filename=\"foo.pdf\"", rec.Header().Get("Content-Disposition")) - // should have no more resources. - test.AssertDirectoryEmpty(t, middleware.TestsTracePrefix) - err = os.RemoveAll(middleware.TestsTracePrefix) - assert.Nil(t, err) - os.Unsetenv(middleware.TestingTraceEnvVar) -} diff --git a/internal/app/api/doc.go b/internal/app/api/doc.go deleted file mode 100644 index 7c19cefb..00000000 --- a/internal/app/api/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package api helps managing the HTTP server behind Gotenberg. -package api diff --git a/internal/app/api/pkg/context/context.go b/internal/app/api/pkg/context/context.go deleted file mode 100644 index f8437f95..00000000 --- a/internal/app/api/pkg/context/context.go +++ /dev/null @@ -1,134 +0,0 @@ -package context - -import ( - "fmt" - "net/http" - "strconv" - "time" - - "github.com/labstack/echo/v4" - "github.com/thecodingmachine/gotenberg/internal/app/api/pkg/resource" - "github.com/thecodingmachine/gotenberg/internal/pkg/config" - "github.com/thecodingmachine/gotenberg/internal/pkg/logger" - "github.com/thecodingmachine/gotenberg/internal/pkg/standarderror" -) - -// Context extends the default echo.Context. -type Context struct { - echo.Context - logger *logger.Logger - config *config.Config - resource *resource.Resource - startTime time.Time -} - -// New creates a new context. -func New(c echo.Context, logger *logger.Logger, config *config.Config) *Context { - return &Context{ - c, - logger, - config, - nil, - time.Now(), - } -} - -// MustCastFromEchoContext cast an echo.Context to our custom -// context. If something goes wrong, panic. -func MustCastFromEchoContext(c echo.Context) *Context { - const op string = "context.MustCastFromEchoContext" - ctx, ok := c.(*Context) - if !ok { - panic(fmt.Sprintf("%s: unable to cast an echo.Context to a custom context", op)) - } - return ctx -} - -// StandardLogger returns the custom logger. -// This method should be used instead of the -// default Logger() method coming from -// the echo.Context! -func (ctx *Context) StandardLogger() *logger.Logger { - return ctx.logger -} - -// Resource returns the associated resource -// to the context. -func (ctx *Context) Resource() *resource.Resource { - return ctx.resource -} - -// WithResource adds a resource to the context. -func (ctx *Context) WithResource(resourceDirPath string) error { - const op string = "context.WithResource" - r, err := resource.New(ctx, ctx.logger, ctx.config, resourceDirPath) - ctx.resource = r - if err != nil { - return &standarderror.Error{ - Op: op, - Err: err, - } - } - return nil -} - -// LogRequestResult logs the result of a request. -// This method should only be used by a middleware! -func (ctx *Context) LogRequestResult(err error, isDebug bool) error { - const op string = "context.LogRequestResult" - req := ctx.Request() - resp := ctx.Response() - stopTime := time.Now() - fields := map[string]interface{}{ - "remote_ip": ctx.RealIP(), - "host": req.Host, - "uri": req.RequestURI, - "method": req.Method, - "path": path(req), - "referer": req.Referer(), - "user_agent": req.UserAgent(), - "status": resp.Status, - "latency": lantency(ctx.startTime, stopTime), - "latency_human": latencyHuman(ctx.startTime, stopTime), - "bytes_in": bytesIn(req), - "bytes_out": bytesOut(resp), - } - if err != nil { - ctx.logger.WithFields(fields).ErrorfOp(op, "request failed") - return err - } - if isDebug { - ctx.logger.WithFields(fields).DebugfOp(op, "request handled") - return nil - } - ctx.logger.WithFields(fields).InfofOp(op, "request handled") - return nil -} - -func path(r *http.Request) string { - path := r.URL.Path - if path == "" { - path = "/" - } - return path -} - -func lantency(startTime time.Time, stopTime time.Time) string { - return strconv.FormatInt(int64(stopTime.Sub(startTime)), 10) -} - -func latencyHuman(startTime time.Time, stopTime time.Time) string { - return stopTime.Sub(startTime).String() -} - -func bytesIn(r *http.Request) string { - bytesIn := r.Header.Get(echo.HeaderContentLength) - if bytesIn == "" { - bytesIn = "0" - } - return bytesIn -} - -func bytesOut(r *echo.Response) string { - return strconv.FormatInt(r.Size, 10) -} diff --git a/internal/app/api/pkg/context/doc.go b/internal/app/api/pkg/context/doc.go deleted file mode 100644 index 4c8b2439..00000000 --- a/internal/app/api/pkg/context/doc.go +++ /dev/null @@ -1,3 +0,0 @@ -// Package context helps extending -// the default echo.Context. -package context diff --git a/internal/app/api/pkg/handler/doc.go b/internal/app/api/pkg/handler/doc.go deleted file mode 100644 index c7fdafe7..00000000 --- a/internal/app/api/pkg/handler/doc.go +++ /dev/null @@ -1,3 +0,0 @@ -// Package handler contains all -// the endpoint methods of the API. -package handler diff --git a/internal/app/api/pkg/handler/handler.go b/internal/app/api/pkg/handler/handler.go deleted file mode 100644 index 92d70f45..00000000 --- a/internal/app/api/pkg/handler/handler.go +++ /dev/null @@ -1,142 +0,0 @@ -package handler - -import ( - "fmt" - "net/http" - "os" - - "github.com/thecodingmachine/gotenberg/internal/app/api/pkg/context" - "github.com/thecodingmachine/gotenberg/internal/app/api/pkg/resource" - "github.com/thecodingmachine/gotenberg/internal/pkg/printer" - "github.com/thecodingmachine/gotenberg/internal/pkg/random" - "github.com/thecodingmachine/gotenberg/internal/pkg/standarderror" -) - -const ( - // PingEndpoint is the route for healthcheck. - PingEndpoint = "/ping" - // MergeEndpoint is the route for merging PDF files. - MergeEndpoint = "/merge" - // ConvertGroupEndpoint is the route of the group - // in charge of converting files to PDF. - ConvertGroupEndpoint = "/convert" - // HTMLEndpoint is the route for converting - // HTML to PDF. - HTMLEndpoint = "/html" - // URLEndpoint is the route for converting - // a URL to PDF. - URLEndpoint = "/url" - // MarkdownEndpoint is the route for converting - // Markdown to PDF. - MarkdownEndpoint = "/markdown" - // OfficeEndpoint is the route for converting - // Office files to PDF. - OfficeEndpoint = "/office" -) - -func convert(ctx *context.Context, p printer.Printer) error { - const op string = "handler.convert" - r := ctx.Resource() - logger := ctx.StandardLogger() - baseFilename := random.Get() - filename := fmt.Sprintf("%s.pdf", baseFilename) - fpath := fmt.Sprintf("%s/%s", r.DirPath(), filename) - // if no webhook URL given, run conversion - // and directly return the resulting PDF file - // or an error. - if !r.Has(resource.WebhookURLFormField) { - logger.DebugfOp(op, "no '%s' found, converting synchronously", resource.WebhookURLFormField) - if err := convertSync(filename, fpath, ctx, p); err != nil { - return &standarderror.Error{Op: op, Err: err} - } - return nil - } - // as a webhook URL has been given, we - // run the following lines in a goroutine so that - // it doesn't block. - logger.DebugfOp(op, "'%s' found, converting asynchronously", resource.WebhookURLFormField) - return convertAsync(filename, fpath, ctx, p) -} - -func convertSync(filename, fpath string, ctx *context.Context, p printer.Printer) error { - const op = "handler.convertSync" - r := ctx.Resource() - logger := ctx.StandardLogger() - if err := p.Print(fpath); err != nil { - return &standarderror.Error{Op: op, Err: err} - } - if !r.Has(resource.ResultFilenameFormField) { - logger.DebugfOp( - op, - "no '%s' found, using generated filename '%s'", - resource.ResultFilenameFormField, - filename, - ) - if err := ctx.Attachment(fpath, filename); err != nil { - return &standarderror.Error{Op: op, Err: err} - } - return nil - } - logger.DebugfOp( - op, - "'%s' found, so not using generated filename", - resource.ResultFilenameFormField, - ) - filename, err := r.Get(resource.ResultFilenameFormField) - if err != nil { - return &standarderror.Error{Op: op, Err: err} - } - if err := ctx.Attachment(fpath, filename); err != nil { - return &standarderror.Error{Op: op, Err: err} - } - return nil -} - -func convertAsync(filename, fpath string, ctx *context.Context, p printer.Printer) error { - const op = "handler.convertAsync" - r := ctx.Resource() - logger := ctx.StandardLogger() - go func() { - defer r.Close() // nolint: errcheck - if err := p.Print(fpath); err != nil { - logger.ErrorOp( - op, - &standarderror.Error{Op: op, Err: err}, - ) - return - } - f, err := os.Open(fpath) - if err != nil { - logger.ErrorOp( - op, - &standarderror.Error{Op: op, Err: err}, - ) - return - } - defer f.Close() // nolint: errcheck - webhookURL, err := r.Get(resource.WebhookURLFormField) - if err != nil { - logger.ErrorOp( - op, - &standarderror.Error{Op: op, Err: err}, - ) - return - } - logger.DebugfOp( - op, - "sending result file '%s' to '%s'", - filename, - webhookURL, - ) - resp, err := http.Post(webhookURL, "application/pdf", f) /* #nosec */ - if err != nil { - logger.ErrorOp( - op, - &standarderror.Error{Op: op, Err: err}, - ) - return - } - defer resp.Body.Close() // nolint: errcheck - }() - return nil -} diff --git a/internal/app/api/pkg/handler/html.go b/internal/app/api/pkg/handler/html.go deleted file mode 100644 index c64f70c7..00000000 --- a/internal/app/api/pkg/handler/html.go +++ /dev/null @@ -1,30 +0,0 @@ -package handler - -import ( - "github.com/labstack/echo/v4" - "github.com/thecodingmachine/gotenberg/internal/app/api/pkg/context" - "github.com/thecodingmachine/gotenberg/internal/pkg/printer" - "github.com/thecodingmachine/gotenberg/internal/pkg/standarderror" -) - -// HTML is the endpoint for converting -// HTML to PDF. -func HTML(c echo.Context) error { - const op string = "handler.HTML" - ctx := context.MustCastFromEchoContext(c) - ctx.StandardLogger().DebugfOp(op, "html request") - r := ctx.Resource() - opts, err := r.ChromePrinterOptions() - if err != nil { - return &standarderror.Error{Op: op, Err: err} - } - fpath, err := r.Fpath("index.html") - if err != nil { - return &standarderror.Error{Op: op, Err: err} - } - p := printer.NewHTML(fpath, opts) - if err := convert(ctx, p); err != nil { - return &standarderror.Error{Op: op, Err: err} - } - return nil -} diff --git a/internal/app/api/pkg/handler/markdown.go b/internal/app/api/pkg/handler/markdown.go deleted file mode 100644 index 6283d4c3..00000000 --- a/internal/app/api/pkg/handler/markdown.go +++ /dev/null @@ -1,33 +0,0 @@ -package handler - -import ( - "github.com/labstack/echo/v4" - "github.com/thecodingmachine/gotenberg/internal/app/api/pkg/context" - "github.com/thecodingmachine/gotenberg/internal/pkg/printer" - "github.com/thecodingmachine/gotenberg/internal/pkg/standarderror" -) - -// Markdown is the endpoint for converting -// Markdown to PDF. -func Markdown(c echo.Context) error { - const op string = "handler.Markdown" - ctx := context.MustCastFromEchoContext(c) - ctx.StandardLogger().DebugfOp(op, "markdown request") - r := ctx.Resource() - opts, err := r.ChromePrinterOptions() - if err != nil { - return &standarderror.Error{Op: op, Err: err} - } - fpath, err := r.Fpath("index.html") - if err != nil { - return &standarderror.Error{Op: op, Err: err} - } - p, err := printer.NewMarkdown(fpath, opts) - if err != nil { - return &standarderror.Error{Op: op, Err: err} - } - if err := convert(ctx, p); err != nil { - return &standarderror.Error{Op: op, Err: err} - } - return nil -} diff --git a/internal/app/api/pkg/handler/merge.go b/internal/app/api/pkg/handler/merge.go deleted file mode 100644 index e3422673..00000000 --- a/internal/app/api/pkg/handler/merge.go +++ /dev/null @@ -1,30 +0,0 @@ -package handler - -import ( - "github.com/labstack/echo/v4" - "github.com/thecodingmachine/gotenberg/internal/app/api/pkg/context" - "github.com/thecodingmachine/gotenberg/internal/pkg/printer" - "github.com/thecodingmachine/gotenberg/internal/pkg/standarderror" -) - -// Merge is the endpoint for -// merging PDF files. -func Merge(c echo.Context) error { - const op string = "handler.Merge" - ctx := context.MustCastFromEchoContext(c) - ctx.StandardLogger().DebugfOp(op, "merge request") - r := ctx.Resource() - opts, err := r.MergePrinterOptions() - if err != nil { - return &standarderror.Error{Op: op, Err: err} - } - fpaths, err := r.Fpaths(".pdf") - if err != nil { - return &standarderror.Error{Op: op, Err: err} - } - p := printer.NewMerge(fpaths, opts) - if err := convert(ctx, p); err != nil { - return &standarderror.Error{Op: op, Err: err} - } - return nil -} diff --git a/internal/app/api/pkg/handler/office.go b/internal/app/api/pkg/handler/office.go deleted file mode 100644 index 2532e21e..00000000 --- a/internal/app/api/pkg/handler/office.go +++ /dev/null @@ -1,43 +0,0 @@ -package handler - -import ( - "github.com/labstack/echo/v4" - "github.com/thecodingmachine/gotenberg/internal/app/api/pkg/context" - "github.com/thecodingmachine/gotenberg/internal/pkg/printer" - "github.com/thecodingmachine/gotenberg/internal/pkg/standarderror" -) - -// Office is the endpoint for converting -// Office files to PDF. -func Office(c echo.Context) error { - const op string = "handler.Office" - ctx := context.MustCastFromEchoContext(c) - ctx.StandardLogger().DebugfOp(op, "office request") - r := ctx.Resource() - opts, err := r.OfficePrinterOptions() - if err != nil { - return &standarderror.Error{Op: op, Err: err} - } - fpaths, err := r.Fpaths( - ".txt", - ".rtf", - ".fodt", - ".doc", - ".docx", - ".odt", - ".xls", - ".xlsx", - ".ods", - ".ppt", - ".pptx", - ".odp", - ) - if err != nil { - return &standarderror.Error{Op: op, Err: err} - } - p := printer.NewOffice(fpaths, opts) - if err := convert(ctx, p); err != nil { - return &standarderror.Error{Op: op, Err: err} - } - return nil -} diff --git a/internal/app/api/pkg/handler/ping.go b/internal/app/api/pkg/handler/ping.go deleted file mode 100644 index c2fa275d..00000000 --- a/internal/app/api/pkg/handler/ping.go +++ /dev/null @@ -1,10 +0,0 @@ -package handler - -import ( - "github.com/labstack/echo/v4" -) - -// Ping is the endpoint for healthcheck. -func Ping(c echo.Context) error { - return nil -} diff --git a/internal/app/api/pkg/handler/url.go b/internal/app/api/pkg/handler/url.go deleted file mode 100644 index f4264f00..00000000 --- a/internal/app/api/pkg/handler/url.go +++ /dev/null @@ -1,31 +0,0 @@ -package handler - -import ( - "github.com/labstack/echo/v4" - "github.com/thecodingmachine/gotenberg/internal/app/api/pkg/context" - "github.com/thecodingmachine/gotenberg/internal/app/api/pkg/resource" - "github.com/thecodingmachine/gotenberg/internal/pkg/printer" - "github.com/thecodingmachine/gotenberg/internal/pkg/standarderror" -) - -// URL is the endpoint for converting -// a URL to PDF. -func URL(c echo.Context) error { - const op string = "handler.URL" - ctx := context.MustCastFromEchoContext(c) - ctx.StandardLogger().DebugfOp(op, "url request") - r := ctx.Resource() - opts, err := r.ChromePrinterOptions() - if err != nil { - return &standarderror.Error{Op: op, Err: err} - } - remoteURL, err := r.Get(resource.RemoteURLFormField) - if err != nil { - return &standarderror.Error{Op: op, Err: err} - } - p := printer.NewURL(remoteURL, opts) - if err := convert(ctx, p); err != nil { - return &standarderror.Error{Op: op, Err: err} - } - return nil -} diff --git a/internal/app/api/pkg/middleware/cleanup.go b/internal/app/api/pkg/middleware/cleanup.go deleted file mode 100644 index 6f6c57f7..00000000 --- a/internal/app/api/pkg/middleware/cleanup.go +++ /dev/null @@ -1,38 +0,0 @@ -package middleware - -import ( - "github.com/labstack/echo/v4" - "github.com/thecodingmachine/gotenberg/internal/app/api/pkg/context" - "github.com/thecodingmachine/gotenberg/internal/app/api/pkg/resource" - "github.com/thecodingmachine/gotenberg/internal/pkg/standarderror" -) - -// Cleanup helps removing a resource at the end of a request. -func Cleanup() echo.MiddlewareFunc { - return func(next echo.HandlerFunc) echo.HandlerFunc { - return func(c echo.Context) error { - const op string = "middleware.Cleanup" - err := next(c) - ctx := context.MustCastFromEchoContext(c) - r := ctx.Resource() - if r == nil { - return err - } - // if a webhook URL has been given, - // do not remove the resource here because - // we don't know if the result file has been - // generated or sent. - if r.Has(resource.WebhookURLFormField) { - return err - } - // a resource is associated with our custom context. - if resourceErr := r.Close(); resourceErr != nil { - ctx.StandardLogger().ErrorOp(op, &standarderror.Error{ - Op: op, - Err: resourceErr, - }) - } - return err - } - } -} diff --git a/internal/app/api/pkg/middleware/context.go b/internal/app/api/pkg/middleware/context.go deleted file mode 100644 index d917ba7b..00000000 --- a/internal/app/api/pkg/middleware/context.go +++ /dev/null @@ -1,60 +0,0 @@ -package middleware - -import ( - "fmt" - "os" - - "github.com/labstack/echo/v4" - "github.com/thecodingmachine/gotenberg/internal/app/api/pkg/context" - "github.com/thecodingmachine/gotenberg/internal/app/api/pkg/handler" - "github.com/thecodingmachine/gotenberg/internal/pkg/config" - "github.com/thecodingmachine/gotenberg/internal/pkg/logger" - "github.com/thecodingmachine/gotenberg/internal/pkg/random" -) - -const ( - // TestingTraceEnvVar is an environment - // variable used in some tests. - TestingTraceEnvVar string = "TESTING_TRACE" - // TestsTracePrefix helps - // creating all resources inside a prefix. - // Only used in some tests - // to check if the resources - // have been removed. - TestsTracePrefix string = "tmp" -) - -// Context helps extending the default echo.Context with -// our custom context. -func Context(config *config.Config) echo.MiddlewareFunc { - return func(next echo.HandlerFunc) echo.HandlerFunc { - return func(c echo.Context) error { - var trace string - if os.Getenv(TestingTraceEnvVar) == "1" { - trace = fmt.Sprintf("%s/%s", TestsTracePrefix, random.Get()) - } else { - // generate a unique identifier for the request. - trace = random.Get() - } - // create the logger for this request using - // the previous identifier as trace. - logger := logger.New(config.LogLevel(), trace) - // extend the current echo context with our custom - // context. - ctx := context.New(c, logger, config) - // if its an healthcheck request, there - // is no resource associated to it. - if ctx.Path() == handler.PingEndpoint { - return next(ctx) - } - // if the endpoint is not for healthcheck, associate a - // resource to our custom context. - if err := ctx.WithResource(trace); err != nil { - // required to have a correct status code. - ctx.Error(err) - return ctx.LogRequestResult(err, false) - } - return next(ctx) - } - } -} diff --git a/internal/app/api/pkg/middleware/doc.go b/internal/app/api/pkg/middleware/doc.go deleted file mode 100644 index 91719d08..00000000 --- a/internal/app/api/pkg/middleware/doc.go +++ /dev/null @@ -1,3 +0,0 @@ -// Package middleware contains the -// middleware of the API. -package middleware diff --git a/internal/app/api/pkg/middleware/error.go b/internal/app/api/pkg/middleware/error.go deleted file mode 100644 index f5254e02..00000000 --- a/internal/app/api/pkg/middleware/error.go +++ /dev/null @@ -1,48 +0,0 @@ -package middleware - -import ( - "net/http" - - "github.com/labstack/echo/v4" - "github.com/thecodingmachine/gotenberg/internal/app/api/pkg/context" - "github.com/thecodingmachine/gotenberg/internal/pkg/standarderror" -) - -// Error helps handling errors (if any). -func Error() echo.MiddlewareFunc { - return func(next echo.HandlerFunc) echo.HandlerFunc { - return func(c echo.Context) error { - ctx := context.MustCastFromEchoContext(c) - err := next(ctx) - if err == nil { - // so far so good! - return nil - } - // if it's an error from echo - // like 404 not found and so on. - if echoHTTPErr, ok := err.(*echo.HTTPError); ok { - return echoHTTPErr - } - // we log the initial error before returning - // the HTTP error. - errOp := standarderror.Op(err) - logger := ctx.StandardLogger() - logger.ErrorOp(errOp, err) - // handle our custom HTTP error. - var httpErr error - errCode := standarderror.Code(err) - errMessage := standarderror.Message(err) - switch errCode { - case standarderror.Invalid: - httpErr = echo.NewHTTPError(http.StatusBadRequest, errMessage) - case standarderror.Timeout: - httpErr = echo.NewHTTPError(http.StatusRequestTimeout, errMessage) - default: - httpErr = echo.NewHTTPError(http.StatusInternalServerError, errMessage) - } - // required to have a correct status code. - ctx.Error(httpErr) - return httpErr - } - } -} diff --git a/internal/app/api/pkg/middleware/logger.go b/internal/app/api/pkg/middleware/logger.go deleted file mode 100644 index 5fb2579e..00000000 --- a/internal/app/api/pkg/middleware/logger.go +++ /dev/null @@ -1,21 +0,0 @@ -package middleware - -import ( - "github.com/labstack/echo/v4" - "github.com/thecodingmachine/gotenberg/internal/app/api/pkg/context" - "github.com/thecodingmachine/gotenberg/internal/app/api/pkg/handler" -) - -// Logger helps logging the result of a request. -func Logger() echo.MiddlewareFunc { - return func(next echo.HandlerFunc) echo.HandlerFunc { - return func(c echo.Context) error { - ctx := context.MustCastFromEchoContext(c) - err := next(ctx) - // we do not want to log healthcheck requests if - // log level is not set to DEBUG. - isDebug := ctx.Path() == handler.PingEndpoint - return ctx.LogRequestResult(err, isDebug) - } - } -} diff --git a/internal/app/api/pkg/resource/doc.go b/internal/app/api/pkg/resource/doc.go deleted file mode 100644 index 56c149b0..00000000 --- a/internal/app/api/pkg/resource/doc.go +++ /dev/null @@ -1,5 +0,0 @@ -// Package resource helps creating a folder -// containing all uploaded files and the resulting -// PDF file. It also helps centralizing all -// the form values. -package resource diff --git a/internal/app/api/pkg/resource/resource.go b/internal/app/api/pkg/resource/resource.go deleted file mode 100644 index c8eba0d9..00000000 --- a/internal/app/api/pkg/resource/resource.go +++ /dev/null @@ -1,421 +0,0 @@ -package resource - -import ( - "fmt" - "io" - "io/ioutil" - "os" - "path/filepath" - "strconv" - - "github.com/labstack/echo/v4" - "github.com/thecodingmachine/gotenberg/internal/pkg/config" - "github.com/thecodingmachine/gotenberg/internal/pkg/logger" - "github.com/thecodingmachine/gotenberg/internal/pkg/printer" - "github.com/thecodingmachine/gotenberg/internal/pkg/standarderror" -) - -const ( - // ResultFilenameFormField contains the name - // of a form field. - ResultFilenameFormField string = "resultFilename" - // WaitTimeoutFormField contains the name - // of a form field. - WaitTimeoutFormField string = "waitTimeout" - // WebhookURLFormField contains the name - // of a form field. - WebhookURLFormField string = "webhookURL" - // RemoteURLFormField contains the name - // of a form field. - RemoteURLFormField string = "remoteURL" - // WaitDelayFormField contains the name - // of a form field. - WaitDelayFormField string = "waitDelay" - // PaperWidthFormField contains the name - // of a form field. - PaperWidthFormField string = "paperWidth" - // PaperHeightFormField contains the name - // of a form field. - PaperHeightFormField string = "paperHeight" - // MarginTopFormField contains the name - // of a form field. - MarginTopFormField string = "marginTop" - // MarginBottomFormField contains the name - // of a form field. - MarginBottomFormField string = "marginBottom" - // MarginLeftFormField contains the name - // of a form field. - MarginLeftFormField string = "marginLeft" - // MarginRightFormField contains the name - // of a form field. - MarginRightFormField string = "marginRight" - // LandscapeFormField contains the name - // of a form field. - LandscapeFormField string = "landscape" -) - -// Resource helps retrieving form values -// and form files from a request. -type Resource struct { - logger *logger.Logger - config *config.Config - formValues map[string]string - formFilesDirPath string -} - -// New creates a new resource. -func New(c echo.Context, logger *logger.Logger, config *config.Config, dirPath string) (*Resource, error) { - const op string = "resource.New" - r := &Resource{ - logger: logger, - config: config, - formValues: formValues(c, logger), - formFilesDirPath: dirPath, - } - if err := os.MkdirAll(dirPath, 0755); err != nil { - return nil, &standarderror.Error{Op: op, Err: err} - } - r.logger.DebugfOp(op, "directory '%s' created", dirPath) - if err := formFiles(c, logger, dirPath); err != nil { - return r, &standarderror.Error{Op: op, Err: err} - } - return r, nil -} - -func formValues(c echo.Context, logger *logger.Logger) map[string]string { - const op string = "resource.formValues" - v := make(map[string]string) - fetch := func(formField string) string { - value := c.FormValue(formField) - if value == "" { - logger.DebugfOp(op, "'%s' is empty", formField) - return value - } - logger.DebugfOp(op, "'%s' retrieved, got '%s'", formField, value) - return value - } - v[ResultFilenameFormField] = fetch(ResultFilenameFormField) - v[WaitTimeoutFormField] = fetch(WaitTimeoutFormField) - v[WebhookURLFormField] = fetch(WebhookURLFormField) - v[RemoteURLFormField] = fetch(RemoteURLFormField) - v[WaitDelayFormField] = fetch(WaitDelayFormField) - v[PaperWidthFormField] = fetch(PaperWidthFormField) - v[PaperHeightFormField] = fetch(PaperHeightFormField) - v[MarginTopFormField] = fetch(MarginTopFormField) - v[MarginBottomFormField] = fetch(MarginBottomFormField) - v[MarginLeftFormField] = fetch(MarginLeftFormField) - v[MarginRightFormField] = fetch(MarginRightFormField) - v[LandscapeFormField] = fetch(LandscapeFormField) - return v -} - -func formFiles(c echo.Context, logger *logger.Logger, dirPath string) error { - const op string = "resource.formFiles" - form, err := c.MultipartForm() - if err != nil { - return &standarderror.Error{Op: op, Err: err} - } - for _, files := range form.File { - for _, fh := range files { - in, err := fh.Open() - if err != nil { - return &standarderror.Error{Op: op, Err: err} - } - defer in.Close() // nolint: errcheck - fpath := fmt.Sprintf("%s/%s", dirPath, fh.Filename) - out, err := os.Create(fpath) - if err != nil { - return &standarderror.Error{Op: op, Err: err} - } - defer out.Close() // nolint: errcheck - if err := out.Chmod(0644); err != nil { - return &standarderror.Error{Op: op, Err: err} - } - if _, err := io.Copy(out, in); err != nil { - return &standarderror.Error{Op: op, Err: err} - } - if _, err := out.Seek(0, 0); err != nil { - return &standarderror.Error{Op: op, Err: err} - } - logger.DebugfOp(op, "'%s' created", fh.Filename) - } - } - return nil -} - -// DirPath returns the directory -// path where are stored the form -// files and the resulting PDF file. -func (r *Resource) DirPath() string { - return r.formFilesDirPath -} - -// Close deletes the working directory of the -// resource if it exists. -func (r *Resource) Close() error { - const op string = "resource.Close" - if _, err := os.Stat(r.formFilesDirPath); os.IsNotExist(err) { - r.logger.DebugfOp(op, "directory '%s' does not exist, nothing to remove", r.formFilesDirPath) - return nil - } - if err := os.RemoveAll(r.formFilesDirPath); err != nil { - return &standarderror.Error{Op: op, Err: err} - } - r.logger.DebugfOp(op, "directory '%s' removed", r.formFilesDirPath) - return nil -} - -const defaultHeaderFooterHTML string = "" - -// ChromePrinterOptions returns the Chrome printer options -// thanks to the form values and form files from the request -// plus the default values from the configuration. -func (r *Resource) ChromePrinterOptions() (*printer.ChromeOptions, error) { - const op string = "resource.ChromePrinterOptions" - waitTimeout, err := r.float64(WaitTimeoutFormField, r.config.DefaultWaitTimeout()) - if err != nil { - return nil, &standarderror.Error{Op: op, Err: err} - } - waitDelay, err := r.float64(WaitDelayFormField, 0.0) - if err != nil { - return nil, &standarderror.Error{Op: op, Err: err} - } - headerHTML, err := r.content("header.html", defaultHeaderFooterHTML) - if err != nil { - return nil, &standarderror.Error{Op: op, Err: err} - } - footerHTML, err := r.content("footer.html", defaultHeaderFooterHTML) - if err != nil { - return nil, &standarderror.Error{Op: op, Err: err} - } - paperWidth, err := r.float64(PaperWidthFormField, 8.27) - if err != nil { - return nil, &standarderror.Error{Op: op, Err: err} - } - paperHeight, err := r.float64(PaperHeightFormField, 11.7) - if err != nil { - return nil, &standarderror.Error{Op: op, Err: err} - } - marginTop, err := r.float64(MarginTopFormField, 1) - if err != nil { - return nil, &standarderror.Error{Op: op, Err: err} - } - marginBottom, err := r.float64(MarginBottomFormField, 1) - if err != nil { - return nil, &standarderror.Error{Op: op, Err: err} - } - marginLeft, err := r.float64(MarginLeftFormField, 1) - if err != nil { - return nil, &standarderror.Error{Op: op, Err: err} - } - marginRight, err := r.float64(MarginRightFormField, 1) - if err != nil { - return nil, &standarderror.Error{Op: op, Err: err} - } - landscape, err := r.bool(LandscapeFormField, false) - if err != nil { - return nil, &standarderror.Error{Op: op, Err: err} - } - opts := &printer.ChromeOptions{ - WaitTimeout: waitTimeout, - WaitDelay: waitDelay, - HeaderHTML: headerHTML, - FooterHTML: footerHTML, - PaperWidth: paperWidth, - PaperHeight: paperHeight, - MarginTop: marginTop, - MarginBottom: marginBottom, - MarginLeft: marginLeft, - MarginRight: marginRight, - Landscape: landscape, - } - r.logger.DebugfOp(op, "printer options: %+v", opts) - return opts, nil -} - -// OfficePrinterOptions returns the Office printer options -// thanks to the form values from the request -// plus the default values from the configuration. -func (r *Resource) OfficePrinterOptions() (*printer.OfficeOptions, error) { - const op string = "resource.OfficePrinterOptions" - waitTimeout, err := r.float64(WaitTimeoutFormField, r.config.DefaultWaitTimeout()) - if err != nil { - return nil, &standarderror.Error{Op: op, Err: err} - } - landscape, err := r.bool(LandscapeFormField, false) - if err != nil { - return nil, &standarderror.Error{Op: op, Err: err} - } - opts := &printer.OfficeOptions{ - WaitTimeout: waitTimeout, - Landscape: landscape, - } - r.logger.DebugfOp(op, "printer options: %+v", opts) - return opts, nil -} - -// MergePrinterOptions returns the merge printer options -// thanks to the form values from the request -// plus the default values from the configuration. -func (r *Resource) MergePrinterOptions() (*printer.MergeOptions, error) { - const op string = "resource.MergePrinterOptions" - waitTimeout, err := r.float64(WaitTimeoutFormField, r.config.DefaultWaitTimeout()) - if err != nil { - return nil, &standarderror.Error{Op: op, Err: err} - } - opts := &printer.MergeOptions{ - WaitTimeout: waitTimeout, - } - r.logger.DebugfOp(op, "printer options: %+v", opts) - return opts, nil -} - -// Has returns true if the resource -// contains the given form field and -// its value is not empty. -func (r *Resource) Has(formField string) bool { - v, ok := r.formValues[formField] - if ok { - ok = v != "" - } - return ok -} - -func (r *Resource) hasFile(filename string) bool { - fpath := fmt.Sprintf("%s/%s", r.formFilesDirPath, filename) - _, err := os.Stat(fpath) - return !os.IsNotExist(err) -} - -// Get returns the form field value. -func (r *Resource) Get(formField string) (string, error) { - const op string = "resource.Get" - v, err := r.value(formField) - if err != nil { - return "", &standarderror.Error{Op: op, Err: err} - } - return v, nil -} - -func (r *Resource) value(formField string) (string, error) { - const op string = "resource.value" - v, ok := r.formValues[formField] - if !ok { - return "", &standarderror.Error{ - Code: standarderror.Invalid, - Message: fmt.Sprintf("'%s' does not exist", formField), - Op: op, - } - } - return v, nil -} - -func (r *Resource) float64(formField string, defaultValue float64) (float64, error) { - const op string = "resource.float64" - if !r.Has(formField) { - return defaultValue, nil - } - v, err := r.value(formField) - if err != nil { - return 0.0, &standarderror.Error{Op: op, Err: err} - } - f, err := strconv.ParseFloat(v, 64) - if err != nil { - return 0.0, &standarderror.Error{ - Code: standarderror.Invalid, - Message: fmt.Sprintf("'%s' is not a float, got '%s'", formField, v), - Op: op, - } - } - return f, nil -} - -func (r *Resource) bool(formField string, defaultValue bool) (bool, error) { - const op string = "resource.bool" - if !r.Has(formField) { - return defaultValue, nil - } - v, err := r.value(formField) - if err != nil { - return false, &standarderror.Error{Op: op, Err: err} - } - b, err := strconv.ParseBool(v) - if err != nil { - return false, &standarderror.Error{ - Code: standarderror.Invalid, - Message: fmt.Sprintf("'%s' is not a boolean, got '%s'", formField, v), - Op: op, - } - } - return b, nil -} - -// Fpath returns the path of the given filename. -// This filename should be the name of a form file. -func (r *Resource) Fpath(filename string) (string, error) { - const op string = "resource.Fpath" - fpath := fmt.Sprintf("%s/%s", r.formFilesDirPath, filename) - _, err := os.Stat(fpath) - if os.IsNotExist(err) { - return "", &standarderror.Error{ - Code: standarderror.Invalid, - Message: fmt.Sprintf("file '%s' does not exist", filename), - Op: op, - } - } - absPath, err := filepath.Abs(fpath) - if err != nil { - return "", &standarderror.Error{Op: op, Err: err} - } - return absPath, nil -} - -func (r *Resource) content(filename string, defaultValue string) (string, error) { - const op string = "resource.content" - if !r.hasFile(filename) { - return defaultValue, nil - } - fpath, err := r.Fpath(filename) - if err != nil { - return "", &standarderror.Error{Op: op, Err: err} - } - b, err := ioutil.ReadFile(fpath) - if err != nil { - return "", &standarderror.Error{Op: op, Err: err} - } - return string(b), nil -} - -// Fpaths returns the list of files of the resource -// according to given file extensions. -func (r *Resource) Fpaths(exts ...string) ([]string, error) { - const op string = "resource.Fpaths" - var fpaths []string - err := filepath.Walk(r.formFilesDirPath, func(path string, info os.FileInfo, _ error) error { - if info.IsDir() { - return nil - } - fpath, err := r.Fpath(info.Name()) - if err != nil { - return &standarderror.Error{Op: op, Err: err} - } - for _, ext := range exts { - if filepath.Ext(fpath) == ext { - fpaths = append(fpaths, fpath) - return nil - } - } - return nil - }) - if err != nil { - return nil, &standarderror.Error{Op: op, Err: err} - } - if len(fpaths) == 0 { - return nil, &standarderror.Error{ - Code: standarderror.Invalid, - Message: fmt.Sprintf("no file found for extentions %v", exts), - Op: op, - } - } - return fpaths, nil -} diff --git a/internal/app/xhttp/doc.go b/internal/app/xhttp/doc.go new file mode 100644 index 00000000..62383c98 --- /dev/null +++ b/internal/app/xhttp/doc.go @@ -0,0 +1,3 @@ +// Package xhttp defines our own implementation +// of echo.Echo. +package xhttp diff --git a/internal/app/xhttp/handler.go b/internal/app/xhttp/handler.go new file mode 100644 index 00000000..52152fcc --- /dev/null +++ b/internal/app/xhttp/handler.go @@ -0,0 +1,298 @@ +package xhttp + +import ( + "fmt" + "net/http" + "os" + + "github.com/labstack/echo/v4" + "github.com/thecodingmachine/gotenberg/internal/app/xhttp/pkg/context" + "github.com/thecodingmachine/gotenberg/internal/app/xhttp/pkg/resource" + "github.com/thecodingmachine/gotenberg/internal/pkg/printer" + "github.com/thecodingmachine/gotenberg/internal/pkg/xerror" + "github.com/thecodingmachine/gotenberg/internal/pkg/xrand" +) + +const ( + pingEndpoint string = "/ping" + mergeEndpoint string = "/merge" + convertGroupEndpoint string = "/convert" + htmlEndpoint string = "/html" + urlEndpoint string = "/url" + markdownEndpoint string = "/markdown" + officeEndpoint string = "/office" +) + +// pingHandler is the handler for healthcheck. +func pingHandler(c echo.Context) error { + const op string = "xhttp.pingHandler" + ctx := context.MustCastFromEchoContext(c) + ctx.XLogger().DebugOp(op, "handling ping request...") + if err := ctx.ProcessesHealthcheck(); err != nil { + return xerror.New(op, err) + } + return nil +} + +// mergeHandler is the handler for merging +// PDF files. +func mergeHandler(c echo.Context) error { + const op string = "xhttp.mergeHandler" + resolver := func() error { + ctx := context.MustCastFromEchoContext(c) + logger := ctx.XLogger() + logger.DebugOp(op, "handling merge request...") + r := ctx.MustResource() + opts, err := mergePrinterOptions(r, ctx.Config()) + if err != nil { + return xerror.New(op, err) + } + fpaths, err := r.Fpaths(".pdf") + if err != nil { + return err + } + p := printer.NewMergePrinter(logger, fpaths, opts) + return convert(ctx, p) + } + if err := resolver(); err != nil { + return xerror.New(op, err) + } + return nil +} + +// htmlHandler is the handler for converting +// HTML to PDF. +func htmlHandler(c echo.Context) error { + const op string = "xhttp.htmlHandler" + resolver := func() error { + ctx := context.MustCastFromEchoContext(c) + logger := ctx.XLogger() + logger.DebugOp(op, "handling HTML request...") + r := ctx.MustResource() + opts, err := chromePrinterOptions(r, ctx.Config()) + if err != nil { + return err + } + fpath, err := r.Fpath("index.html") + if err != nil { + return err + } + p := printer.NewHTMLPrinter(logger, fpath, opts) + return convert(ctx, p) + } + if err := resolver(); err != nil { + return xerror.New(op, err) + } + return nil +} + +// urlHandler is the handler for converting +// a URL to PDF. +func urlHandler(c echo.Context) error { + const op string = "xhttp.urlHandler" + resolver := func() error { + ctx := context.MustCastFromEchoContext(c) + logger := ctx.XLogger() + logger.DebugOp(op, "handling URL request...") + r := ctx.MustResource() + opts, err := chromePrinterOptions(r, ctx.Config()) + if err != nil { + return err + } + if !r.HasArg(resource.RemoteURLArgKey) { + return xerror.Invalid( + op, + fmt.Sprintf("'%s' not found or empty", resource.RemoteURLArgKey), + nil, + ) + } + remoteURL, err := r.StringArg(resource.RemoteURLArgKey, "") + if err != nil { + return err + } + p := printer.NewURLPrinter(logger, remoteURL, opts) + return convert(ctx, p) + } + if err := resolver(); err != nil { + return xerror.New(op, err) + } + return nil +} + +// markdownHandler is the handler for converting +// Markdown to PDF. +func markdownHandler(c echo.Context) error { + const op string = "xhttp.markdownHandler" + resolver := func() error { + ctx := context.MustCastFromEchoContext(c) + logger := ctx.XLogger() + logger.DebugOp(op, "handling Markdown request...") + r := ctx.MustResource() + opts, err := chromePrinterOptions(r, ctx.Config()) + if err != nil { + return err + } + fpath, err := r.Fpath("index.html") + if err != nil { + return err + } + p, err := printer.NewMarkdownPrinter(logger, fpath, opts) + if err != nil { + return err + } + return convert(ctx, p) + } + if err := resolver(); err != nil { + return xerror.New(op, err) + } + return nil +} + +// officeHandler is the handler for converting +// Office documents to PDF. +func officeHandler(c echo.Context) error { + const op string = "xhttp.officeHandler" + resolver := func() error { + ctx := context.MustCastFromEchoContext(c) + logger := ctx.XLogger() + logger.DebugOp(op, "handling Office request...") + r := ctx.MustResource() + opts, err := officePrinterOptions(r, ctx.Config()) + if err != nil { + return err + } + fpaths, err := r.Fpaths( + ".txt", + ".rtf", + ".fodt", + ".doc", + ".docx", + ".odt", + ".xls", + ".xlsx", + ".ods", + ".ppt", + ".pptx", + ".odp", + ) + if err != nil { + return err + } + p := printer.NewOfficePrinter(logger, fpaths, opts) + return convert(ctx, p) + } + if err := resolver(); err != nil { + return xerror.New(op, err) + } + return nil +} + +func convert(ctx context.Context, p printer.Printer) error { + const op string = "xhttp.convert" + resolver := func() error { + logger := ctx.XLogger() + r := ctx.MustResource() + baseFilename := xrand.Get() + filename := fmt.Sprintf("%s.pdf", baseFilename) + fpath := fmt.Sprintf("%s/%s", r.DirPath(), filename) + // if no webhook URL given, run conversion + // and directly return the resulting PDF file + // or an error. + if !r.HasArg(resource.WebhookURLArgKey) { + logger.DebugfOp(op, "no '%s' found, converting synchronously", resource.WebhookURLArgKey) + return convertSync(ctx, p, filename, fpath) + } + // as a webhook URL has been given, we + // run the following lines in a goroutine so that + // it doesn't block. + logger.DebugfOp(op, "'%s' found, converting asynchronously", resource.WebhookURLArgKey) + return convertAsync(ctx, p, filename, fpath) + } + if err := resolver(); err != nil { + return xerror.New(op, err) + } + return nil +} + +func convertSync(ctx context.Context, p printer.Printer, filename, fpath string) error { + const op = "xhttp.convertSync" + resolver := func() error { + logger := ctx.XLogger() + r := ctx.MustResource() + + if err := p.Print(fpath); err != nil { + return err + } + if !r.HasArg(resource.ResultFilenameArgKey) { + logger.DebugfOp( + op, + "no '%s' found, using generated filename '%s'", + resource.RemoteURLArgKey, + filename, + ) + if err := ctx.Attachment(fpath, filename); err != nil { + return err + } + return nil + } + logger.DebugfOp( + op, + "'%s' found, so not using generated filename", + resource.ResultFilenameArgKey, + ) + filename, err := r.StringArg(resource.ResultFilenameArgKey, filename) + if err != nil { + return err + } + if err := ctx.Attachment(fpath, filename); err != nil { + return err + } + return nil + } + if err := resolver(); err != nil { + return xerror.New(op, err) + } + return nil +} + +func convertAsync(ctx context.Context, p printer.Printer, filename, fpath string) error { + const op = "xhttp.convertAsync" + logger := ctx.XLogger() + r := ctx.MustResource() + go func() { + defer r.Close() // nolint: errcheck + if err := p.Print(fpath); err != nil { + xerr := xerror.New(op, err) + logger.ErrorOp(xerror.Op(xerr), xerr) + return + } + f, err := os.Open(fpath) + if err != nil { + xerr := xerror.New(op, err) + logger.ErrorOp(xerror.Op(xerr), xerr) + return + } + defer f.Close() // nolint: errcheck + webhookURL, err := r.StringArg(resource.WebhookURLArgKey, "") + if err != nil { + xerr := xerror.New(op, err) + logger.ErrorOp(xerror.Op(xerr), xerr) + return + } + logger.DebugfOp( + op, + "sending result file '%s' to '%s'", + filename, + webhookURL, + ) + // TODO timeout + resp, err := http.Post(webhookURL, "application/pdf", f) /* #nosec */ + if err != nil { + xerr := xerror.New(op, err) + logger.ErrorOp(xerror.Op(xerr), xerr) + return + } + defer resp.Body.Close() // nolint: errcheck + }() + return nil +} diff --git a/internal/app/xhttp/middleware.go b/internal/app/xhttp/middleware.go new file mode 100644 index 00000000..3d0dd6be --- /dev/null +++ b/internal/app/xhttp/middleware.go @@ -0,0 +1,128 @@ +package xhttp + +import ( + "net/http" + + "github.com/labstack/echo/v4" + "github.com/thecodingmachine/gotenberg/internal/app/xhttp/pkg/context" + "github.com/thecodingmachine/gotenberg/internal/app/xhttp/pkg/resource" + "github.com/thecodingmachine/gotenberg/internal/pkg/conf" + "github.com/thecodingmachine/gotenberg/internal/pkg/pm2" + "github.com/thecodingmachine/gotenberg/internal/pkg/xerror" + "github.com/thecodingmachine/gotenberg/internal/pkg/xlog" + "github.com/thecodingmachine/gotenberg/internal/pkg/xrand" +) + +// contextMiddleware extends the default echo.Context with +// our custom context.Context. +func contextMiddleware(config conf.Config, processes ...pm2.Process) echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + // generate a unique identifier for the request. + trace := xrand.Get() + // create the logger for this request using + // the previous identifier as trace. + logger := xlog.New(config.LogLevel(), trace) + // extend the current echo context with our custom + // context. + ctx := context.New(c, logger, config, processes...) + // if its an healthcheck request, there + // is no need to create a Resource. + if ctx.Path() == pingEndpoint { + return next(ctx) + } + // if the endpoint is not for healthcheck, create a + // Resource. + if err := ctx.WithResource(trace); err != nil { + // required to have a correct status code. + ctx.Error(err) + return ctx.LogRequestResult(err, false) + } + return next(ctx) + } + } +} + +// loggerMiddleware logs the result of a request. +func loggerMiddleware() echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + ctx := context.MustCastFromEchoContext(c) + err := next(ctx) + // we do not want to log healthcheck requests if + // log level is not set to DEBUG. + isDebug := ctx.Path() == pingEndpoint + return ctx.LogRequestResult(err, isDebug) + } + } +} + +// cleanupMiddleware removes a resource.Resource +// at the end of a request. +func cleanupMiddleware() echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + const op string = "xhttp.cleanupMiddleware" + err := next(c) + ctx := context.MustCastFromEchoContext(c) + if !ctx.HasResource() { + // nothing to remove. + return err + } + r := ctx.MustResource() + // if a webhook URL has been given, + // do not remove the resource.Resource here because + // we don't know if the result file has been + // generated or sent. + if r.HasArg(resource.WebhookURLArgKey) { + return err + } + // a resource.Resource is associated with our custom context. + if resourceErr := r.Close(); resourceErr != nil { + xerr := xerror.New(op, resourceErr) + ctx.XLogger().ErrorOp(xerror.Op(xerr), xerr) + } + return err + } + } +} + +// errorMiddleware handles errors (if any). +func errorMiddleware() echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + ctx := context.MustCastFromEchoContext(c) + err := next(ctx) + if err == nil { + // so far so good! + return nil + } + // if it's an error from echo + // like 404 not found and so on. + if echoHTTPErr, ok := err.(*echo.HTTPError); ok { + return echoHTTPErr + } + // we log the initial error before returning + // the HTTP error. + errOp := xerror.Op(err) + logger := ctx.XLogger() + logger.ErrorOp(errOp, err) + // handle our custom HTTP error. + var httpErr error + errCode := xerror.Code(err) + errMessage := xerror.Message(err) + switch errCode { + case xerror.InvalidCode: + httpErr = echo.NewHTTPError(http.StatusBadRequest, errMessage) + case xerror.TimeoutCode: + // TODO status + httpErr = echo.NewHTTPError(http.StatusBadGateway, errMessage) + default: + httpErr = echo.NewHTTPError(http.StatusInternalServerError, errMessage) + } + // required to have a correct status code. + ctx.Error(httpErr) + return httpErr + } + } +} diff --git a/internal/app/xhttp/option.go b/internal/app/xhttp/option.go new file mode 100644 index 00000000..da9bf2f4 --- /dev/null +++ b/internal/app/xhttp/option.go @@ -0,0 +1,93 @@ +package xhttp + +import ( + "github.com/thecodingmachine/gotenberg/internal/app/xhttp/pkg/resource" + "github.com/thecodingmachine/gotenberg/internal/pkg/conf" + "github.com/thecodingmachine/gotenberg/internal/pkg/printer" + "github.com/thecodingmachine/gotenberg/internal/pkg/xerror" +) + +func mergePrinterOptions(r resource.Resource, config conf.Config) (printer.MergePrinterOptions, error) { + const op string = "xhttp.mergePrinterOptions" + waitTimeout, err := resource.WaitTimeoutArg(r, config) + if err != nil { + return printer.MergePrinterOptions{}, xerror.New(op, err) + } + return printer.MergePrinterOptions{ + WaitTimeout: waitTimeout, + }, nil +} + +func chromePrinterOptions(r resource.Resource, config conf.Config) (printer.ChromePrinterOptions, error) { + const op string = "xhttp.chromePrinterOptions" + resolver := func() (printer.ChromePrinterOptions, error) { + waitTimeout, err := resource.WaitTimeoutArg(r, config) + if err != nil { + return printer.ChromePrinterOptions{}, err + } + waitDelay, err := resource.WaitDelayArg(r, config) + if err != nil { + return printer.ChromePrinterOptions{}, err + } + headerHTML, footerHTML, + err := resource.HeaderFooterContents(r) + if err != nil { + return printer.ChromePrinterOptions{}, err + } + paperWidth, paperHeight, + err := resource.PaperSizeArgs(r) + if err != nil { + return printer.ChromePrinterOptions{}, err + } + marginTop, marginBottom, marginLeft, marginRight, + err := resource.MarginArgs(r) + if err != nil { + return printer.ChromePrinterOptions{}, err + } + landscape, err := r.BoolArg(resource.LandscapeArgKey, false) + if err != nil { + return printer.ChromePrinterOptions{}, err + } + return printer.ChromePrinterOptions{ + WaitTimeout: waitTimeout, + WaitDelay: waitDelay, + HeaderHTML: headerHTML, + FooterHTML: footerHTML, + PaperWidth: paperWidth, + PaperHeight: paperHeight, + MarginTop: marginTop, + MarginBottom: marginBottom, + MarginLeft: marginLeft, + MarginRight: marginRight, + Landscape: landscape, + }, nil + } + opts, err := resolver() + if err != nil { + return opts, xerror.New(op, err) + } + return opts, nil +} + +func officePrinterOptions(r resource.Resource, config conf.Config) (printer.OfficePrinterOptions, error) { + const op string = "xhttp.officePrinterOptions" + resolver := func() (printer.OfficePrinterOptions, error) { + waitTimeout, err := resource.WaitTimeoutArg(r, config) + if err != nil { + return printer.OfficePrinterOptions{}, err + } + landscape, err := r.BoolArg(resource.LandscapeArgKey, false) + if err != nil { + return printer.OfficePrinterOptions{}, err + } + return printer.OfficePrinterOptions{ + WaitTimeout: waitTimeout, + Landscape: landscape, + }, nil + } + opts, err := resolver() + if err != nil { + return opts, xerror.New(op, err) + } + return opts, nil +} diff --git a/internal/app/xhttp/pkg/context/context.go b/internal/app/xhttp/pkg/context/context.go new file mode 100644 index 00000000..cf49d205 --- /dev/null +++ b/internal/app/xhttp/pkg/context/context.go @@ -0,0 +1,210 @@ +package context + +import ( + "fmt" + "net/http" + "strconv" + "time" + + "github.com/labstack/echo/v4" + "github.com/thecodingmachine/gotenberg/internal/app/xhttp/pkg/resource" + "github.com/thecodingmachine/gotenberg/internal/pkg/conf" + "github.com/thecodingmachine/gotenberg/internal/pkg/pm2" + "github.com/thecodingmachine/gotenberg/internal/pkg/xerror" + "github.com/thecodingmachine/gotenberg/internal/pkg/xlog" +) + +// Context extends the default echo.Context. +type Context struct { + echo.Context + logger xlog.Logger + config conf.Config + processes []pm2.Process + resource resource.Resource + startTime time.Time +} + +// New creates a new Context. +func New(c echo.Context, logger xlog.Logger, config conf.Config, processess ...pm2.Process) Context { + return Context{ + c, + logger, + config, + processess, + resource.Resource{}, + time.Now(), + } +} + +/* +MustCastFromEchoContext cast an echo.Context +to our custom Context. + +It panics if casting goes wrong. +*/ +func MustCastFromEchoContext(c echo.Context) Context { + const op string = "context.MustCastFromEchoContext" + ctx, ok := c.(Context) + if !ok { + panic(fmt.Sprintf("%s: unable to cast an echo.Context to our custom context.Context", op)) + } + return ctx +} + +/* +XLogger returns the xlog.Logger associated +with the Context. + +This method should be used instead of the +default Logger() method coming from +the echo.Context. +*/ +func (ctx Context) XLogger() xlog.Logger { + return ctx.logger +} + +// Config returns the conf.Config associated +// with the Context. +func (ctx Context) Config() conf.Config { + return ctx.config +} + +// ProcessesHealthcheck returns an error if +// one of the processes is not viable. +func (ctx Context) ProcessesHealthcheck() error { + const op string = "context.Context.ProcessesHealthcheck" + for _, process := range ctx.processes { + if !process.IsViable() { + return xerror.New( + op, + fmt.Errorf("'%s' is not viable", process.Fullname()), + ) + } + } + return nil +} + +// WithResource creates a resource.Resource and +// adds it to the Context. +func (ctx *Context) WithResource(directoryName string) error { + const op string = "context.Context.WithResource" + resolver := func() (resource.Resource, error) { + r, err := resource.New(ctx.logger, directoryName) + if err != nil { + return r, err + } + // retrieve form values from request. + for _, key := range resource.ArgKeys() { + r.WithArg(key, ctx.FormValue(string(key))) + } + // write form files from request. + form, err := ctx.MultipartForm() + if err != nil { + return r, err + } + for _, files := range form.File { + for _, fh := range files { + in, err := fh.Open() + if err != nil { + return r, err + } + defer in.Close() // nolint: errcheck + if err := r.WithFile(fh.Filename, in); err != nil { + return r, err + } + } + } + return r, nil + } + resource, err := resolver() + ctx.resource = resource + if err != nil { + return xerror.New(op, err) + } + return nil +} + +/* +MustResource returns the resource.Resource +associated with the Context. + +It panics if no resource.Resource. +*/ +func (ctx Context) MustResource() resource.Resource { + const op string = "context.Context.MustResource" + if !ctx.HasResource() { + panic(fmt.Sprintf("%s: unable to retrieve the resource.Resource from our custom context.Context", op)) + } + return ctx.resource +} + +// HasResource returns true if the Context +// has a resource.Resource. +func (ctx Context) HasResource() bool { + return &ctx.resource != nil +} + +/* +LogRequestResult logs the result of a request. +This method should only be used by a middleware! + +If an error is given, returns the exact same error. +*/ +func (ctx Context) LogRequestResult(err error, isDebug bool) error { + const op string = "context.Context.LogRequestResult" + req := ctx.Request() + resp := ctx.Response() + stopTime := time.Now() + fields := map[string]interface{}{ + "remote_ip": ctx.RealIP(), + "host": req.Host, + "uri": req.RequestURI, + "method": req.Method, + "path": path(req), + "referer": req.Referer(), + "user_agent": req.UserAgent(), + "status": resp.Status, + "latency": lantency(ctx.startTime, stopTime), + "latency_human": latencyHuman(ctx.startTime, stopTime), + "bytes_in": bytesIn(req), + "bytes_out": bytesOut(resp), + } + if err != nil { + ctx.logger.WithFields(fields).ErrorfOp(op, "request failed") + return err + } + if isDebug { + ctx.logger.WithFields(fields).DebugfOp(op, "request handled") + return nil + } + ctx.logger.WithFields(fields).InfofOp(op, "request handled") + return nil +} + +func path(r *http.Request) string { + path := r.URL.Path + if path == "" { + path = "/" + } + return path +} + +func lantency(startTime time.Time, stopTime time.Time) string { + return strconv.FormatInt(int64(stopTime.Sub(startTime)), 10) +} + +func latencyHuman(startTime time.Time, stopTime time.Time) string { + return stopTime.Sub(startTime).String() +} + +func bytesIn(r *http.Request) string { + bytesIn := r.Header.Get(echo.HeaderContentLength) + if bytesIn == "" { + bytesIn = "0" + } + return bytesIn +} + +func bytesOut(r *echo.Response) string { + return strconv.FormatInt(r.Size, 10) +} diff --git a/internal/app/xhttp/pkg/context/doc.go b/internal/app/xhttp/pkg/context/doc.go new file mode 100644 index 00000000..294bb860 --- /dev/null +++ b/internal/app/xhttp/pkg/context/doc.go @@ -0,0 +1,7 @@ +/* +Package context extends the default echo.Context. + +All functions return our standard xerror.Error +in case of error. +*/ +package context diff --git a/internal/app/xhttp/pkg/resource/arg.go b/internal/app/xhttp/pkg/resource/arg.go new file mode 100644 index 00000000..d225b121 --- /dev/null +++ b/internal/app/xhttp/pkg/resource/arg.go @@ -0,0 +1,253 @@ +package resource + +import ( + "github.com/thecodingmachine/gotenberg/internal/pkg/conf" + "github.com/thecodingmachine/gotenberg/internal/pkg/xassert" + "github.com/thecodingmachine/gotenberg/internal/pkg/xerror" +) + +// ArgKey is a type for +// arguments' keys. +type ArgKey string + +const ( + // ResultFilenameArgKey is the key + // of the argument "resultFilename". + ResultFilenameArgKey ArgKey = "resultFilename" + // WaitTimeoutArgKey is the key + // of the argument "waitTimeout". + WaitTimeoutArgKey ArgKey = "waitTimeout" + // WebhookURLArgKey is the key + // of the argument "webhookURL". + WebhookURLArgKey ArgKey = "webhookURL" + // WebhookURLTimeoutArgKey is the key + // of the argument "webhookURLTimeout". + WebhookURLTimeoutArgKey ArgKey = "webhookURLTimeout" + // RemoteURLArgKey is the key + // of the argument "remoteURL". + RemoteURLArgKey ArgKey = "remoteURL" + // WaitDelayArgKey is the key + // of the argument "waitDelay". + WaitDelayArgKey ArgKey = "waitDelay" + // PaperWidthArgKey is the key + // of the argument "paperWidth". + PaperWidthArgKey ArgKey = "paperWidth" + // PaperHeightArgKey is the key + // of the argument "paperHeight". + PaperHeightArgKey ArgKey = "paperHeight" + // MarginTopArgKey is the key + // of the argument "marginTop". + MarginTopArgKey ArgKey = "marginTop" + // MarginBottomArgKey is the key + // of the argument "marginBottom". + MarginBottomArgKey ArgKey = "marginBottom" + // MarginLeftArgKey is the key + // of the argument "marginLeft". + MarginLeftArgKey ArgKey = "marginLeft" + // MarginRightArgKey is the key + // of the argument "marginRight". + MarginRightArgKey ArgKey = "marginRight" + // LandscapeArgKey is the key + // of the argument "landscape". + LandscapeArgKey ArgKey = "landscape" +) + +/* +ArgKeys returns a slice +containing all available +arguments' keys. +*/ +func ArgKeys() []ArgKey { + return []ArgKey{ + ResultFilenameArgKey, + WaitTimeoutArgKey, + WebhookURLArgKey, + WebhookURLTimeoutArgKey, + RemoteURLArgKey, + WaitDelayArgKey, + PaperWidthArgKey, + PaperHeightArgKey, + MarginTopArgKey, + MarginBottomArgKey, + MarginLeftArgKey, + MarginRightArgKey, + LandscapeArgKey, + } +} + +/* +WaitTimeoutArg is a helper for retrieving +the "waitTimeout" argument as float64. + +It also validates it against the application +configuration. +*/ +func WaitTimeoutArg(r Resource, config conf.Config) (float64, error) { + const op string = "resource.WaitTimeoutArg" + result, err := r.Float64Arg( + WaitTimeoutArgKey, + config.DefaultWaitTimeout(), + xassert.Float64NotInferiorTo(0), + xassert.Float64NotSuperiorTo(config.MaximumWaitTimeout()), + ) + if err != nil { + return result, xerror.New(op, err) + } + return result, nil +} + +/* +WaitDelayArg is a helper for retrieving +the "waitDelay" argument as float64. + +It also validates it against the application +configuration. +*/ +func WaitDelayArg(r Resource, config conf.Config) (float64, error) { + const ( + op string = "resource.WaitDelayArg" + defaultWaitDelay float64 = 0.0 + ) + result, err := r.Float64Arg( + WaitDelayArgKey, + defaultWaitDelay, + xassert.Float64NotInferiorTo(0.0), + xassert.Float64NotSuperiorTo(config.MaximumWaitDelay()), + ) + if err != nil { + return result, xerror.New(op, err) + } + return result, nil +} + +/* +PaperSizeArgs is a helper for retrieving +the "paperWidth" and "paperHeight" arguments +as float64. +*/ +func PaperSizeArgs(r Resource) (float64, float64, error) { + const ( + op string = "resource.PaperSizeArgs" + defaultPaperWidth float64 = 8.27 + defaultPaperHeight float64 = 11.7 + ) + resolver := func() (float64, float64, error) { + paperWidth, err := r.Float64Arg( + PaperWidthArgKey, + defaultPaperWidth, + xassert.Float64NotInferiorTo(0.0), + ) + if err != nil { + return defaultPaperWidth, + defaultPaperHeight, + err + } + paperHeight, err := r.Float64Arg( + PaperHeightArgKey, + defaultPaperHeight, + xassert.Float64NotInferiorTo(0.0), + ) + if err != nil { + return defaultPaperWidth, + defaultPaperHeight, + err + } + return paperWidth, + paperHeight, + nil + } + paperWidth, paperHeight, + err := resolver() + if err != nil { + return paperWidth, + paperHeight, + xerror.New(op, err) + } + return paperWidth, + paperHeight, + nil +} + +/* +MarginArgs is a helper for retrieving +the "marginTop", "marginBottom", "marginLeft" +and "marginRight" arguments as float64. +*/ +func MarginArgs(r Resource) (float64, float64, float64, float64, error) { + const ( + op string = "resource.MarginArgs" + defaultMarginTop float64 = 1.0 + defaultMarginBottom float64 = 1.0 + defaultMarginLeft float64 = 1.0 + defaultMarginRight float64 = 1.0 + ) + resolver := func() (float64, float64, float64, float64, error) { + marginTop, err := r.Float64Arg( + MarginTopArgKey, + defaultMarginTop, + xassert.Float64NotInferiorTo(0.0), + ) + if err != nil { + return defaultMarginTop, + defaultMarginBottom, + defaultMarginLeft, + defaultMarginRight, + err + } + marginBottom, err := r.Float64Arg( + MarginBottomArgKey, + defaultMarginBottom, + xassert.Float64NotInferiorTo(0.0), + ) + if err != nil { + return defaultMarginTop, + defaultMarginBottom, + defaultMarginLeft, + defaultMarginRight, + err + } + marginLeft, err := r.Float64Arg( + MarginLeftArgKey, + defaultMarginLeft, + xassert.Float64NotInferiorTo(0.0), + ) + if err != nil { + return defaultMarginTop, + defaultMarginBottom, + defaultMarginLeft, + defaultMarginRight, + err + } + marginRight, err := r.Float64Arg( + MarginRightArgKey, + defaultMarginRight, + xassert.Float64NotInferiorTo(0.0), + ) + if err != nil { + return defaultMarginTop, + defaultMarginBottom, + defaultMarginLeft, + defaultMarginRight, + err + } + return marginTop, + marginBottom, + marginLeft, + marginRight, + nil + } + marginTop, marginBottom, marginLeft, marginRight, + err := resolver() + if err != nil { + return marginTop, + marginBottom, + marginLeft, + marginRight, + xerror.New(op, err) + } + return marginTop, + marginBottom, + marginLeft, + marginRight, + nil +} diff --git a/internal/app/xhttp/pkg/resource/doc.go b/internal/app/xhttp/pkg/resource/doc.go new file mode 100644 index 00000000..3ec7b2c9 --- /dev/null +++ b/internal/app/xhttp/pkg/resource/doc.go @@ -0,0 +1,8 @@ +/* +Package resource helps managing +arguments and files for a conversion. + +All functions return our standard xerror.Error +in case of error. +*/ +package resource diff --git a/internal/app/xhttp/pkg/resource/file.go b/internal/app/xhttp/pkg/resource/file.go new file mode 100644 index 00000000..c6edbd0c --- /dev/null +++ b/internal/app/xhttp/pkg/resource/file.go @@ -0,0 +1,91 @@ +package resource + +import ( + "io" + "io/ioutil" + "os" + + "github.com/thecodingmachine/gotenberg/internal/pkg/xerror" +) + +// file represents a file within the resource. +type file struct { + fpath string +} + +// write writes given content to the +// resourceFile location. +func (f file) write(in io.Reader) error { + const op string = "resource.file.write" + resolver := func() error { + out, err := os.Create(f.fpath) + if err != nil { + return err + } + defer out.Close() // nolint: errcheck + if err := out.Chmod(0644); err != nil { + return err + } + if _, err := io.Copy(out, in); err != nil { + return err + } + if _, err := out.Seek(0, 0); err != nil { + return err + } + return nil + } + if err := resolver(); err != nil { + return xerror.New(op, err) + } + return nil +} + +// content returns the string content of +// the file. +func (f file) content() (string, error) { + const op string = "resource.file.content" + b, err := ioutil.ReadFile(f.fpath) + if err != nil { + return "", xerror.New(op, err) + } + return string(b), nil +} + +/* +HeaderFooterContents is a helper for retrieving +the content of the files "header.html" +and "footer.html". +*/ +func HeaderFooterContents(r Resource) (string, string, error) { + const ( + op string = "resource.HeaderFooterContents" + defaultHeaderFooterHTML string = "" + ) + resolver := func() (string, string, error) { + headerHTML, err := r.Fcontent("header.html", defaultHeaderFooterHTML) + if err != nil { + return defaultHeaderFooterHTML, + defaultHeaderFooterHTML, + err + } + footerHTML, err := r.Fcontent("footer.html", defaultHeaderFooterHTML) + if err != nil { + return defaultHeaderFooterHTML, + defaultHeaderFooterHTML, + err + } + return headerHTML, + footerHTML, + nil + } + headerHTML, footerHTML, + err := resolver() + if err != nil { + return headerHTML, + footerHTML, + xerror.New(op, err) + } + return headerHTML, + footerHTML, + nil +} diff --git a/internal/app/xhttp/pkg/resource/resource.go b/internal/app/xhttp/pkg/resource/resource.go new file mode 100644 index 00000000..81cf001a --- /dev/null +++ b/internal/app/xhttp/pkg/resource/resource.go @@ -0,0 +1,227 @@ +package resource + +import ( + "fmt" + "io" + "os" + "path/filepath" + + "github.com/thecodingmachine/gotenberg/internal/pkg/xassert" + "github.com/thecodingmachine/gotenberg/internal/pkg/xerror" + "github.com/thecodingmachine/gotenberg/internal/pkg/xlog" +) + +/* +TemporaryDirectory is the directory +where all the resources directory +are located. +*/ +const TemporaryDirectory string = "tmp" + +// Resource helps managing +// arguments and files for a conversion. +type Resource struct { + logger xlog.Logger + dirPath string + args map[ArgKey]string + files map[string]file +} + +// New creates a Resource where its files will +// be located in the given directory name. +func New(logger xlog.Logger, directoryName string) (Resource, error) { + const op string = "resource.New" + resolver := func() (string, error) { + dirPath := fmt.Sprintf("%s/%s", TemporaryDirectory, directoryName) + if err := os.MkdirAll(dirPath, 0755); err != nil { + return "", err + } + absDirPath, err := filepath.Abs(dirPath) + if err != nil { + return "", err + } + return absDirPath, nil + } + dirPath, err := resolver() + if err != nil { + return Resource{}, xerror.New(op, err) + } + logger.DebugfOp(op, "resource directory '%s' created", directoryName) + return Resource{ + logger: logger, + dirPath: dirPath, + args: make(map[ArgKey]string), + files: make(map[string]file), + }, nil +} + +// Close removes the working directory of the +// Resource if it exists. +func (r Resource) Close() error { + const op string = "resource.Resource.Close" + if _, err := os.Stat(r.dirPath); os.IsNotExist(err) { + r.logger.DebugfOp(op, "resource directory '%s' does not exist, nothing to remove", r.dirPath) + return nil + } + if err := os.RemoveAll(r.dirPath); err != nil { + return xerror.New(op, err) + } + r.logger.DebugfOp(op, "resource directory '%s' removed", r.dirPath) + return nil +} + +// WithArg add a new argument to the Resource. +func (r *Resource) WithArg(key ArgKey, value string) { + const op string = "resource.Resource.WithArg" + r.args[key] = value + r.logger.DebugfOp(op, "added '%s' with value '%s' to resource args", key, value) +} + +// WithFile add a new file to the Resource. +func (r *Resource) WithFile(filename string, in io.Reader) error { + const op string = "resource.Resource.WithFile" + fpath := fmt.Sprintf("%s/%s", r.dirPath, filename) + file := file{fpath: fpath} + if err := file.write(in); err != nil { + return xerror.New(op, err) + } + r.files[filename] = file + r.logger.DebugfOp(op, "resource file '%s' created", filename) + return nil +} + +// DirPath returns the directory path +// of the Resource. +func (r Resource) DirPath() string { + return r.dirPath +} + +// HasArg returns true if given key exists +// among the Resource and its value is not empty. +func (r Resource) HasArg(key ArgKey) bool { + if v, ok := r.args[key]; ok { + return v != "" + } + return false +} + +/* +StringArg returns the value of the +argument identified by given key. + +It works in the same manner as xassert.String. +*/ +func (r Resource) StringArg(key ArgKey, defaultValue string, rules ...xassert.RuleString) (string, error) { + const op string = "resource.Resource.StringArg" + result, err := xassert.String(string(key), r.args[key], defaultValue, rules...) + if err != nil { + return result, xerror.New(op, err) + } + return result, nil +} + +/* +Int64Arg returns the int64 representation of the +argument identified by given key. + +It works in the same manner as xassert.Int64. +*/ +func (r Resource) Int64Arg(key ArgKey, defaultValue int64, rules ...xassert.RuleInt64) (int64, error) { + const op string = "resource.Resource.Int64Arg" + result, err := xassert.Int64(string(key), r.args[key], defaultValue, rules...) + if err != nil { + return result, xerror.New(op, err) + } + return result, nil +} + +/* +Float64Arg returns the float64 representation of the +argument identified by given key. + +It works in the same manner as xassert.Float64. +*/ +func (r Resource) Float64Arg(key ArgKey, defaultValue float64, rules ...xassert.RuleFloat64) (float64, error) { + const op string = "resource.Resource.Float64Arg" + result, err := xassert.Float64(string(key), r.args[key], defaultValue, rules...) + if err != nil { + return result, xerror.New(op, err) + } + return result, nil +} + +/* +BoolArg returns the boolean representation of the +argument identified by given key. + +It works in the same manner as xassert.Bool. +*/ +func (r Resource) BoolArg(key ArgKey, defaultValue bool) (bool, error) { + const op string = "resource.Resource.BoolArg" + result, err := xassert.Bool(string(key), r.args[key], defaultValue) + if err != nil { + return result, xerror.New(op, err) + } + return result, nil +} + +// Fpath returns the path of the given filename. +// This filename should exist whithin the Resource. +func (r Resource) Fpath(filename string) (string, error) { + const op string = "resource.Resource.Fpath" + file, ok := r.files[filename] + if !ok { + return "", xerror.Invalid( + op, + fmt.Sprintf("resource file '%s' does not exist", filename), + nil, + ) + } + return file.fpath, nil +} + +/* +Fpaths returns the paths of the files +having one of the given file extensions. + +It should found at least one path. +*/ +func (r Resource) Fpaths(exts ...string) ([]string, error) { + const op string = "resource.Resource.Fpaths" + var fpaths []string + for filename, file := range r.files { + for _, ext := range exts { + if filepath.Ext(filename) == ext { + fpaths = append(fpaths, file.fpath) + } + } + } + if len(fpaths) == 0 { + return nil, xerror.Invalid( + op, + fmt.Sprintf("no resource file found for extensions '%v'", exts), + nil, + ) + } + return fpaths, nil +} + +/* +Fcontent returns the string content of the +given filename. + +If filename does not exist within the Resource, +returns the default value. +*/ +func (r Resource) Fcontent(filename, defaultValue string) (string, error) { + const op string = "resource.Resource.Fcontent" + file, ok := r.files[filename] + if !ok { + return defaultValue, nil + } + content, err := file.content() + if err != nil { + return "", xerror.New(op, err) + } + return content, nil +} diff --git a/internal/app/xhttp/xhttp.go b/internal/app/xhttp/xhttp.go new file mode 100644 index 00000000..73241d9b --- /dev/null +++ b/internal/app/xhttp/xhttp.go @@ -0,0 +1,33 @@ +package xhttp + +import ( + "github.com/labstack/echo/v4" + "github.com/thecodingmachine/gotenberg/internal/pkg/conf" + "github.com/thecodingmachine/gotenberg/internal/pkg/pm2" +) + +// New returns a custom echo.Echo. +func New(config conf.Config, processes ...pm2.Process) *echo.Echo { + srv := echo.New() + srv.HideBanner = true + srv.HidePort = true + srv.Use(contextMiddleware(config, processes...)) + srv.Use(loggerMiddleware()) + srv.Use(cleanupMiddleware()) + srv.Use(errorMiddleware()) + srv.GET(pingEndpoint, pingHandler) + srv.POST(mergeEndpoint, mergeHandler) + if config.DisableGoogleChrome() && config.DisableUnoconv() { + return srv + } + g := srv.Group(convertGroupEndpoint) + if !config.DisableGoogleChrome() { + g.POST(htmlEndpoint, htmlHandler) + g.POST(urlEndpoint, urlHandler) + g.POST(markdownEndpoint, markdownHandler) + } + if !config.DisableUnoconv() { + g.POST(officeEndpoint, officeHandler) + } + return srv +} diff --git a/internal/pkg/conf/conf.go b/internal/pkg/conf/conf.go new file mode 100644 index 00000000..56072b74 --- /dev/null +++ b/internal/pkg/conf/conf.go @@ -0,0 +1,206 @@ +package conf + +import ( + "github.com/thecodingmachine/gotenberg/internal/pkg/xassert" + "github.com/thecodingmachine/gotenberg/internal/pkg/xerror" + "github.com/thecodingmachine/gotenberg/internal/pkg/xlog" +) + +const ( + maximumWaitTimeoutEnvVar string = "MAXIMUM_WAIT_TIMEOUT" + maximumWaitDelayEnvVar string = "MAXIMUM_WAIT_DELAY" + maximumWebhookURLTimeoutEnvVar string = "MAXIMUM_WEBHOOK_URL_TIMEOUT" + defaultWaitTimeoutEnvVar string = "DEFAULT_WAIT_TIMEOUT" + defaultWebhookURLTimeoutEnvVar string = "DEFAULT_WEBHOOK_URL_TIMEOUT" + defaultListenPortEnvVar string = "DEFAULT_LISTEN_PORT" + disableGoogleChromeEnvVar string = "DISABLE_GOOGLE_CHROME" + disableUnoconvEnvVar string = "DISABLE_UNOCONV" + logLevelEnvVar string = "LOG_LEVEL" +) + +// Config contains the application +// configuration. +type Config struct { + maximumWaitTimeout float64 + maximumWaitDelay float64 + maximumWebhookURLTimeout float64 + defaultWaitTimeout float64 + defaultWebhookURLTimeout float64 + defaultListenPort int64 + disableGoogleChrome bool + disableUnoconv bool + logLevel xlog.Level +} + +func defaultConfig() Config { + return Config{ + maximumWaitTimeout: 30.0, + maximumWaitDelay: 10.0, + maximumWebhookURLTimeout: 30.0, + defaultWaitTimeout: 10.0, + defaultWebhookURLTimeout: 10.0, + defaultListenPort: 3000, + disableGoogleChrome: false, + disableUnoconv: false, + logLevel: xlog.InfoLevel, + } +} + +/* +FromEnv returns a Conf according +to environment variables. +*/ +func FromEnv() (Config, error) { + const op string = "conf.FromEnv" + resolver := func() (Config, error) { + c := defaultConfig() + maximumWaitTimeout, err := xassert.Float64FromEnv( + maximumWaitTimeoutEnvVar, + c.maximumWaitTimeout, + xassert.Float64NotInferiorTo(0.0), + ) + c.maximumWaitTimeout = maximumWaitTimeout + if err != nil { + return c, err + } + maximumWaitDelay, err := xassert.Float64FromEnv( + maximumWaitDelayEnvVar, + c.maximumWaitDelay, + xassert.Float64NotInferiorTo(0.0), + ) + c.maximumWaitDelay = maximumWaitDelay + if err != nil { + return c, err + } + maximumWebhookURLTimeout, err := xassert.Float64FromEnv( + maximumWebhookURLTimeoutEnvVar, + c.maximumWebhookURLTimeout, + xassert.Float64NotInferiorTo(0.0), + ) + c.maximumWebhookURLTimeout = maximumWebhookURLTimeout + if err != nil { + return c, err + } + defaultWaitTimeout, err := xassert.Float64FromEnv( + defaultWaitTimeoutEnvVar, + c.defaultWaitTimeout, + xassert.Float64NotInferiorTo(0.0), + xassert.Float64NotSuperiorTo(c.maximumWaitTimeout), + ) + c.defaultWaitTimeout = defaultWaitTimeout + if err != nil { + return c, err + } + defaultWebhookURLTimeout, err := xassert.Float64FromEnv( + defaultWebhookURLTimeoutEnvVar, + c.defaultWebhookURLTimeout, + xassert.Float64NotInferiorTo(0.0), + xassert.Float64NotSuperiorTo(c.defaultWebhookURLTimeout), + ) + c.defaultWebhookURLTimeout = defaultWebhookURLTimeout + if err != nil { + return c, err + } + defaultListenPort, err := xassert.Int64FromEnv( + defaultListenPortEnvVar, + c.defaultListenPort, + xassert.Int64NotInferiorTo(0), + xassert.Int64NotSuperiorTo(65535), + ) + c.defaultListenPort = defaultListenPort + if err != nil { + return c, err + } + disableGoogleChrome, err := xassert.BoolFromEnv( + disableGoogleChromeEnvVar, + c.disableGoogleChrome, + ) + c.disableGoogleChrome = disableGoogleChrome + if err != nil { + return c, err + } + disableUnoconv, err := xassert.BoolFromEnv( + disableUnoconvEnvVar, + c.disableUnoconv, + ) + c.disableUnoconv = disableUnoconv + if err != nil { + return c, err + } + logLevel, err := xassert.StringFromEnv( + logLevelEnvVar, + string(c.logLevel), + xassert.StringOneOf(xlog.Levels()), + ) + c.logLevel = xlog.MustParseLevel(logLevel) + if err != nil { + return c, err + } + return c, nil + } + result, err := resolver() + if err != nil { + return result, xerror.New(op, err) + } + return result, nil +} + +// MaximumWaitTimeout returns the maximum +// wait timeout from the configuration. +func (c Config) MaximumWaitTimeout() float64 { + return c.maximumWaitTimeout +} + +// MaximumWaitDelay returns the maximum +// wait timeout from the configuration. +func (c Config) MaximumWaitDelay() float64 { + return c.maximumWaitDelay +} + +// MaximumWebhookURLTimeout returns the maximum +// webhook URL wait timeout from the configuration. +func (c Config) MaximumWebhookURLTimeout() float64 { + return c.maximumWebhookURLTimeout +} + +// DefaultWaitTimeout returns the default +// wait timeout from the configuration. +func (c Config) DefaultWaitTimeout() float64 { + return c.defaultWaitTimeout +} + +// DefaultWebhookURLTimeout returns the default +// webhook URL wait timeout from the configuration. +func (c Config) DefaultWebhookURLTimeout() float64 { + return c.defaultWebhookURLTimeout +} + +// DefaultListenPort returns the default +// listen port from the configuration. +func (c Config) DefaultListenPort() int64 { + return c.defaultListenPort +} + +/* +DisableGoogleChrome returns true if +Google Chrome is disabled in the +configuration. +*/ +func (c Config) DisableGoogleChrome() bool { + return c.disableGoogleChrome +} + +/* +DisableUnoconv returns true if +Unoconv is disabled in the +configuration. +*/ +func (c Config) DisableUnoconv() bool { + return c.disableUnoconv +} + +// LogLevel returns the xlog.Level from +// the configuration. +func (c Config) LogLevel() xlog.Level { + return c.logLevel +} diff --git a/internal/pkg/conf/conf_test.go b/internal/pkg/conf/conf_test.go new file mode 100644 index 00000000..8ce2b2f9 --- /dev/null +++ b/internal/pkg/conf/conf_test.go @@ -0,0 +1,334 @@ +package conf + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/thecodingmachine/gotenberg/internal/pkg/xlog" + "github.com/thecodingmachine/gotenberg/test/internalpkg/xerrortest" +) + +func TestEmptyFromEnv(t *testing.T) { + var ( + expected Config + result Config + err error + ) + // no environment variables set, + // values should be equal to default config. + expected = defaultConfig() + result, err = FromEnv() + assert.Nil(t, err) + assert.Equal(t, expected, result) +} + +func TestMaximumWaitTimeoutFromEnv(t *testing.T) { + var ( + expected Config + result Config + err error + ) + // MAXIMUM_WAIT_TIMEOUT correctly set. + os.Setenv(maximumWaitTimeoutEnvVar, "10.0") + expected = defaultConfig() + expected.maximumWaitTimeout = 10.0 + result, err = FromEnv() + assert.Nil(t, err) + assert.Equal(t, expected, result) + os.Unsetenv(maximumWaitTimeoutEnvVar) + // MAXIMUM_WAIT_TIMEOUT wrongly set. + os.Setenv(maximumWaitTimeoutEnvVar, "foo") + expected = defaultConfig() + result, err = FromEnv() + xerrortest.AssertError(t, err) + assert.Equal(t, expected, result) + os.Unsetenv(maximumWaitTimeoutEnvVar) + // MAXIMUM_WAIT_TIMEOUT < 0. + os.Setenv(maximumWaitTimeoutEnvVar, "-1.0") + expected = defaultConfig() + result, err = FromEnv() + xerrortest.AssertError(t, err) + assert.Equal(t, expected, result) + os.Unsetenv(maximumWaitTimeoutEnvVar) +} + +func TestMaximumWaitDelayFromEnv(t *testing.T) { + var ( + expected Config + result Config + err error + ) + // MAXIMUM_WAIT_DELAY correctly set. + os.Setenv(maximumWaitDelayEnvVar, "10.0") + expected = defaultConfig() + expected.maximumWaitDelay = 10.0 + result, err = FromEnv() + assert.Nil(t, err) + assert.Equal(t, expected, result) + os.Unsetenv(maximumWaitDelayEnvVar) + // MAXIMUM_WAIT_DELAY wrongly set. + os.Setenv(maximumWaitDelayEnvVar, "foo") + expected = defaultConfig() + result, err = FromEnv() + xerrortest.AssertError(t, err) + assert.Equal(t, expected, result) + os.Unsetenv(maximumWaitDelayEnvVar) + // MAXIMUM_WAIT_DELAY < 0. + os.Setenv(maximumWaitDelayEnvVar, "-1.0") + expected = defaultConfig() + result, err = FromEnv() + xerrortest.AssertError(t, err) + assert.Equal(t, expected, result) + os.Unsetenv(maximumWaitDelayEnvVar) +} + +func TestMaximumWebhookURLTimeoutFromEnv(t *testing.T) { + var ( + expected Config + result Config + err error + ) + // MAXIMUM_WEBHOOK_URL_TIMEOUT correctly set. + os.Setenv(maximumWebhookURLTimeoutEnvVar, "10.0") + expected = defaultConfig() + expected.maximumWebhookURLTimeout = 10.0 + result, err = FromEnv() + assert.Nil(t, err) + assert.Equal(t, expected, result) + os.Unsetenv(maximumWebhookURLTimeoutEnvVar) + // MAXIMUM_WEBHOOK_URL_TIMEOUT wrongly set. + os.Setenv(maximumWebhookURLTimeoutEnvVar, "foo") + expected = defaultConfig() + result, err = FromEnv() + xerrortest.AssertError(t, err) + assert.Equal(t, expected, result) + os.Unsetenv(maximumWebhookURLTimeoutEnvVar) + // MAXIMUM_WEBHOOK_URL_TIMEOUT < 0. + os.Setenv(maximumWebhookURLTimeoutEnvVar, "-1.0") + expected = defaultConfig() + result, err = FromEnv() + xerrortest.AssertError(t, err) + assert.Equal(t, expected, result) + os.Unsetenv(maximumWebhookURLTimeoutEnvVar) +} + +func TestDefaultWaitTimeoutFromEnv(t *testing.T) { + var ( + expected Config + result Config + err error + ) + // DEFAULT_WAIT_TIMEOUT correctly set. + os.Setenv(defaultWaitTimeoutEnvVar, "10.0") + expected = defaultConfig() + expected.defaultWaitTimeout = 10.0 + result, err = FromEnv() + assert.Nil(t, err) + assert.Equal(t, expected, result) + os.Unsetenv(defaultWaitTimeoutEnvVar) + // DEFAULT_WAIT_TIMEOUT wrongly set. + os.Setenv(defaultWaitTimeoutEnvVar, "foo") + expected = defaultConfig() + result, err = FromEnv() + xerrortest.AssertError(t, err) + assert.Equal(t, expected, result) + os.Unsetenv(defaultWaitTimeoutEnvVar) + // DEFAULT_WAIT_TIMEOUT < 0. + os.Setenv(defaultWaitTimeoutEnvVar, "-1.0") + expected = defaultConfig() + result, err = FromEnv() + xerrortest.AssertError(t, err) + assert.Equal(t, expected, result) + os.Unsetenv(defaultWaitTimeoutEnvVar) + // DEFAULT_WAIT_TIMEOUT > MAXIMUM_WAIT_TIMEOUT. + os.Setenv(defaultWaitTimeoutEnvVar, "40.0") + expected = defaultConfig() + result, err = FromEnv() + xerrortest.AssertError(t, err) + assert.Equal(t, expected, result) + os.Unsetenv(defaultWaitTimeoutEnvVar) +} + +func TestDefaultWebhookURLTimeoutFromEnv(t *testing.T) { + var ( + expected Config + result Config + err error + ) + // DEFAULT_WEBHOOK_URL_TIMEOUT correctly set. + os.Setenv(defaultWebhookURLTimeoutEnvVar, "10.0") + expected = defaultConfig() + expected.defaultWebhookURLTimeout = 10.0 + result, err = FromEnv() + assert.Nil(t, err) + assert.Equal(t, expected, result) + os.Unsetenv(defaultWebhookURLTimeoutEnvVar) + // DEFAULT_WEBHOOK_URL_TIMEOUT wrongly set. + os.Setenv(defaultWebhookURLTimeoutEnvVar, "foo") + expected = defaultConfig() + result, err = FromEnv() + xerrortest.AssertError(t, err) + assert.Equal(t, expected, result) + os.Unsetenv(defaultWebhookURLTimeoutEnvVar) + // DEFAULT_WEBHOOK_URL_TIMEOUT < 0. + os.Setenv(defaultWebhookURLTimeoutEnvVar, "-1.0") + expected = defaultConfig() + result, err = FromEnv() + xerrortest.AssertError(t, err) + assert.Equal(t, expected, result) + os.Unsetenv(defaultWebhookURLTimeoutEnvVar) + // DEFAULT_WEBHOOK_URL_TIMEOUT > MAXIMUM_WEBHOOK_URL_TIMEOUT. + os.Setenv(defaultWebhookURLTimeoutEnvVar, "40.0") + expected = defaultConfig() + result, err = FromEnv() + xerrortest.AssertError(t, err) + assert.Equal(t, expected, result) + os.Unsetenv(defaultWebhookURLTimeoutEnvVar) +} + +func TestDefaultListenPortFromEnv(t *testing.T) { + var ( + expected Config + result Config + err error + ) + // DEFAULT_LISTEN_PORT correctly set. + os.Setenv(defaultListenPortEnvVar, "80") + expected = defaultConfig() + expected.defaultListenPort = 80 + result, err = FromEnv() + assert.Nil(t, err) + assert.Equal(t, expected, result) + os.Unsetenv(defaultListenPortEnvVar) + // DEFAULT_LISTEN_PORT wrongly set. + os.Setenv(defaultListenPortEnvVar, "foo") + expected = defaultConfig() + result, err = FromEnv() + xerrortest.AssertError(t, err) + assert.Equal(t, expected, result) + os.Unsetenv(defaultListenPortEnvVar) + // DEFAULT_LISTEN_PORT < 0. + os.Setenv(defaultListenPortEnvVar, "-1.0") + expected = defaultConfig() + result, err = FromEnv() + xerrortest.AssertError(t, err) + assert.Equal(t, expected, result) + os.Unsetenv(defaultListenPortEnvVar) + // DEFAULT_LISTEN_PORT > 65535. + os.Setenv(defaultListenPortEnvVar, "65536") + expected = defaultConfig() + result, err = FromEnv() + xerrortest.AssertError(t, err) + assert.Equal(t, expected, result) + os.Unsetenv(defaultListenPortEnvVar) +} + +func TestDisableGoogleChromeFromEnv(t *testing.T) { + var ( + expected Config + result Config + err error + ) + // DISABLE_GOOGLE_CHROME correctly set. + os.Setenv(disableGoogleChromeEnvVar, "1") + expected = defaultConfig() + expected.disableGoogleChrome = true + result, err = FromEnv() + assert.Nil(t, err) + assert.Equal(t, expected, result) + os.Unsetenv(disableGoogleChromeEnvVar) + os.Setenv(disableGoogleChromeEnvVar, "0") + expected = defaultConfig() + expected.disableGoogleChrome = false + result, err = FromEnv() + assert.Nil(t, err) + assert.Equal(t, expected, result) + os.Unsetenv(disableGoogleChromeEnvVar) + // DISABLE_GOOGLE_CHROME wrongly set. + os.Setenv(disableGoogleChromeEnvVar, "foo") + expected = defaultConfig() + result, err = FromEnv() + xerrortest.AssertError(t, err) + assert.Equal(t, expected, result) + os.Unsetenv(disableGoogleChromeEnvVar) +} + +func TestDisableUnoconvFromEnv(t *testing.T) { + var ( + expected Config + result Config + err error + ) + // DISABLE_UNOCONV correctly set. + os.Setenv(disableUnoconvEnvVar, "1") + expected = defaultConfig() + expected.disableUnoconv = true + result, err = FromEnv() + assert.Nil(t, err) + assert.Equal(t, expected, result) + os.Unsetenv(disableUnoconvEnvVar) + os.Setenv(disableUnoconvEnvVar, "0") + expected = defaultConfig() + expected.disableUnoconv = false + result, err = FromEnv() + assert.Nil(t, err) + assert.Equal(t, expected, result) + os.Unsetenv(disableUnoconvEnvVar) + // DISABLE_UNOCONV wrongly set. + os.Setenv(disableUnoconvEnvVar, "foo") + expected = defaultConfig() + result, err = FromEnv() + xerrortest.AssertError(t, err) + assert.Equal(t, expected, result) + os.Unsetenv(disableUnoconvEnvVar) +} + +func TestLogLevelFromEnv(t *testing.T) { + var ( + expected Config + result Config + err error + ) + // LOG_LEVEL correctly set. + os.Setenv(logLevelEnvVar, "DEBUG") + expected = defaultConfig() + expected.logLevel = xlog.DebugLevel + result, err = FromEnv() + assert.Nil(t, err) + assert.Equal(t, expected, result) + os.Unsetenv(logLevelEnvVar) + os.Setenv(logLevelEnvVar, "INFO") + expected = defaultConfig() + result, err = FromEnv() + assert.Nil(t, err) + assert.Equal(t, expected, result) + os.Unsetenv(logLevelEnvVar) + os.Setenv(logLevelEnvVar, "ERROR") + expected = defaultConfig() + expected.logLevel = xlog.ErrorLevel + result, err = FromEnv() + assert.Nil(t, err) + assert.Equal(t, expected, result) + os.Unsetenv(logLevelEnvVar) + // LOG_LEVEL wrongly set. + os.Setenv(logLevelEnvVar, "foo") + expected = defaultConfig() + result, err = FromEnv() + xerrortest.AssertError(t, err) + assert.Equal(t, expected, result) + os.Unsetenv(logLevelEnvVar) +} + +func TestGetters(t *testing.T) { + result := defaultConfig() + assert.Equal(t, result.maximumWaitTimeout, result.MaximumWaitTimeout()) + assert.Equal(t, result.maximumWaitDelay, result.MaximumWaitDelay()) + assert.Equal(t, result.maximumWebhookURLTimeout, result.MaximumWebhookURLTimeout()) + assert.Equal(t, result.defaultWaitTimeout, result.DefaultWaitTimeout()) + assert.Equal(t, result.defaultWebhookURLTimeout, result.DefaultWebhookURLTimeout()) + assert.Equal(t, result.defaultListenPort, result.DefaultListenPort()) + assert.Equal(t, result.disableGoogleChrome, result.DisableGoogleChrome()) + assert.Equal(t, result.disableUnoconv, result.DisableUnoconv()) + assert.Equal(t, result.logLevel, result.LogLevel()) +} diff --git a/internal/pkg/conf/doc.go b/internal/pkg/conf/doc.go new file mode 100644 index 00000000..352ce083 --- /dev/null +++ b/internal/pkg/conf/doc.go @@ -0,0 +1,3 @@ +// Package conf gathers all +// configuration data. +package conf diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go deleted file mode 100644 index 41be1df9..00000000 --- a/internal/pkg/config/config.go +++ /dev/null @@ -1,178 +0,0 @@ -package config - -import ( - "fmt" - "os" - "strconv" - - "github.com/sirupsen/logrus" - "github.com/thecodingmachine/gotenberg/internal/pkg/standarderror" -) - -const ( - defaultWaitTimeoutEnvVar string = "DEFAULT_WAIT_TIMEOUT" - defaultListenPortEnvVar string = "DEFAULT_LISTEN_PORT" - disableGoogleChromeEnvVar string = "DISABLE_GOOGLE_CHROME" - disableUnoconvEnvVar string = "DISABLE_UNOCONV" - logLevelEnvVar string = "LOG_LEVEL" -) - -// Config contains the application -// configuration. -type Config struct { - defaultWaitTimeout float64 - defaultListenPort string - enableChromeEndpoints bool - enableUnoconvEndpoints bool - logLevel logrus.Level -} - -func defaultConfig() *Config { - return &Config{ - defaultWaitTimeout: 10, - defaultListenPort: "3000", - enableChromeEndpoints: true, - enableUnoconvEndpoints: true, - logLevel: logrus.InfoLevel, - } -} - -// FromEnv fetches configuration -// from environment variables. -func FromEnv() (*Config, error) { - const op string = "config.FromEnv" - c := defaultConfig() - defaultWaitTimeout, err := defaultWaitTimeoutFromEnv(defaultWaitTimeoutEnvVar, c.DefaultWaitTimeout()) - c.defaultWaitTimeout = defaultWaitTimeout - if err != nil { - return c, &standarderror.Error{Op: op, Err: err} - } - defaultListenPort, err := defaultListenPortFromEnv(defaultListenPortEnvVar, c.DefaultListenPort()) - c.defaultListenPort = defaultListenPort - if err != nil { - return c, &standarderror.Error{Op: op, Err: err} - } - disableChromeEndpoints, err := boolFromEnv(disableGoogleChromeEnvVar, !c.EnableChromeEndpoints()) - c.enableChromeEndpoints = !disableChromeEndpoints - if err != nil { - return c, &standarderror.Error{Op: op, Err: err} - } - disableUnoconvEndpoints, err := boolFromEnv(disableUnoconvEnvVar, !c.EnableUnoconvEndpoints()) - c.enableUnoconvEndpoints = !disableUnoconvEndpoints - if err != nil { - return c, &standarderror.Error{Op: op, Err: err} - } - logLevel, err := logLevelFromEnv(logLevelEnvVar, c.LogLevel()) - c.logLevel = logLevel - if err != nil { - return c, &standarderror.Error{Op: op, Err: err} - } - return c, nil -} - -// DefaultWaitTimeout returns the default -// wait timeout from the configuration. -func (c *Config) DefaultWaitTimeout() float64 { - return c.defaultWaitTimeout -} - -// DefaultListenPort returns the default -// listen port from the configuration. -func (c *Config) DefaultListenPort() string { - return c.defaultListenPort -} - -// EnableChromeEndpoints returns true if -// Chrome endpoints are enabled in the -// configuration. -func (c *Config) EnableChromeEndpoints() bool { - return c.enableChromeEndpoints -} - -// EnableUnoconvEndpoints returns true if -// Unoconv endpoints are enabled in the -// configuration. -func (c *Config) EnableUnoconvEndpoints() bool { - return c.enableUnoconvEndpoints -} - -// LogLevel returns the logrus.Level from -// the configuration. -func (c *Config) LogLevel() logrus.Level { - return c.logLevel -} - -func defaultWaitTimeoutFromEnv(envVar string, defaultValue float64) (float64, error) { - const op string = "config.defaultWaitTimeoutFromEnv" - if v, ok := os.LookupEnv(envVar); ok { - waitTimeout, err := strconv.ParseFloat(v, 64) - if err != nil { - return defaultValue, &standarderror.Error{ - Code: standarderror.Invalid, - Message: fmt.Sprintf("'%s' is not a float, got '%s'", envVar, v), - Op: op, - } - } - return waitTimeout, nil - } - return defaultValue, nil -} - -func defaultListenPortFromEnv(envVar string, defaultValue string) (string, error) { - const op string = "config.defaultListenPortFromEnv" - if v, ok := os.LookupEnv(envVar); ok { - portAsUint, err := strconv.ParseUint(v, 10, 64) - if err != nil { - return defaultValue, &standarderror.Error{ - Code: standarderror.Invalid, - Message: fmt.Sprintf("'%s' is not a uint, got '%s'", envVar, v), - Op: op, - } - } - if portAsUint > 65535 { - return defaultValue, &standarderror.Error{ - Code: standarderror.Invalid, - Message: fmt.Sprintf("'%s' is not a uint < 65535, got '%d'", envVar, portAsUint), - Op: op, - } - } - return v, nil - } - return defaultValue, nil -} - -func boolFromEnv(envVar string, defaultValue bool) (bool, error) { - const op string = "config.boolFromEnv" - if v, ok := os.LookupEnv(envVar); ok { - if v != "1" && v != "0" { - return defaultValue, &standarderror.Error{ - Code: standarderror.Invalid, - Message: fmt.Sprintf("'%s' is not '0' or '1', got %s", envVar, v), - Op: op, - } - } - return v == "1", nil - } - return defaultValue, nil -} - -func logLevelFromEnv(envVar string, defaultValue logrus.Level) (logrus.Level, error) { - const op string = "config.logLevelFromEnv" - if v, ok := os.LookupEnv(envVar); ok { - switch v { - case "DEBUG": - return logrus.DebugLevel, nil - case "INFO": - return logrus.InfoLevel, nil - case "ERROR": - return logrus.ErrorLevel, nil - default: - return defaultValue, &standarderror.Error{ - Code: standarderror.Invalid, - Message: fmt.Sprintf("'%s' is not 'DEBUG', 'INFO' or 'ERROR', got '%s'", envVar, v), - Op: op, - } - } - } - return defaultValue, nil -} diff --git a/internal/pkg/config/config_test.go b/internal/pkg/config/config_test.go deleted file mode 100644 index 72efa520..00000000 --- a/internal/pkg/config/config_test.go +++ /dev/null @@ -1,123 +0,0 @@ -package config - -import ( - "os" - "testing" - - "github.com/sirupsen/logrus" - "github.com/stretchr/testify/assert" - "github.com/thecodingmachine/gotenberg/internal/pkg/standarderror" - "github.com/thecodingmachine/gotenberg/test" -) - -func TestDefaultWaitTimeout(t *testing.T) { - // should be OK. - config, err := FromEnv() - assert.Nil(t, err) - assert.Equal(t, 10.0, config.DefaultWaitTimeout()) - os.Setenv(defaultWaitTimeoutEnvVar, "1.5") - config, err = FromEnv() - assert.Nil(t, err) - assert.Equal(t, 1.5, config.DefaultWaitTimeout()) - // should failed. - os.Setenv(defaultWaitTimeoutEnvVar, "foo") - _, err = FromEnv() - assert.NotNil(t, err) - standardized := test.RequireStandardError(t, err) - assert.Equal(t, standarderror.Invalid, standarderror.Code(standardized)) - os.Unsetenv(defaultWaitTimeoutEnvVar) -} - -func TestDefaultListenPort(t *testing.T) { - // should be OK. - config, err := FromEnv() - assert.Nil(t, err) - assert.Equal(t, "3000", config.DefaultListenPort()) - os.Setenv(defaultListenPortEnvVar, "4000") - config, err = FromEnv() - assert.Nil(t, err) - assert.Equal(t, "4000", config.DefaultListenPort()) - // should failed. - os.Setenv(defaultListenPortEnvVar, "foo") - _, err = FromEnv() - assert.NotNil(t, err) - standardized := test.RequireStandardError(t, err) - assert.Equal(t, standarderror.Invalid, standarderror.Code(standardized)) - os.Setenv(defaultListenPortEnvVar, "100000000") - _, err = FromEnv() - assert.NotNil(t, err) - standardized = test.RequireStandardError(t, err) - assert.Equal(t, standarderror.Invalid, standarderror.Code(standardized)) - os.Unsetenv(defaultListenPortEnvVar) -} - -func TestEnableChromeEndpoints(t *testing.T) { - // should be OK. - config, err := FromEnv() - assert.Nil(t, err) - assert.Equal(t, true, config.EnableChromeEndpoints()) - os.Setenv(disableGoogleChromeEnvVar, "1") - config, err = FromEnv() - assert.Nil(t, err) - assert.Equal(t, false, config.EnableChromeEndpoints()) - os.Setenv(disableGoogleChromeEnvVar, "0") - config, err = FromEnv() - assert.Nil(t, err) - assert.Equal(t, true, config.EnableChromeEndpoints()) - // should failed. - os.Setenv(disableGoogleChromeEnvVar, "true") - _, err = FromEnv() - assert.NotNil(t, err) - standardized := test.RequireStandardError(t, err) - assert.Equal(t, standarderror.Invalid, standarderror.Code(standardized)) - os.Unsetenv(disableGoogleChromeEnvVar) -} - -func TestEnableUnoconvEndpoints(t *testing.T) { - // should be OK. - config, err := FromEnv() - assert.Nil(t, err) - assert.Equal(t, true, config.EnableUnoconvEndpoints()) - os.Setenv(disableUnoconvEnvVar, "1") - config, err = FromEnv() - assert.Nil(t, err) - assert.Equal(t, false, config.EnableUnoconvEndpoints()) - os.Setenv(disableUnoconvEnvVar, "0") - config, err = FromEnv() - assert.Nil(t, err) - assert.Equal(t, true, config.EnableUnoconvEndpoints()) - // should failed. - os.Setenv(disableUnoconvEnvVar, "true") - _, err = FromEnv() - assert.NotNil(t, err) - standardized := test.RequireStandardError(t, err) - assert.Equal(t, standarderror.Invalid, standarderror.Code(standardized)) - os.Unsetenv(disableUnoconvEnvVar) -} - -func TestLogLevel(t *testing.T) { - // should be OK. - config, err := FromEnv() - assert.Nil(t, err) - assert.Equal(t, logrus.InfoLevel, config.LogLevel()) - os.Setenv(logLevelEnvVar, "DEBUG") - config, err = FromEnv() - assert.Nil(t, err) - assert.Equal(t, logrus.DebugLevel, config.LogLevel()) - os.Setenv(logLevelEnvVar, "INFO") - config, err = FromEnv() - assert.Nil(t, err) - assert.Equal(t, logrus.InfoLevel, config.LogLevel()) - os.Setenv(logLevelEnvVar, "ERROR") - config, err = FromEnv() - assert.Nil(t, err) - assert.Equal(t, logrus.ErrorLevel, config.LogLevel()) - // should failed. - os.Setenv(logLevelEnvVar, "foo") - config, err = FromEnv() - assert.Equal(t, logrus.InfoLevel, config.LogLevel()) - assert.NotNil(t, err) - standardized := test.RequireStandardError(t, err) - assert.Equal(t, standarderror.Invalid, standarderror.Code(standardized)) - os.Unsetenv(logLevelEnvVar) -} diff --git a/internal/pkg/config/doc.go b/internal/pkg/config/doc.go deleted file mode 100644 index 55b7416f..00000000 --- a/internal/pkg/config/doc.go +++ /dev/null @@ -1,3 +0,0 @@ -// Package config gathers all -// configuration data. -package config diff --git a/internal/pkg/logger/doc.go b/internal/pkg/logger/doc.go deleted file mode 100644 index b9fcafe6..00000000 --- a/internal/pkg/logger/doc.go +++ /dev/null @@ -1,3 +0,0 @@ -// Package logger defines a standard -// logger for the application. -package logger diff --git a/internal/pkg/logger/logger.go b/internal/pkg/logger/logger.go deleted file mode 100644 index ee24d8c3..00000000 --- a/internal/pkg/logger/logger.go +++ /dev/null @@ -1,63 +0,0 @@ -package logger - -import ( - "os" - - "github.com/mattn/go-isatty" - "github.com/sirupsen/logrus" -) - -// Logger enforces specific log message formats. -type Logger struct { - entry *logrus.Entry -} - -// New initializes the logger. -func New(level logrus.Level, trace string) *Logger { - l := logrus.New() - l.SetLevel(level) - if !isatty.IsTerminal(os.Stdout.Fd()) { - l.SetFormatter(&logrus.JSONFormatter{}) - } - return &Logger{ - entry: l.WithField("trace", trace), - } -} - -// WithFields creates a new logger with -// given fields. -func (l *Logger) WithFields(fields map[string]interface{}) *Logger { - return &Logger{ - entry: l.entry.WithFields(fields), - } -} - -// DebugfOp logs a debug message for given -// logical operation. -func (l *Logger) DebugfOp(op string, format string, args ...interface{}) { - l.entry.WithField("op", op).Debugf(format, args...) -} - -// InfofOp logs an info message for given -// logical operation. -func (l *Logger) InfofOp(op string, format string, args ...interface{}) { - l.entry.WithField("op", op).Infof(format, args...) -} - -// ErrorOp logs an error for given -// logical operation. -func (l *Logger) ErrorOp(op string, err error) { - l.entry.WithField("op", op).Error(err.Error()) -} - -// ErrorfOp logs an error message for given -// logical operation. -func (l *Logger) ErrorfOp(op string, message string) { - l.entry.WithField("op", op).Error(message) -} - -// FatalOp logs an error message for given -// logical operation. -func (l *Logger) FatalOp(op string, err error) { - l.entry.WithField("op", op).Error(err.Error()) -} diff --git a/internal/pkg/pm2/chrome.go b/internal/pkg/pm2/chrome.go index 52a639c9..a111e456 100644 --- a/internal/pkg/pm2/chrome.go +++ b/internal/pkg/pm2/chrome.go @@ -5,45 +5,72 @@ import ( "time" "github.com/mafredri/cdp/devtool" - "github.com/thecodingmachine/gotenberg/internal/pkg/logger" - "github.com/thecodingmachine/gotenberg/internal/pkg/standarderror" + "github.com/thecodingmachine/gotenberg/internal/pkg/xerror" + "github.com/thecodingmachine/gotenberg/internal/pkg/xlog" ) -const chromeWarmupTime time.Duration = 10 * time.Second - -type chrome struct { - manager *processManager +type chromeProcess struct { + logger xlog.Logger } -// NewChrome returns a Google Chrome +// NewChromeProcess returns a Google Chrome // headless process. -func NewChrome(logger *logger.Logger) Process { - return &chrome{ - manager: &processManager{logger: logger}, +func NewChromeProcess(logger xlog.Logger) Process { + return chromeProcess{ + logger: logger, } } -func (p *chrome) Fullname() string { +func (p chromeProcess) Fullname() string { return "Google Chrome headless" } -func (p *chrome) Start() error { - const op string = "pm2.chrome.Start" - if err := p.manager.start(p); err != nil { - return &standarderror.Error{Op: op, Err: err} +func (p chromeProcess) Start() error { + const op string = "pm2.chromeProcess.Start" + if err := start(p.logger, p); err != nil { + return xerror.New(op, err) } return nil } -func (p *chrome) Shutdown() error { - const op string = "pm2.chrome.Shutdown" - if err := p.manager.shutdown(p); err != nil { - return &standarderror.Error{Op: op, Err: err} +func (p chromeProcess) IsViable() bool { + const op string = "pm2.chromeProcess.IsViable" + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + p.logger.DebugfOp( + op, + "checking '%s' viability via endpoint '%s'", + p.Fullname(), + "http://localhost:9222/json/version", + ) + v, err := devtool.New("http://localhost:9222").Version(ctx) + if err != nil { + p.logger.ErrorfOp( + op, + "'%s' is not viable as endpoint returned '%v'", + p.Fullname(), + err, + ) + return false + } + p.logger.DebugfOp( + op, + "'%s' is viable as endpoint returned '%v'", + p.Fullname(), + v, + ) + return true +} + +func (p chromeProcess) Stop() error { + const op string = "pm2.chromeProcess.Stop" + if err := stop(p.logger, p); err != nil { + return xerror.New(op, err) } return nil } -func (p *chrome) args() []string { +func (p chromeProcess) args() []string { return []string{ "--no-sandbox", "--headless", @@ -62,47 +89,25 @@ func (p *chrome) args() []string { } } -func (p *chrome) name() string { +func (p chromeProcess) binary() string { return "google-chrome-stable" } -func (p *chrome) viable() bool { - const op string = "pm2.chrome.viable" - // check if Google Chrome is correctly running. - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - p.manager.logger.DebugfOp( - op, - "checking liveness via debug version endpoint http://localhost:9222/json/version", +func (p chromeProcess) warmup() { + const ( + op string = "pm2.chromeProcess.warmup" + warmupTime time.Duration = 10 * time.Second ) - v, err := devtool.New("http://localhost:9222").Version(ctx) - if err != nil { - p.manager.logger.DebugfOp( - op, - "debug version endpoint returned error: %v", - err, - ) - return false - } - p.manager.logger.DebugfOp( + p.logger.DebugfOp( op, - "debug version endpoint returned version info: %+v", - *v, + "waiting '%v' for allowing '%s' to warmup", + warmupTime, + p.Fullname(), ) - return true -} - -func (p *chrome) warmup() { - const op string = "pm2.chrome.warmup" - p.manager.logger.DebugfOp( - op, - "allowing %v to startup", - chromeWarmupTime, - ) - time.Sleep(chromeWarmupTime) + time.Sleep(warmupTime) } // Compile-time checks to ensure type implements desired interfaces. var ( - _ = Process(new(chrome)) + _ = Process(new(chromeProcess)) ) diff --git a/internal/pkg/pm2/chrome_test.go b/internal/pkg/pm2/chrome_test.go deleted file mode 100644 index 2fea8458..00000000 --- a/internal/pkg/pm2/chrome_test.go +++ /dev/null @@ -1,20 +0,0 @@ -package pm2 - -import ( - "testing" - - "github.com/stretchr/testify/require" - "github.com/thecodingmachine/gotenberg/test" -) - -func TestChromeStart(t *testing.T) { - p := NewChrome(test.CreateTestLogger()) - err := p.Start() - require.Nil(t, err) -} - -func TestChromeShutdown(t *testing.T) { - p := NewChrome(test.CreateTestLogger()) - err := p.Shutdown() - require.Nil(t, err) -} diff --git a/internal/pkg/pm2/doc.go b/internal/pkg/pm2/doc.go index 879a203c..d64ac2a0 100644 --- a/internal/pkg/pm2/doc.go +++ b/internal/pkg/pm2/doc.go @@ -1,6 +1,6 @@ /* Package pm2 facilitates starting external -processes on which our API depends. +processes on which our application depends. For instance, it may start Google Chrome headless and unoconv listener with PM2. diff --git a/internal/pkg/pm2/pm2.go b/internal/pkg/pm2/pm2.go index 7f1113f0..6338ecb5 100644 --- a/internal/pkg/pm2/pm2.go +++ b/internal/pkg/pm2/pm2.go @@ -1,124 +1,102 @@ package pm2 import ( - "bufio" "fmt" - "io" - "os/exec" - "strings" - "github.com/thecodingmachine/gotenberg/internal/pkg/logger" - "github.com/thecodingmachine/gotenberg/internal/pkg/standarderror" -) - -const ( - stoppedState int32 = iota - runningState - errorState + "github.com/thecodingmachine/gotenberg/internal/pkg/xerror" + "github.com/thecodingmachine/gotenberg/internal/pkg/xexec" + "github.com/thecodingmachine/gotenberg/internal/pkg/xlog" ) // Process is a type that can start or -// shutdown a process with PM2. +// stop a process with PM2. type Process interface { Fullname() string Start() error - Shutdown() error + IsViable() bool + Stop() error args() []string - name() string - viable() bool + binary() string warmup() } -type processManager struct { - heuristicState int32 - logger *logger.Logger -} +type pm2Command string -func (m *processManager) start(p Process) error { - const op string = "pm2.start" - if err := m.pm2(p, "start"); err != nil { - return &standarderror.Error{Op: op, Err: err} - } - p.warmup() - if !p.viable() { - attempts := 0 - for attempts < 5 && !p.viable() { - if err := m.pm2(p, "restart"); err != nil { - m.heuristicState = errorState - return &standarderror.Error{Op: op, Err: err} - } - p.warmup() - attempts++ +const ( + startCommand pm2Command = "start" + restartCommand pm2Command = "restart" + stopCommand pm2Command = "stop" + logsCommand pm2Command = "logs" +) + +func start(logger xlog.Logger, process Process) error { + const ( + op string = "pm2.start" + maximumAttempts int = 3 + ) + resolver := func() error { + // first, we try to start the process. + if err := run(logger, startCommand, process); err != nil { + return err } - if !p.viable() { - m.heuristicState = errorState - return &standarderror.Error{ - Op: op, - Message: fmt.Sprintf("failed to launch %s", p.Fullname()), + // we wait the process to be ready. + process.warmup() + // if the process failed to start correctly, + // we have to restart it. + if !process.IsViable() { + attempts := 0 + for attempts < maximumAttempts && !process.IsViable() { + if err := run(logger, restartCommand, process); err != nil { + return err + } + process.warmup() + attempts++ + } + if !process.IsViable() { + return fmt.Errorf("failed to start '%s'", process.Fullname()) } } - } - m.heuristicState = runningState - return nil -} - -func (m *processManager) shutdown(p Process) error { - const op string = "pm2.shutdown" - if m.heuristicState != runningState { + // the process is viable, let's log its + // output. + if err := run(logger, logsCommand, process); err != nil { + return err + } return nil } - if err := m.pm2(p, "stop"); err != nil { - m.heuristicState = errorState - return &standarderror.Error{Op: op, Err: err} + if err := resolver(); err != nil { + return xerror.New(op, err) } - m.heuristicState = stoppedState return nil } -func (m *processManager) pm2(p Process, cmdName string) error { - const op string = "pm2.pm2" - cmdArgs := []string{ - cmdName, - p.name(), - } - if cmdName == "start" { - cmdArgs = append(cmdArgs, "--interpreter=none", "--") - cmdArgs = append(cmdArgs, p.args()...) - } - cmd := exec.Command( - "pm2", - cmdArgs..., - ) - m.logger.DebugfOp(op, "executing command: %s", strings.Join(cmd.Args, " ")) - processStdOut, err := cmd.StdoutPipe() - if err != nil { - return &standarderror.Error{Op: op, Err: err} - } - processStdErr, err := cmd.StderrPipe() - if err != nil { - return &standarderror.Error{Op: op, Err: err} - } - readFromPipe := func(outputType string, reader io.ReadCloser) { - readFromPipeOp := fmt.Sprintf("pm2.%s.%s", p.name(), outputType) - r := bufio.NewReader(reader) - defer reader.Close() // nolint: errcheck - for { - line, _, err := r.ReadLine() - if err != nil { - if err != io.EOF { - m.logger.ErrorOp(readFromPipeOp, err) - } - break - } - if len(line) != 0 { - m.logger.DebugfOp(readFromPipeOp, string(line)) - } - } - } - go readFromPipe("stdout", processStdOut) - go readFromPipe("stderr", processStdErr) - if err := cmd.Start(); err != nil { - return &standarderror.Error{Op: op, Err: err} +func stop(logger xlog.Logger, process Process) error { + const op string = "pm2.stop" + if err := run(logger, stopCommand, process); err != nil { + return xerror.New(op, err) + } + return nil +} + +func run(logger xlog.Logger, pm2Cmd pm2Command, process Process) error { + const op string = "pm2.run" + resolver := func() error { + args := []string{ + string(pm2Cmd), + process.binary(), + } + if pm2Cmd == startCommand { + args = append(args, "--interpreter=none", "--") + args = append(args, process.args()...) + } + cmd, err := xexec.Command(logger, "pm2", args...) + if err != nil { + return err + } + xexec.LogBeforeExecute(logger, cmd) + return cmd.Start() + } + if err := resolver(); err != nil { + return xerror.New(op, err) } return nil } diff --git a/internal/pkg/pm2/unoconv.go b/internal/pkg/pm2/unoconv.go index 6c67b3f6..05cd5b06 100644 --- a/internal/pkg/pm2/unoconv.go +++ b/internal/pkg/pm2/unoconv.go @@ -3,73 +3,75 @@ package pm2 import ( "time" - "github.com/thecodingmachine/gotenberg/internal/pkg/logger" - "github.com/thecodingmachine/gotenberg/internal/pkg/standarderror" + "github.com/thecodingmachine/gotenberg/internal/pkg/xerror" + "github.com/thecodingmachine/gotenberg/internal/pkg/xlog" ) -const unoconvWarmupTime time.Duration = 5 * time.Second - -type unoconv struct { - manager *processManager +type unoconvProcess struct { + logger xlog.Logger } -// NewUnoconv returns a unoconv listener +// NewUnoconvProcess returns a unoconv listener // process. -func NewUnoconv(logger *logger.Logger) Process { - return &unoconv{ - manager: &processManager{logger: logger}, +func NewUnoconvProcess(logger xlog.Logger) Process { + return unoconvProcess{ + logger: logger, } } -func (p *unoconv) Fullname() string { +func (p unoconvProcess) Fullname() string { return "unoconv listener" } -func (p *unoconv) Start() error { - const op string = "pm2.unoconv.Start" - if err := p.manager.start(p); err != nil { - return &standarderror.Error{Op: op, Err: err} +func (p unoconvProcess) Start() error { + const op string = "pm2.unoconvProcess.Start" + if err := start(p.logger, p); err != nil { + return xerror.New(op, err) } return nil } -func (p *unoconv) Shutdown() error { - const op string = "pm2.unoconv.Shutdown" - if err := p.manager.shutdown(p); err != nil { - return &standarderror.Error{Op: op, Err: err} - } - return nil -} - -func (p *unoconv) args() []string { - return []string{ - "--listener", - "--verbose", - } -} - -func (p *unoconv) name() string { - return "unoconv" -} - -func (p *unoconv) viable() bool { +func (p unoconvProcess) IsViable() bool { // TODO find a way to check if // the unoconv listener // is correctly started? return true } -func (p *unoconv) warmup() { - const op string = "pm2.unoconv.warmup" - p.manager.logger.DebugfOp( - op, - "allowing %v to startup", - unoconvWarmupTime, +func (p unoconvProcess) Stop() error { + const op string = "pm2.unoconvProcess.Stop" + if err := stop(p.logger, p); err != nil { + return xerror.New(op, err) + } + return nil +} + +func (p unoconvProcess) args() []string { + return []string{ + "--listener", + "--verbose", + } +} + +func (p unoconvProcess) binary() string { + return "unoconv" +} + +func (p unoconvProcess) warmup() { + const ( + op string = "pm2.unoconvProcess.warmup" + warmupTime time.Duration = 3 * time.Second ) - time.Sleep(unoconvWarmupTime) + p.logger.DebugfOp( + op, + "waiting '%v' for allowing '%s' to warmup", + warmupTime, + p.Fullname(), + ) + time.Sleep(warmupTime) } // Compile-time checks to ensure type implements desired interfaces. var ( - _ = Process(new(unoconv)) + _ = Process(new(unoconvProcess)) ) diff --git a/internal/pkg/pm2/unoconv_test.go b/internal/pkg/pm2/unoconv_test.go deleted file mode 100644 index 4c5fb8bf..00000000 --- a/internal/pkg/pm2/unoconv_test.go +++ /dev/null @@ -1,20 +0,0 @@ -package pm2 - -import ( - "testing" - - "github.com/stretchr/testify/require" - "github.com/thecodingmachine/gotenberg/test" -) - -func TestUnoconvStart(t *testing.T) { - p := NewUnoconv(test.CreateTestLogger()) - err := p.Start() - require.Nil(t, err) -} - -func TestUnoconvShutdown(t *testing.T) { - p := NewUnoconv(test.CreateTestLogger()) - err := p.Shutdown() - require.Nil(t, err) -} diff --git a/internal/pkg/printer/chrome.go b/internal/pkg/printer/chrome.go index 968030dd..8976fea6 100644 --- a/internal/pkg/printer/chrome.go +++ b/internal/pkg/printer/chrome.go @@ -12,19 +12,22 @@ import ( "github.com/mafredri/cdp/protocol/page" "github.com/mafredri/cdp/protocol/target" "github.com/mafredri/cdp/rpcc" - "github.com/thecodingmachine/gotenberg/internal/pkg/standarderror" - "github.com/thecodingmachine/gotenberg/internal/pkg/timeout" + "github.com/thecodingmachine/gotenberg/internal/pkg/xcontext" + "github.com/thecodingmachine/gotenberg/internal/pkg/xerror" + "github.com/thecodingmachine/gotenberg/internal/pkg/xlog" + "github.com/thecodingmachine/gotenberg/internal/pkg/xtime" "golang.org/x/sync/errgroup" ) -type chrome struct { - url string - opts *ChromeOptions +type chromePrinter struct { + logger xlog.Logger + url string + opts ChromePrinterOptions } -// ChromeOptions helps customizing the +// ChromePrinterOptions helps customizing the // Google Chrome printer behaviour. -type ChromeOptions struct { +type ChromePrinterOptions struct { WaitTimeout float64 WaitDelay float64 HeaderHTML string @@ -38,26 +41,27 @@ type ChromeOptions struct { Landscape bool } -func (p *chrome) Print(destination string) error { - const op string = "printer.chrome.Print" - ctx, cancel := timeout.Context(p.opts.WaitTimeout + p.opts.WaitDelay) +func (p chromePrinter) Print(destination string) error { + const op string = "printer.chromePrinter.Print" + logOptions(p.logger, p.opts) + ctx, cancel := xcontext.WithTimeout(p.logger, p.opts.WaitTimeout+p.opts.WaitDelay) defer cancel() resolver := func() error { devt, err := devtool.New("http://localhost:9222").Version(ctx) if err != nil { - return &standarderror.Error{Op: op, Err: err} + return err } // connect to WebSocket URL (page) that speaks the Chrome DevTools Protocol. devtConn, err := rpcc.DialContext(ctx, devt.WebSocketDebuggerURL) if err != nil { - return &standarderror.Error{Op: op, Err: err} + return err } defer devtConn.Close() // nolint: errcheck // create a new CDP Client that uses conn. devtClient := cdp.NewClient(devtConn) newContextTarget, err := devtClient.Target.CreateBrowserContext(ctx) if err != nil { - return &standarderror.Error{Op: op, Err: err} + return err } // create a new blank target with the new browser context. createTargetArgs := target. @@ -65,13 +69,13 @@ func (p *chrome) Print(destination string) error { SetBrowserContextID(newContextTarget.BrowserContextID) newTarget, err := devtClient.Target.CreateTarget(ctx, createTargetArgs) if err != nil { - return &standarderror.Error{Op: op, Err: err} + return err } // connect the client to the new target. newTargetWsURL := fmt.Sprintf("ws://127.0.0.1:9222/devtools/page/%s", newTarget.TargetID) newContextConn, err := rpcc.DialContext(ctx, newTargetWsURL) if err != nil { - return &standarderror.Error{Op: op, Err: err} + return err } defer newContextConn.Close() // nolint: errcheck // create a new CDP Client that uses newContextConn. @@ -86,10 +90,10 @@ func (p *chrome) Print(destination string) error { func() error { return targetClient.Page.Enable(ctx) }, func() error { return targetClient.Runtime.Enable(ctx) }, ); err != nil { - return &standarderror.Error{Op: op, Err: err} + return err } if err := p.navigate(ctx, targetClient); err != nil { - return &standarderror.Error{Op: op, Err: err} + return err } print, err := targetClient.Page.PrintToPDF( ctx, @@ -107,58 +111,67 @@ func (p *chrome) Print(destination string) error { SetPrintBackground(true), ) if err != nil { - return &standarderror.Error{Op: op, Err: err} + return err } if err := ioutil.WriteFile(destination, print.Data, 0644); err != nil { - return &standarderror.Error{Op: op, Err: err} + return err } return nil } if err := resolver(); err != nil { - return timeout.Err(ctx, err) + return xcontext.MustHandleError( + ctx, + xerror.New(op, err), + ) } return nil } -func (p *chrome) navigate(ctx context.Context, client *cdp.Client) error { - const op string = "printer.chrome.navigate" - // make sure Page events are enabled. - if err := client.Page.Enable(ctx); err != nil { - return &standarderror.Error{Op: op, Err: err} +func (p chromePrinter) navigate(ctx context.Context, client *cdp.Client) error { + const op string = "printer.chromePrinter.navigate" + resolver := func() error { + // make sure Page events are enabled. + if err := client.Page.Enable(ctx); err != nil { + return err + } + // make sure Network events are enabled. + if err := client.Network.Enable(ctx, nil); err != nil { + return err + } + // create all clients for events. + domContentEventFired, err := client.Page.DOMContentEventFired(ctx) + if err != nil { + return err + } + defer domContentEventFired.Close() // nolint: errcheck + loadEventFired, err := client.Page.LoadEventFired(ctx) + if err != nil { + return err + } + defer loadEventFired.Close() // nolint: errcheck + loadingFinished, err := client.Network.LoadingFinished(ctx) + if err != nil { + return err + } + defer loadingFinished.Close() // nolint: errcheck + if _, err := client.Page.Navigate(ctx, page.NewNavigateArgs(p.url)); err != nil { + return err + } + if err := runBatch( + // wait for all events. + func() error { _, err := domContentEventFired.Recv(); return err }, + func() error { _, err := loadEventFired.Recv(); return err }, + func() error { _, err := loadingFinished.Recv(); return err }, + ); err != nil { + return err + } + // wait for a given amount of time (useful for javascript delay). + time.Sleep(xtime.Duration(p.opts.WaitDelay)) + return nil } - // make sure Network events are enabled. - if err := client.Network.Enable(ctx, nil); err != nil { - return &standarderror.Error{Op: op, Err: err} + if err := resolver(); err != nil { + return xerror.New(op, err) } - // create all clients for events. - domContentEventFired, err := client.Page.DOMContentEventFired(ctx) - if err != nil { - return &standarderror.Error{Op: op, Err: err} - } - defer domContentEventFired.Close() // nolint: errcheck - loadEventFired, err := client.Page.LoadEventFired(ctx) - if err != nil { - return &standarderror.Error{Op: op, Err: err} - } - defer loadEventFired.Close() // nolint: errcheck - loadingFinished, err := client.Network.LoadingFinished(ctx) - if err != nil { - return &standarderror.Error{Op: op, Err: err} - } - defer loadingFinished.Close() // nolint: errcheck - if _, err := client.Page.Navigate(ctx, page.NewNavigateArgs(p.url)); err != nil { - return &standarderror.Error{Op: op, Err: err} - } - if err := runBatch( - // wait for all events. - func() error { _, err := domContentEventFired.Recv(); return err }, - func() error { _, err := loadEventFired.Recv(); return err }, - func() error { _, err := loadingFinished.Recv(); return err }, - ); err != nil { - return &standarderror.Error{Op: op, Err: err} - } - // wait for a given amount of time (useful for javascript delay). - time.Sleep(timeout.Duration(p.opts.WaitDelay)) return nil } @@ -174,5 +187,5 @@ func runBatch(fn ...func() error) error { // Compile-time checks to ensure type implements desired interfaces. var ( - _ = Printer(new(chrome)) + _ = Printer(new(chromePrinter)) ) diff --git a/internal/pkg/printer/doc.go b/internal/pkg/printer/doc.go index 4b2ce259..d36329e3 100644 --- a/internal/pkg/printer/doc.go +++ b/internal/pkg/printer/doc.go @@ -1,5 +1,3 @@ -/* -Package printer contains structs which convert -a specific file type to PDF. -*/ +// Package printer helps converting +// a specific file type to PDF. package printer diff --git a/internal/pkg/printer/html.go b/internal/pkg/printer/html.go index 026c0f24..edad5ebf 100644 --- a/internal/pkg/printer/html.go +++ b/internal/pkg/printer/html.go @@ -2,13 +2,17 @@ package printer import ( "fmt" + + "github.com/thecodingmachine/gotenberg/internal/pkg/xlog" ) -// NewHTML returns an HTML printer. -func NewHTML(fpath string, opts *ChromeOptions) Printer { +// NewHTMLPrinter returns a Printer which +// is able to convert an HTML file to PDF. +func NewHTMLPrinter(logger xlog.Logger, fpath string, opts ChromePrinterOptions) Printer { URL := fmt.Sprintf("file://%s", fpath) - return &chrome{ - url: URL, - opts: opts, + return chromePrinter{ + logger: logger, + url: URL, + opts: opts, } } diff --git a/internal/pkg/printer/markdown.go b/internal/pkg/printer/markdown.go index 199ebeea..2332db4d 100644 --- a/internal/pkg/printer/markdown.go +++ b/internal/pkg/printer/markdown.go @@ -7,37 +7,46 @@ import ( "io/ioutil" "path/filepath" - "github.com/labstack/gommon/random" "github.com/microcosm-cc/bluemonday" "github.com/russross/blackfriday/v2" - "github.com/thecodingmachine/gotenberg/internal/pkg/standarderror" + "github.com/thecodingmachine/gotenberg/internal/pkg/xerror" + "github.com/thecodingmachine/gotenberg/internal/pkg/xlog" + "github.com/thecodingmachine/gotenberg/internal/pkg/xrand" ) -// NewMarkdown returns a Markdown printer. -func NewMarkdown(fpath string, opts *ChromeOptions) (Printer, error) { - const op string = "printer.NewMarkdown" - tmpl, err := template. - New(filepath.Base(fpath)). - Funcs(template.FuncMap{"toHTML": markdownToHTML}). - ParseFiles(fpath) +// NewMarkdownPrinter returns a Printer which +// is able to convert Markdown files to PDF. +func NewMarkdownPrinter(logger xlog.Logger, fpath string, opts ChromePrinterOptions) (Printer, error) { + const op string = "printer.NewMarkdownPrinter" + resolver := func() (string, error) { + tmpl, err := template. + New(filepath.Base(fpath)). + Funcs(template.FuncMap{"toHTML": markdownToHTML}). + ParseFiles(fpath) + if err != nil { + return "", err + } + dirPath := filepath.Dir(fpath) + data := &templateData{DirPath: dirPath} + var buffer bytes.Buffer + if err := tmpl.Execute(&buffer, data); err != nil { + return "", err + } + baseFilename := xrand.Get() + dst := fmt.Sprintf("%s/%s.html", dirPath, baseFilename) + if err := ioutil.WriteFile(dst, buffer.Bytes(), 0644); err != nil { + return "", err + } + return fmt.Sprintf("file://%s", dst), nil + } + URL, err := resolver() if err != nil { - return nil, &standarderror.Error{Op: op, Err: err} + return chromePrinter{}, xerror.New(op, err) } - dirPath := filepath.Dir(fpath) - data := &templateData{DirPath: dirPath} - var buffer bytes.Buffer - if err := tmpl.Execute(&buffer, data); err != nil { - return nil, &standarderror.Error{Op: op, Err: err} - } - baseFilename := random.String(32) - dst := fmt.Sprintf("%s/%s.html", dirPath, baseFilename) - if err := ioutil.WriteFile(dst, buffer.Bytes(), 0644); err != nil { - return nil, &standarderror.Error{Op: op, Err: err} - } - URL := fmt.Sprintf("file://%s", dst) - return &chrome{ - url: URL, - opts: opts, + return chromePrinter{ + logger: logger, + url: URL, + opts: opts, }, nil } @@ -50,7 +59,7 @@ func markdownToHTML(dirPath, filename string) (template.HTML, error) { fpath := fmt.Sprintf("%s/%s", dirPath, filename) b, err := ioutil.ReadFile(fpath) if err != nil { - return "", &standarderror.Error{Op: op, Err: err} + return "", xerror.New(op, err) } unsafe := blackfriday.Run(b) content := bluemonday.UGCPolicy().SanitizeBytes(unsafe) diff --git a/internal/pkg/printer/merge.go b/internal/pkg/printer/merge.go index 4bf5e2e5..fd350f38 100644 --- a/internal/pkg/printer/merge.go +++ b/internal/pkg/printer/merge.go @@ -2,57 +2,71 @@ package printer import ( "context" - "os/exec" - "github.com/thecodingmachine/gotenberg/internal/pkg/standarderror" - "github.com/thecodingmachine/gotenberg/internal/pkg/timeout" + "github.com/thecodingmachine/gotenberg/internal/pkg/xcontext" + "github.com/thecodingmachine/gotenberg/internal/pkg/xerror" + "github.com/thecodingmachine/gotenberg/internal/pkg/xexec" + "github.com/thecodingmachine/gotenberg/internal/pkg/xlog" ) -type merge struct { +type mergePrinter struct { ctx context.Context + logger xlog.Logger fpaths []string - opts *MergeOptions + opts MergePrinterOptions } -// MergeOptions helps customizing the -// merge printer behaviour. -type MergeOptions struct { +// MergePrinterOptions helps customizing the +// merge Printer behaviour. +type MergePrinterOptions struct { WaitTimeout float64 } -// NewMerge returns a merge printer. -func NewMerge(fpaths []string, opts *MergeOptions) Printer { - return &merge{ +// NewMergePrinter returns a Printer which +// is able to merge PDFs. +func NewMergePrinter(logger xlog.Logger, fpaths []string, opts MergePrinterOptions) Printer { + return mergePrinter{ + logger: logger, fpaths: fpaths, opts: opts, } } -func (p *merge) Print(destination string) error { - const op string = "printer.merge.Print" +func (p mergePrinter) Print(destination string) error { + const op string = "printer.mergePrinter.Print" + logOptions(p.logger, p.opts) + /* + context.Context may be providen from + an officePrinter which needs to merge + its result files. + */ if p.ctx == nil { - ctx, cancel := timeout.Context(p.opts.WaitTimeout) + ctx, cancel := xcontext.WithTimeout(p.logger, p.opts.WaitTimeout) defer cancel() p.ctx = ctx } + p.logger.DebugfOp(op, "merging '%v'...", p.fpaths) resolver := func() error { - var cmdArgs []string - cmdArgs = append(cmdArgs, p.fpaths...) - cmdArgs = append(cmdArgs, "cat", "output", destination) - cmd := exec.CommandContext(p.ctx, "pdftk", cmdArgs...) - _, err := cmd.Output() + var args []string + args = append(args, p.fpaths...) + args = append(args, "cat", "output", destination) + cmd, err := xexec.CommandContext(p.ctx, p.logger, "pdftk", args...) if err != nil { - return &standarderror.Error{Op: op, Err: err} + return err } - return nil + xexec.LogBeforeExecute(p.logger, cmd) + return cmd.Run() } if err := resolver(); err != nil { - return timeout.Err(p.ctx, err) + return xcontext.MustHandleError( + p.ctx, + xerror.New(op, err), + ) } return nil } // Compile-time checks to ensure type implements desired interfaces. var ( - _ = Printer(new(merge)) + _ = Printer(new(mergePrinter)) ) diff --git a/internal/pkg/printer/office.go b/internal/pkg/printer/office.go index d7a9520f..9ed66c0e 100644 --- a/internal/pkg/printer/office.go +++ b/internal/pkg/printer/office.go @@ -4,67 +4,75 @@ import ( "context" "fmt" "os" - "os/exec" "path/filepath" "sync" - "github.com/labstack/gommon/random" - "github.com/thecodingmachine/gotenberg/internal/pkg/standarderror" - "github.com/thecodingmachine/gotenberg/internal/pkg/timeout" + "github.com/thecodingmachine/gotenberg/internal/pkg/xcontext" + "github.com/thecodingmachine/gotenberg/internal/pkg/xerror" + "github.com/thecodingmachine/gotenberg/internal/pkg/xexec" + "github.com/thecodingmachine/gotenberg/internal/pkg/xlog" + "github.com/thecodingmachine/gotenberg/internal/pkg/xrand" ) -type office struct { +type officePrinter struct { + logger xlog.Logger fpaths []string - opts *OfficeOptions + opts OfficePrinterOptions } -// OfficeOptions helps customizing the -// Office printer behaviour. -type OfficeOptions struct { +// OfficePrinterOptions helps customizing the +// Office Printer behaviour. +type OfficePrinterOptions struct { WaitTimeout float64 Landscape bool } -// NewOffice returns an Office printer. -func NewOffice(fpaths []string, opts *OfficeOptions) Printer { - return &office{ +// NewOfficePrinter returns a Printer which +// is able to convert Office documents to PDF. +func NewOfficePrinter(logger xlog.Logger, fpaths []string, opts OfficePrinterOptions) Printer { + return officePrinter{ + logger: logger, fpaths: fpaths, opts: opts, } } -func (p *office) Print(destination string) error { - const op string = "printer.office.Print" - ctx, cancel := timeout.Context(p.opts.WaitTimeout) +func (p officePrinter) Print(destination string) error { + const op string = "printer.officePrinter.Print" + logOptions(p.logger, p.opts) + ctx, cancel := xcontext.WithTimeout(p.logger, p.opts.WaitTimeout) defer cancel() - fpaths := make([]string, len(p.fpaths)) resolver := func() error { + fpaths := make([]string, len(p.fpaths)) dirPath := filepath.Dir(destination) for i, fpath := range p.fpaths { - baseFilename := random.String(32) + baseFilename := xrand.Get() tmpDest := fmt.Sprintf("%s/%d%s.pdf", dirPath, i, baseFilename) - if err := unoconv(ctx, fpath, tmpDest, p.opts); err != nil { - return &standarderror.Error{Op: op, Err: err} + p.logger.DebugfOp(op, "converting '%s' to PDF...", fpath) + if err := unoconv(ctx, p.logger, fpath, tmpDest, p.opts); err != nil { + return err } + p.logger.DebugfOp(op, "'%s.pdf' created", baseFilename) fpaths[i] = tmpDest } - return nil + if len(fpaths) == 1 { + p.logger.DebugOp(op, "only one PDF created, nothing to merge") + if err := os.Rename(fpaths[0], destination); err != nil { + return err + } + return nil + } + m := mergePrinter{ + ctx: ctx, + fpaths: fpaths, + } + return m.Print(destination) } if err := resolver(); err != nil { - return timeout.Err(ctx, err) - } - if len(fpaths) == 1 { - if err := os.Rename(fpaths[0], destination); err != nil { - return &standarderror.Error{Op: op, Err: err} - } - return nil - } - m := &merge{ - ctx: ctx, - fpaths: fpaths, - } - if err := m.Print(destination); err != nil { - return &standarderror.Error{Op: op, Err: err} + return xcontext.MustHandleError( + ctx, + xerror.New(op, err), + ) } return nil } @@ -72,31 +80,41 @@ func (p *office) Print(destination string) error { // nolint: gochecknoglobals var mu sync.Mutex -func unoconv(ctx context.Context, fpath, destination string, opts *OfficeOptions) error { +func unoconv(ctx context.Context, logger xlog.Logger, fpath, destination string, opts OfficePrinterOptions) error { const op string = "printer.unoconv" + // TODO check if timeout while waiting for the lock. + logger.DebugOp(op, "waiting lock to be released...") mu.Lock() defer mu.Unlock() - cmdArgs := []string{ - "--format", - "pdf", + logger.DebugOp(op, "lock released") + resolver := func() error { + args := []string{ + "--format", + "pdf", + } + if opts.Landscape { + args = append(args, "--printer", "PaperOrientation=landscape") + } + args = append(args, "--output", destination, fpath) + cmd, err := xexec.CommandContext( + ctx, + logger, + "unoconv", + args..., + ) + if err != nil { + return err + } + xexec.LogBeforeExecute(logger, cmd) + return cmd.Run() } - if opts.Landscape { - cmdArgs = append(cmdArgs, "--printer", "PaperOrientation=landscape") - } - cmdArgs = append(cmdArgs, "--output", destination, fpath) - cmd := exec.CommandContext( - ctx, - "unoconv", - cmdArgs..., - ) - _, err := cmd.Output() - if err != nil { - return &standarderror.Error{Op: op, Err: err} + if err := resolver(); err != nil { + return xerror.New(op, err) } return nil } // Compile-time checks to ensure type implements desired interfaces. var ( - _ = Printer(new(office)) + _ = Printer(new(officePrinter)) ) diff --git a/internal/pkg/printer/printer.go b/internal/pkg/printer/printer.go index e1e72497..de800bdf 100644 --- a/internal/pkg/printer/printer.go +++ b/internal/pkg/printer/printer.go @@ -1,7 +1,16 @@ package printer +import ( + "github.com/thecodingmachine/gotenberg/internal/pkg/xlog" +) + // Printer is a type that can create a PDF file from a source. // The source is defined in the underlying implementation. type Printer interface { Print(destination string) error } + +func logOptions(logger xlog.Logger, opts interface{}) { + const op string = "printer.logOptions" + logger.DebugfOp(op, "options: %+v", opts) +} diff --git a/internal/pkg/printer/url.go b/internal/pkg/printer/url.go index c31a92c0..191fb93b 100644 --- a/internal/pkg/printer/url.go +++ b/internal/pkg/printer/url.go @@ -1,9 +1,15 @@ package printer -// NewURL returns a URL printer. -func NewURL(url string, opts *ChromeOptions) Printer { - return &chrome{ - url: url, - opts: opts, +import ( + "github.com/thecodingmachine/gotenberg/internal/pkg/xlog" +) + +// NewURLPrinter returns a Printer which +// is able to convert a URL to PDF. +func NewURLPrinter(logger xlog.Logger, url string, opts ChromePrinterOptions) Printer { + return chromePrinter{ + logger: logger, + url: url, + opts: opts, } } diff --git a/internal/pkg/random/doc.go b/internal/pkg/random/doc.go deleted file mode 100644 index d79456c1..00000000 --- a/internal/pkg/random/doc.go +++ /dev/null @@ -1,3 +0,0 @@ -// Package random helps generating -// a random string. -package random diff --git a/internal/pkg/standarderror/doc.go b/internal/pkg/standarderror/doc.go deleted file mode 100644 index b932abe3..00000000 --- a/internal/pkg/standarderror/doc.go +++ /dev/null @@ -1,7 +0,0 @@ -/* -Package standarderror helps standardizing -the errors in the application. - -Credits: https://middlemost.com/failure-is-your-domain/ -*/ -package standarderror diff --git a/internal/pkg/standarderror/standarderror.go b/internal/pkg/standarderror/standarderror.go deleted file mode 100644 index d39cc2f0..00000000 --- a/internal/pkg/standarderror/standarderror.go +++ /dev/null @@ -1,108 +0,0 @@ -package standarderror - -import ( - "bytes" - "fmt" -) - -const ( - // Internal is a code - // for internal errors. - Internal = "internal" - // Invalid is a code - // for validation errors. - Invalid = "invalid" - // Timeout is a code - // for timeout errors. - Timeout = "timeout" -) - -// Error defines a standard application -// error. -type Error struct { - // Code is a machine-readable - // error code. - Code string - // Message is a human-readable - // message. - Message string - // Op is a logical operation. - Op string - // Err is a nested error. - Err error -} - -// Error returns the string representation of the error message. -func (err *Error) Error() string { - var buf bytes.Buffer - // if wrapping an error, print its Error() message. - // Otherwise print the error code & message. - if err.Err != nil { - buf.WriteString(err.Err.Error()) - } else { - if err.Code != "" { - fmt.Fprintf(&buf, "<%s> ", err.Code) - } - buf.WriteString(err.Message) - } - return buf.String() -} - -// Code returns the code of the root error, if available. -// Otherwise returns Internal. -func Code(err error) string { - if err == nil { - return "" - } - e, ok := err.(*Error) - if ok && e.Code != "" { - return e.Code - } - if ok && e.Err != nil { - return Code(e.Err) - } - return Internal -} - -const defaultMessage string = "an internal error has occurred: please contact technical support" - -// Message returns the human-readable message of the error, if available. -// Otherwise returns a generic error message. -func Message(err error) string { - if err == nil { - return "" - } - e, ok := err.(*Error) - if ok && e.Message != "" { - return e.Message - } - if ok && e.Err != nil { - return Message(e.Err) - } - return defaultMessage -} - -// Op returns the logical operation of the error, if available. -// Otherwise returns an empty string. -func Op(err error) string { - if err == nil { - return "" - } - e, ok := err.(*Error) - if !ok { - return "" - } - var buf bytes.Buffer - if e.Op != "" { - fmt.Fprintf(&buf, "%s", e.Op) - } - if nestedOp := Op(e.Err); nestedOp != "" { - fmt.Fprintf(&buf, ": %s", nestedOp) - } - return buf.String() -} - -// Compile-time checks to ensure type implements desired interfaces. -var ( - _ = error(new(Error)) -) diff --git a/internal/pkg/standarderror/standarderror_test.go b/internal/pkg/standarderror/standarderror_test.go deleted file mode 100644 index eecd5631..00000000 --- a/internal/pkg/standarderror/standarderror_test.go +++ /dev/null @@ -1,70 +0,0 @@ -package standarderror - -import ( - "errors" - "testing" - - "github.com/stretchr/testify/assert" -) - -func scenario1() error { - rootErr := errors.New("root error") - nestedErr := &Error{ - Code: Invalid, - Op: "bar", - Message: "nested error", - Err: rootErr, - } - err := &Error{ - Op: "foo", - Err: nestedErr, - } - return err -} - -func scenario2() error { - nestedErr := &Error{ - Code: Invalid, - Op: "bar", - Message: "nested error", - } - err := &Error{ - Code: Internal, - Op: "foo", - Err: nestedErr, - } - return err -} - -func TestError(t *testing.T) { - err := scenario1() - assert.Equal(t, "root error", err.Error()) - err = scenario2() - assert.Equal(t, " nested error", err.Error()) -} - -func TestCode(t *testing.T) { - assert.Equal(t, "", Code(nil)) - err := scenario1() - assert.Equal(t, Invalid, Code(err)) - err = scenario2() - assert.Equal(t, Internal, Code(err)) - err = errors.New("some error") - assert.Equal(t, Internal, Code(err)) -} - -func TestMessage(t *testing.T) { - assert.Equal(t, "", Message(nil)) - err := scenario1() - assert.Equal(t, "nested error", Message(err)) - err = errors.New("some error") - assert.Equal(t, defaultMessage, Message(err)) -} - -func TestOp(t *testing.T) { - assert.Equal(t, "", Op(nil)) - err := scenario1() - assert.Equal(t, "foo: bar", Op(err)) - err = errors.New("some error") - assert.Equal(t, "", Op(err)) -} diff --git a/internal/pkg/timeout/doc.go b/internal/pkg/timeout/doc.go deleted file mode 100644 index e8c95004..00000000 --- a/internal/pkg/timeout/doc.go +++ /dev/null @@ -1,3 +0,0 @@ -// Package timeout helps managing -// context with timeout. -package timeout diff --git a/internal/pkg/timeout/timeout.go b/internal/pkg/timeout/timeout.go deleted file mode 100644 index 37a61325..00000000 --- a/internal/pkg/timeout/timeout.go +++ /dev/null @@ -1,47 +0,0 @@ -package timeout - -import ( - "context" - "fmt" - "strings" - "time" - - "github.com/thecodingmachine/gotenberg/internal/pkg/standarderror" -) - -// Context creates a context with timeout for -// given second. -func Context(seconds float64) (context.Context, context.CancelFunc) { - return context.WithTimeout(context.Background(), Duration(seconds)) -} - -// Duration creates a duration from seconds. -func Duration(seconds float64) time.Duration { - return time.Duration(1000*seconds) * time.Millisecond -} - -// Err checks if there is an error in the given context -// and wraps the previous error inside a standarderror.Error. -func Err(ctx context.Context, previousErr error) error { - const op string = "timeout.Err" - if previousErr == nil { - panic(fmt.Sprintf("%s: previous error should not be nil", op)) - } - err := ctx.Err() - if err == nil { - return previousErr - } - if strings.Contains(err.Error(), context.DeadlineExceeded.Error()) { - return &standarderror.Error{ - Code: standarderror.Timeout, - Message: "context has timed out", - Op: op, - Err: previousErr, - } - } - return &standarderror.Error{ - Message: "context finished with an error", - Op: op, - Err: previousErr, - } -} diff --git a/internal/pkg/timeout/timeout_test.go b/internal/pkg/timeout/timeout_test.go deleted file mode 100644 index 3ff7f724..00000000 --- a/internal/pkg/timeout/timeout_test.go +++ /dev/null @@ -1,40 +0,0 @@ -package timeout - -import ( - "errors" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/thecodingmachine/gotenberg/internal/pkg/standarderror" - "github.com/thecodingmachine/gotenberg/test" -) - -func TestDuration(t *testing.T) { - expected := time.Duration(1500) * time.Millisecond - result := Duration(1.5) - assert.Equal(t, expected.String(), result.String()) -} - -func TestErr(t *testing.T) { - previousErr := errors.New("previous error") - // should be OK. - ctx, cancel := Context(5) - defer cancel() - assert.NotNil(t, Err(ctx, previousErr)) - // should timeout. - ctx, cancel = Context(0.5) - defer cancel() - time.Sleep(Duration(1)) - err := Err(ctx, previousErr) - assert.NotNil(t, err) - standardized := test.RequireStandardError(t, err) - assert.Equal(t, standarderror.Timeout, standardized.Code) - // should failed. - ctx, cancel = Context(5) - cancel() - err = Err(ctx, previousErr) - assert.NotNil(t, err) - standardized = test.RequireStandardError(t, err) - assert.Equal(t, standarderror.Internal, standarderror.Code(err)) -} diff --git a/internal/pkg/xassert/doc.go b/internal/pkg/xassert/doc.go new file mode 100644 index 00000000..14b2417e --- /dev/null +++ b/internal/pkg/xassert/doc.go @@ -0,0 +1,8 @@ +/* +Package xassert is a helper for converting +and/or validating strings. + +All functions return our standard xerror.Error +in case of error. +*/ +package xassert diff --git a/internal/pkg/xassert/float64.go b/internal/pkg/xassert/float64.go new file mode 100644 index 00000000..e8521b7e --- /dev/null +++ b/internal/pkg/xassert/float64.go @@ -0,0 +1,88 @@ +package xassert + +import ( + "fmt" + + "github.com/thecodingmachine/gotenberg/internal/pkg/xerror" +) + +// RuleFloat64 is an interface for +// validating a float64. +type RuleFloat64 interface { + with(key string, value float64) + validate() error +} + +type baseRuleFloat64 struct { + key string + value float64 +} + +func (r *baseRuleFloat64) with(key string, value float64) { + r.key = key + r.value = value +} + +type ruleFloat64NotInferiorTo struct { + *baseRuleFloat64 + lowerBound float64 +} + +func (r ruleFloat64NotInferiorTo) validate() error { + const op string = "xassert.ruleFloat64NotInferiorTo.validate" + if r.value < r.lowerBound { + return xerror.Invalid( + op, + fmt.Sprintf("'%s' should be > '%f', got '%f'", r.key, r.lowerBound, r.value), + nil, + ) + } + return nil +} + +/* +Float64NotInferiorTo returns a RuleFloat64 for +validating that a float64 is not inferior to +given lower bound. +*/ +func Float64NotInferiorTo(lowerBound float64) RuleFloat64 { + return ruleFloat64NotInferiorTo{ + &baseRuleFloat64{}, + lowerBound, + } +} + +type ruleFloat64NotSuperiorTo struct { + *baseRuleFloat64 + upperBound float64 +} + +func (r ruleFloat64NotSuperiorTo) validate() error { + const op string = "xassert.ruleFloat64NotSuperiorTo.validate" + if r.value > r.upperBound { + return xerror.Invalid( + op, + fmt.Sprintf("'%s' should be < '%f', got '%f'", r.key, r.upperBound, r.value), + nil, + ) + } + return nil +} + +/* +Float64NotSuperiorTo returns a RuleFloat64 for +validating that a float64 is not superior to +given upper bound. +*/ +func Float64NotSuperiorTo(upperBound float64) RuleFloat64 { + return ruleFloat64NotSuperiorTo{ + &baseRuleFloat64{}, + upperBound, + } +} + +// Compile-time checks to ensure type implements desired interfaces. +var ( + _ = RuleFloat64(new(ruleFloat64NotInferiorTo)) + _ = RuleFloat64(new(ruleFloat64NotSuperiorTo)) +) diff --git a/internal/pkg/xassert/float64_test.go b/internal/pkg/xassert/float64_test.go new file mode 100644 index 00000000..c43124d1 --- /dev/null +++ b/internal/pkg/xassert/float64_test.go @@ -0,0 +1,32 @@ +package xassert + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/thecodingmachine/gotenberg/test/internalpkg/xerrortest" +) + +func TestFloat64NotInferiorTo(t *testing.T) { + rule := Float64NotInferiorTo(0.0) + // should be OK. + rule.with("FOO", 10.0) + err := rule.validate() + assert.Nil(t, err) + // should not be OK. + rule.with("FOO", -10.0) + err = rule.validate() + xerrortest.AssertError(t, err) +} + +func TestFloat64NotSuperiorTo(t *testing.T) { + rule := Float64NotSuperiorTo(0.0) + // should be OK. + rule.with("FOO", -10.0) + err := rule.validate() + assert.Nil(t, err) + // should not be OK. + rule.with("FOO", 10.0) + err = rule.validate() + xerrortest.AssertError(t, err) +} diff --git a/internal/pkg/xassert/int64.go b/internal/pkg/xassert/int64.go new file mode 100644 index 00000000..c3fc3ae7 --- /dev/null +++ b/internal/pkg/xassert/int64.go @@ -0,0 +1,88 @@ +package xassert + +import ( + "fmt" + + "github.com/thecodingmachine/gotenberg/internal/pkg/xerror" +) + +// RuleInt64 is an interface for +// validating an int64. +type RuleInt64 interface { + with(key string, value int64) + validate() error +} + +type baseRuleInt64 struct { + key string + value int64 +} + +func (r *baseRuleInt64) with(key string, value int64) { + r.key = key + r.value = value +} + +type ruleInt64NotInferiorTo struct { + *baseRuleInt64 + lowerBound int64 +} + +func (r ruleInt64NotInferiorTo) validate() error { + const op string = "xassert.ruleInt64NotInferiorTo.validate" + if r.value < r.lowerBound { + return xerror.Invalid( + op, + fmt.Sprintf("'%s' should be > '%d', got '%d'", r.key, r.lowerBound, r.value), + nil, + ) + } + return nil +} + +/* +Int64NotInferiorTo returns a RuleInt64 for +validating that an int64 is not inferior to +given lower bound. +*/ +func Int64NotInferiorTo(lowerBound int64) RuleInt64 { + return &ruleInt64NotInferiorTo{ + &baseRuleInt64{}, + lowerBound, + } +} + +type ruleInt64NotSuperiorTo struct { + *baseRuleInt64 + upperBound int64 +} + +func (r ruleInt64NotSuperiorTo) validate() error { + const op string = "xassert.ruleInt64NotSuperiorTo.validate" + if r.value > r.upperBound { + return xerror.Invalid( + op, + fmt.Sprintf("'%s' should be < '%d', got '%d'", r.key, r.upperBound, r.value), + nil, + ) + } + return nil +} + +/* +Int64NotSuperiorTo returns a RuleInt64 for +validating that an int64 is not superior to +given upper bound. +*/ +func Int64NotSuperiorTo(upperBound int64) RuleInt64 { + return ruleInt64NotSuperiorTo{ + &baseRuleInt64{}, + upperBound, + } +} + +// Compile-time checks to ensure type implements desired interfaces. +var ( + _ = RuleInt64(new(ruleInt64NotInferiorTo)) + _ = RuleInt64(new(ruleInt64NotSuperiorTo)) +) diff --git a/internal/pkg/xassert/int64_test.go b/internal/pkg/xassert/int64_test.go new file mode 100644 index 00000000..96d12bc2 --- /dev/null +++ b/internal/pkg/xassert/int64_test.go @@ -0,0 +1,32 @@ +package xassert + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/thecodingmachine/gotenberg/test/internalpkg/xerrortest" +) + +func TestInt64NotInferiorTo(t *testing.T) { + rule := Int64NotInferiorTo(0) + // should be OK. + rule.with("FOO", 10) + err := rule.validate() + assert.Nil(t, err) + // should not be OK. + rule.with("FOO", -10) + err = rule.validate() + xerrortest.AssertError(t, err) +} + +func TestInt64NotSuperiorTo(t *testing.T) { + rule := Int64NotSuperiorTo(0) + // should be OK. + rule.with("FOO", -10) + err := rule.validate() + assert.Nil(t, err) + // should not be OK. + rule.with("FOO", 10) + err = rule.validate() + xerrortest.AssertError(t, err) +} diff --git a/internal/pkg/xassert/string.go b/internal/pkg/xassert/string.go new file mode 100644 index 00000000..540eee9b --- /dev/null +++ b/internal/pkg/xassert/string.go @@ -0,0 +1,60 @@ +package xassert + +import ( + "fmt" + + "github.com/thecodingmachine/gotenberg/internal/pkg/xerror" +) + +// RuleString is an interface for +// validating a string. +type RuleString interface { + with(key, value string) + validate() error +} + +type baseRuleString struct { + key string + value string +} + +func (r *baseRuleString) with(key, value string) { + r.key = key + r.value = value +} + +type ruleStringOneOf struct { + *baseRuleString + values []string +} + +func (r ruleStringOneOf) validate() error { + const op string = "xassert.ruleStringOneOf.validate" + for _, v := range r.values { + if r.value == v { + return nil + } + } + return xerror.Invalid( + op, + fmt.Sprintf("'%s' should be one of '%v', got '%s'", r.key, r.values, r.value), + nil, + ) +} + +/* +StringOneOf returns a RuleString for +validating that a string is one of given +values. +*/ +func StringOneOf(values []string) RuleString { + return ruleStringOneOf{ + &baseRuleString{}, + values, + } +} + +// Compile-time checks to ensure type implements desired interfaces. +var ( + _ = RuleString(new(ruleStringOneOf)) +) diff --git a/internal/pkg/xassert/string_test.go b/internal/pkg/xassert/string_test.go new file mode 100644 index 00000000..4f0c59fa --- /dev/null +++ b/internal/pkg/xassert/string_test.go @@ -0,0 +1,20 @@ +package xassert + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/thecodingmachine/gotenberg/test/internalpkg/xerrortest" +) + +func TestStringOfOne(t *testing.T) { + rule := StringOneOf([]string{"foo", "bar", "baz"}) + // should be OK. + rule.with("FOO", "foo") + err := rule.validate() + assert.Nil(t, err) + // should not be OK. + rule.with("FOO", "qux") + err = rule.validate() + xerrortest.AssertError(t, err) +} diff --git a/internal/pkg/xassert/xassert.go b/internal/pkg/xassert/xassert.go new file mode 100644 index 00000000..574008ff --- /dev/null +++ b/internal/pkg/xassert/xassert.go @@ -0,0 +1,185 @@ +package xassert + +import ( + "fmt" + "os" + "strconv" + + "github.com/thecodingmachine/gotenberg/internal/pkg/xerror" +) + +/* +String applies validation on a string. + +If string is empty or validation fails, +returns the default value. + +The key is used to identify the value. +*/ +func String(key, value, defaultValue string, rules ...RuleString) (string, error) { + const op string = "xassert.String" + result := defaultValue + if value != "" { + result = value + } + for _, rule := range rules { + rule.with(key, result) + if err := rule.validate(); err != nil { + return defaultValue, xerror.New(op, err) + } + } + return result, nil +} + +/* +StringFromEnv returns the value of given environment +variable or the default value if not found or +validation fails. +*/ +func StringFromEnv(envVar, defaultValue string, rules ...RuleString) (string, error) { + const op string = "xassert.StringFromEnv" + value := os.Getenv(envVar) + result, err := String(envVar, value, defaultValue, rules...) + if err != nil { + return result, xerror.New(op, err) + } + return result, nil +} + +/* +Int64 tries to convert a string to an int64. + +If string is empty, conversion or validation fails, +returns the default value. + +The key is used to identify the value. +*/ +func Int64(key, value string, defaultValue int64, rules ...RuleInt64) (int64, error) { + const op string = "xassert.Int64" + result := defaultValue + if value != "" { + parsedValue, err := strconv.ParseInt(value, 10, 64) + if err != nil { + return defaultValue, xerror.Invalid( + op, + fmt.Sprintf("'%s' is not an integer, got '%s'", key, value), + err, + ) + } + result = parsedValue + } + for _, rule := range rules { + rule.with(key, result) + if err := rule.validate(); err != nil { + return defaultValue, xerror.New(op, err) + } + } + return result, nil +} + +/* +Int64FromEnv returns the int64 representation of the +value of given environment variable. + +If not found, empty, conversion or validation fails, +returns the default value. +*/ +func Int64FromEnv(envVar string, defaultValue int64, rules ...RuleInt64) (int64, error) { + const op string = "xassert.Int64FromEnv" + value := os.Getenv(envVar) + result, err := Int64(envVar, value, defaultValue, rules...) + if err != nil { + return result, xerror.New(op, err) + } + return result, nil +} + +/* +Float64 tries to convert a string to a float64. + +If string is empty, conversion or validation fails, +returns the default value. + +The key is used to identify the value. +*/ +func Float64(key, value string, defaultValue float64, rules ...RuleFloat64) (float64, error) { + const op string = "xassert.Float64" + result := defaultValue + if value != "" { + parsedValue, err := strconv.ParseFloat(value, 64) + if err != nil { + return defaultValue, xerror.Invalid( + op, + fmt.Sprintf("'%s' is not a float, got '%s'", key, value), + err, + ) + } + result = parsedValue + } + for _, rule := range rules { + rule.with(key, result) + if err := rule.validate(); err != nil { + return defaultValue, xerror.New(op, err) + } + } + return result, nil +} + +/* +Float64FromEnv returns the float64 representation of the +value of given environment variable. + +If not found, empty, conversion or validation fails, +returns the default value. +*/ +func Float64FromEnv(envVar string, defaultValue float64, rules ...RuleFloat64) (float64, error) { + const op string = "xassert.Float64FromEnv" + value := os.Getenv(envVar) + result, err := Float64(envVar, value, defaultValue, rules...) + if err != nil { + return result, xerror.New(op, err) + } + return result, nil +} + +/* +Bool tries to convert a string to a boolean. + +If string is empty or conversion fails, returns the +default value. + +The key is used to identify the value. +*/ +func Bool(key, value string, defaultValue bool) (bool, error) { + const op string = "xassert.Bool" + result := defaultValue + if value != "" { + parsedValue, err := strconv.ParseBool(value) + if err != nil { + return defaultValue, xerror.Invalid( + op, + fmt.Sprintf("'%s' is not a boolean, got '%s'", key, value), + err, + ) + } + result = parsedValue + } + return result, nil +} + +/* +BoolFromEnv returns the boolean representation of the +value of given environment variable. + +If not found, empty or conversion fails, returns the +default value. +*/ +func BoolFromEnv(envVar string, defaultValue bool) (bool, error) { + const op string = "xassert.BoolFromEnv" + value := os.Getenv(envVar) + result, err := Bool(envVar, value, defaultValue) + if err != nil { + return result, xerror.New(op, err) + } + return result, nil +} diff --git a/internal/pkg/xassert/xassert_test.go b/internal/pkg/xassert/xassert_test.go new file mode 100644 index 00000000..607b70c1 --- /dev/null +++ b/internal/pkg/xassert/xassert_test.go @@ -0,0 +1,289 @@ +package xassert + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/thecodingmachine/gotenberg/test/internalpkg/xerrortest" +) + +func TestString(t *testing.T) { + const ( + defaultValue string = "FOO" + ) + var expected string + rule := StringOneOf([]string{"FOO", "BAR"}) + // empty value, result should be equal + // to the default value. + v, err := String("foo", "", defaultValue) + expected = defaultValue + assert.Equal(t, expected, v) + assert.Nil(t, err) + // result should be equal to given value + // as it is one of "FOO" and "BAR". + expected = "FOO" + v, err = String("foo", expected, defaultValue, rule) + assert.Equal(t, expected, v) + assert.Nil(t, err) + // should not be OK as given value is not + // one of "FOO" and "BAR". + v, err = String("foo", "BAZ", defaultValue, rule) + expected = defaultValue + assert.Equal(t, expected, v) + xerrortest.AssertError(t, err) +} + +func TestStringFromEnv(t *testing.T) { + const ( + envVar string = "FOO" + defaultValue string = "FOO" + ) + var expected string + rule := StringOneOf([]string{"FOO", "BAR"}) + // no environment variable set, + // value should be equal to default value. + v, err := StringFromEnv(envVar, defaultValue) + expected = defaultValue + assert.Equal(t, expected, v) + assert.Nil(t, err) + // result should be equal to environment variable + // value as it is one of "FOO" and "BAR". + expected = "BAR" + os.Setenv(envVar, expected) + v, err = StringFromEnv(envVar, defaultValue, rule) + assert.Equal(t, expected, v) + assert.Nil(t, err) + os.Unsetenv(envVar) + // should not be OK as environment variable + // value is not one of "FOO" and "BAR". + os.Setenv(envVar, "BAZ") + v, err = StringFromEnv(envVar, defaultValue, rule) + expected = defaultValue + assert.Equal(t, expected, v) + xerrortest.AssertError(t, err) + os.Unsetenv(envVar) +} + +func TestInt64(t *testing.T) { + const ( + defaultValue int64 = 10 + ) + var expected int64 + rule := Int64NotInferiorTo(6) + // empty value, result should be equal + // to the default value. + v, err := Int64("foo", "", defaultValue) + expected = defaultValue + assert.Equal(t, expected, v) + assert.Nil(t, err) + // result should be equal to given value + // but as integer. + v, err = Int64("foo", "5", defaultValue) + expected = 5 + assert.Equal(t, expected, v) + assert.Nil(t, err) + // should not be OK as given value is not + // a string representation of an integer. + v, err = Int64("foo", "foo", defaultValue) + expected = defaultValue + assert.Equal(t, expected, v) + xerrortest.AssertError(t, err) + // should not be OK as given value does not + // validate the rule x >= 6. + v, err = Int64("foo", "5", defaultValue, rule) + expected = defaultValue + assert.Equal(t, expected, v) + xerrortest.AssertError(t, err) +} + +func TestInt64FromEnv(t *testing.T) { + const ( + envVar string = "FOO" + defaultValue int64 = 10 + ) + var expected int64 + rule := Int64NotInferiorTo(6) + // no environment variable set, + // value should be equal to default value. + v, err := Int64FromEnv(envVar, defaultValue) + expected = defaultValue + assert.Equal(t, expected, v) + assert.Nil(t, err) + // result should be equal to environment variable + // value but as integer. + os.Setenv(envVar, "5") + v, err = Int64FromEnv(envVar, defaultValue) + expected = 5 + assert.Equal(t, expected, v) + assert.Nil(t, err) + os.Unsetenv(envVar) + // should not be OK as environment variable + // value is not a string representation of an integer. + os.Setenv(envVar, "foo") + v, err = Int64FromEnv(envVar, defaultValue) + expected = defaultValue + assert.Equal(t, expected, v) + xerrortest.AssertError(t, err) + os.Unsetenv(envVar) + // should not be OK as environment variable + // value does not validate the rule x >= 6. + os.Setenv(envVar, "5") + v, err = Int64FromEnv(envVar, defaultValue, rule) + expected = defaultValue + assert.Equal(t, expected, v) + xerrortest.AssertError(t, err) + os.Unsetenv(envVar) +} + +func TestFloat64(t *testing.T) { + const defaultValue float64 = 10.0 + var expected float64 + rule := Float64NotInferiorTo(6.0) + // empty value, result should be equal + // to the default value. + v, err := Float64("foo", "", defaultValue) + expected = defaultValue + assert.Equal(t, expected, v) + assert.Nil(t, err) + // result should be equal to given value + // but as float. + v, err = Float64("foo", "5.5", defaultValue) + expected = 5.5 + assert.Equal(t, expected, v) + assert.Nil(t, err) + // should not be OK as given value is not + // a string representation of a float. + v, err = Float64("foo", "foo", defaultValue) + expected = defaultValue + assert.Equal(t, expected, v) + xerrortest.AssertError(t, err) + // should not be OK as given value does not + // validate the rule x >= 6. + v, err = Float64("foo", "5", defaultValue, rule) + expected = defaultValue + assert.Equal(t, expected, v) + xerrortest.AssertError(t, err) +} + +func TestFloat64FromEnv(t *testing.T) { + const ( + envVar string = "FOO" + defaultValue float64 = 10.0 + ) + var expected float64 + rule := Float64NotInferiorTo(6.0) + // no environment variable set, + // value should be equal to default value. + v, err := Float64FromEnv(envVar, defaultValue) + expected = defaultValue + assert.Equal(t, expected, v) + assert.Nil(t, err) + // result should be equal to environment variable + // value but as float. + os.Setenv(envVar, "5.5") + v, err = Float64FromEnv(envVar, defaultValue) + expected = 5.5 + assert.Equal(t, expected, v) + assert.Nil(t, err) + os.Unsetenv(envVar) + // should not be OK as environment variable + // value is not a string representation of a float. + os.Setenv(envVar, "foo") + v, err = Float64FromEnv(envVar, defaultValue) + expected = defaultValue + assert.Equal(t, expected, v) + xerrortest.AssertError(t, err) + os.Unsetenv(envVar) + // should not be OK as environment variable + // value does not validate the rule x >= 6. + os.Setenv(envVar, "5") + v, err = Float64FromEnv(envVar, defaultValue, rule) + expected = defaultValue + assert.Equal(t, expected, v) + xerrortest.AssertError(t, err) + os.Unsetenv(envVar) +} + +func TestBool(t *testing.T) { + const defaultValue bool = true + var expected bool + // empty value, result should be equal + // to the default value. + v, err := Bool("foo", "", defaultValue) + expected = defaultValue + assert.Equal(t, expected, v) + assert.Nil(t, err) + // result should be equal to given value + // but as boolean. + v, err = Bool("foo", "1", defaultValue) + expected = true + assert.Equal(t, expected, v) + assert.Nil(t, err) + v, err = Bool("foo", "true", defaultValue) + expected = true + assert.Equal(t, expected, v) + assert.Nil(t, err) + v, err = Bool("foo", "0", defaultValue) + expected = false + assert.Equal(t, expected, v) + assert.Nil(t, err) + v, err = Bool("foo", "false", defaultValue) + expected = false + assert.Equal(t, expected, v) + assert.Nil(t, err) + // should not be OK as given value is not + // a string representation of a boolean. + v, err = Bool("foo", "foo", defaultValue) + expected = defaultValue + assert.Equal(t, expected, v) + xerrortest.AssertError(t, err) +} + +func TestBoolFromEnv(t *testing.T) { + const ( + envVar string = "FOO" + defaultValue bool = true + ) + var expected bool + // no environment variable set, + // value should be equal to default value. + v, err := BoolFromEnv(envVar, defaultValue) + expected = defaultValue + assert.Equal(t, expected, v) + assert.Nil(t, err) + // result should be equal to environment variable + // value but as boolean. + os.Setenv(envVar, "1") + v, err = BoolFromEnv(envVar, defaultValue) + expected = true + assert.Equal(t, expected, v) + assert.Nil(t, err) + os.Unsetenv(envVar) + os.Setenv(envVar, "true") + v, err = BoolFromEnv(envVar, defaultValue) + expected = true + assert.Equal(t, expected, v) + assert.Nil(t, err) + os.Unsetenv(envVar) + os.Setenv(envVar, "0") + v, err = BoolFromEnv(envVar, defaultValue) + expected = false + assert.Equal(t, expected, v) + assert.Nil(t, err) + os.Unsetenv(envVar) + os.Setenv(envVar, "false") + v, err = BoolFromEnv(envVar, defaultValue) + expected = false + assert.Equal(t, expected, v) + assert.Nil(t, err) + os.Unsetenv(envVar) + // should not be OK as environment variable + // value is not a string representation of a boolean. + os.Setenv(envVar, "foo") + v, err = BoolFromEnv(envVar, defaultValue) + expected = defaultValue + assert.Equal(t, expected, v) + xerrortest.AssertError(t, err) + os.Unsetenv(envVar) +} diff --git a/internal/pkg/xcontext/doc.go b/internal/pkg/xcontext/doc.go new file mode 100644 index 00000000..d6a369d6 --- /dev/null +++ b/internal/pkg/xcontext/doc.go @@ -0,0 +1,3 @@ +// Package xcontext helps managing +// context.Context with timeout. +package xcontext diff --git a/internal/pkg/xcontext/xcontext.go b/internal/pkg/xcontext/xcontext.go new file mode 100644 index 00000000..67a27d16 --- /dev/null +++ b/internal/pkg/xcontext/xcontext.go @@ -0,0 +1,56 @@ +package xcontext + +import ( + "context" + "fmt" + "strings" + + "github.com/thecodingmachine/gotenberg/internal/pkg/xerror" + "github.com/thecodingmachine/gotenberg/internal/pkg/xlog" + "github.com/thecodingmachine/gotenberg/internal/pkg/xtime" +) + +// WithTimeout creates a context.Context which +// times out after given seconds. +func WithTimeout(logger xlog.Logger, seconds float64) (context.Context, context.CancelFunc) { + const op string = "xcontext.WithTimeout" + logger.DebugfOp(op, "creating context with '%.2fs' of timeout...", seconds) + return context.WithTimeout(context.Background(), xtime.Duration(seconds)) +} + +/* +MustHandleError checks if there is an error +in the given Context. + +If no error, returns the previous error. + +If context.DeadlineExceeded, wraps the previous +error inside an xerror.Error with xerror.TimeoutCode. + +Otherwise wraps the previous error inside an +xerror.Error. + +It panics if no previous error. +*/ +func MustHandleError(ctx context.Context, previousErr error) error { + const op string = "xcontext.MustHandleError" + if previousErr == nil { + panic(fmt.Sprintf("%s: previous error should not be nil", op)) + } + err := ctx.Err() + if err == nil { + // we do not wrap the previous error + // as it should be wrapped by the caller. + return previousErr + } + // context has timed out + if strings.Contains(err.Error(), context.DeadlineExceeded.Error()) { + return xerror.Timeout(op, "context has timed out", previousErr) + } + /* + context has another error: we do not + wrap the error from the Context as the previous + error should contain it. + */ + return xerror.New(op, previousErr) +} diff --git a/internal/pkg/xcontext/xcontext_test.go b/internal/pkg/xcontext/xcontext_test.go new file mode 100644 index 00000000..a0d541ff --- /dev/null +++ b/internal/pkg/xcontext/xcontext_test.go @@ -0,0 +1,43 @@ +package xcontext + +import ( + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/thecodingmachine/gotenberg/internal/pkg/xerror" + "github.com/thecodingmachine/gotenberg/internal/pkg/xtime" + "github.com/thecodingmachine/gotenberg/test/internalpkg/xerrortest" + "github.com/thecodingmachine/gotenberg/test/internalpkg/xlogtest" +) + +func TestMustHandleError(t *testing.T) { + previousErr := errors.New("previous error") + logger := xlogtest.DebugLogger() + // context should not have an error. + ctx, cancel := WithTimeout(logger, 5) + defer cancel() + err := MustHandleError(ctx, previousErr) + assert.Equal(t, previousErr, err) + // should panic. + ctx, cancel = WithTimeout(logger, 5) + defer cancel() + assert.Panics(t, func() { + MustHandleError(ctx, nil) + }) + // context should timed out. + ctx, cancel = WithTimeout(logger, 0.5) + defer cancel() + time.Sleep(xtime.Duration(1)) + err = MustHandleError(ctx, previousErr) + xerr := xerrortest.AssertError(t, err) + assert.Equal(t, xerror.TimeoutCode, xerror.Code(xerr)) + // context should have an error different + // than context.DeadlineExceeded. + ctx, cancel = WithTimeout(logger, 5) + cancel() + err = MustHandleError(ctx, previousErr) + xerr = xerrortest.AssertError(t, err) + assert.Equal(t, xerror.InternalCode, xerror.Code(xerr)) +} diff --git a/internal/pkg/xerror/doc.go b/internal/pkg/xerror/doc.go new file mode 100644 index 00000000..999decf8 --- /dev/null +++ b/internal/pkg/xerror/doc.go @@ -0,0 +1,7 @@ +/* +Package xerror helps standardizing +the errors through the application. + +Credits: https://middlemost.com/failure-is-your-domain/ +*/ +package xerror diff --git a/internal/pkg/xerror/xerror.go b/internal/pkg/xerror/xerror.go new file mode 100644 index 00000000..f1f9c1ee --- /dev/null +++ b/internal/pkg/xerror/xerror.go @@ -0,0 +1,152 @@ +package xerror + +import ( + "bytes" + "fmt" + "strings" +) + +// ErrorCode is machine-readable error code. +type ErrorCode string + +const ( + // InternalCode is an internal error. + InternalCode ErrorCode = "internal" + // InvalidCode occurs when a validation + // failed. + InvalidCode ErrorCode = "invalid" + // TimeoutCode occurs when something + // timed out. + TimeoutCode ErrorCode = "timeout" +) + +// Error defines our standard application +// error. +type Error struct { + code ErrorCode + message string + op string + err error +} + +// Error returns the string representation of the error message. +func (e Error) Error() string { + var buf bytes.Buffer + // if wrapping an error, print its Error() message. + // Otherwise print the error code & message. + if e.err != nil { + buf.WriteString(e.err.Error()) + } else { + if e.code != "" { + fmt.Fprintf(&buf, "<%s> ", e.code) + } + buf.WriteString(e.message) + } + return buf.String() +} + +/* +New returns a xerror.Error. + +Should be used for wrapping an error +at the end of a function. +*/ +func New(op string, previous error) error { + return &Error{ + op: op, + err: previous, + } +} + +/* +Invalid returns a xerror.Error. + +Should be used when an input +is wrong. +*/ +func Invalid(op, message string, previous error) error { + return &Error{ + code: InvalidCode, + message: message, + op: op, + err: previous, + } +} + +/* +Timeout returns a xerror.Error. + +Should be used when a timeout occurs. +*/ +func Timeout(op, message string, previous error) error { + return &Error{ + code: TimeoutCode, + message: message, + op: op, + err: previous, + } +} + +// Code returns the code of the root error, if available. +// Otherwise returns InternalCode. +func Code(err error) ErrorCode { + if err == nil { + return "" + } + e, ok := err.(*Error) + if ok && e.code != "" { + return e.code + } + if ok && e.err != nil { + return Code(e.err) + } + return InternalCode +} + +const defaultMessage string = "an internal error has occurred: please contact technical support" + +// Message returns the human-readable message of the error, if available. +// Otherwise returns a generic error message. +func Message(err error) string { + if err == nil { + return "" + } + e, ok := err.(*Error) + if ok && e.message != "" { + return e.message + } + if ok && e.err != nil { + return Message(e.err) + } + return defaultMessage +} + +// Op returns the logical operation of the error, if available. +// Otherwise returns an empty string. +func Op(err error) string { + if err == nil { + return "" + } + e, ok := err.(*Error) + if !ok { + return "" + } + var buf bytes.Buffer + nestedOp := Op(e.err) + if nestedOp != "" { + // we want to avoid having the same op chained. + if e.op != "" && !strings.Contains(nestedOp, e.op) { + fmt.Fprintf(&buf, "%s: %s", e.op, nestedOp) + } else { + fmt.Fprintf(&buf, "%s", nestedOp) + } + } else if e.op != "" { + fmt.Fprintf(&buf, "%s", e.op) + } + return buf.String() +} + +// Compile-time checks to ensure type implements desired interfaces. +var ( + _ = error(new(Error)) +) diff --git a/internal/pkg/xerror/xerror_test.go b/internal/pkg/xerror/xerror_test.go new file mode 100644 index 00000000..a3727ccf --- /dev/null +++ b/internal/pkg/xerror/xerror_test.go @@ -0,0 +1,97 @@ +package xerror + +import ( + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +/* +Error 1.0: op = "foo" +Error 1.1: op = "bar" +Error 1.2: code = "invalid", op = "baz", message = "nested error" +Error 1.3: message = "root error" +*/ +func scenario1() error { + rootErr := errors.New("root error") + nestedErr := Invalid("baz", "nested error", rootErr) + wrappingErr := New("bar", nestedErr) + return New("foo", wrappingErr) +} + +/* +Error 2.0: op = "foo" +Error 2.1: op = "bar" +Error 2.2: code = "timeout", op = "bar", message = "nested error" +*/ +func scenario2() error { + nestedErr := Timeout("bar", "nested error", nil) + wrappingErr := New("bar", nestedErr) + return New("foo", wrappingErr) +} + +// Error 3.0: code = "", op = "foo" +func scenario3() error { + return New("foo", nil) +} + +func TestError(t *testing.T) { + // should return the Error 1.3 + // message. + err := scenario1() + assert.Equal(t, "root error", err.Error()) + // should return the Error 2.2 message with + // its code. + err = scenario2() + assert.Equal(t, " nested error", err.Error()) +} + +func TestCode(t *testing.T) { + // should be an empty code if no error. + assert.Equal(t, "", fmt.Sprintf("%s", Code(nil))) + // should be the code of Error 1.2. + err := scenario1() + assert.Equal(t, InvalidCode, Code(err)) + // should be the code of Error 2.2. + err = scenario2() + assert.Equal(t, TimeoutCode, Code(err)) + // should be the default code. + err = scenario3() + assert.Equal(t, InternalCode, Code(err)) + err = errors.New("some error") + assert.Equal(t, InternalCode, Code(err)) +} + +func TestMessage(t *testing.T) { + // should be an empty message if no error. + assert.Equal(t, "", Message(nil)) + // should be the message of Error 1.2. + err := scenario1() + assert.Equal(t, "nested error", Message(err)) + // should be the default message. + err = errors.New("some error") + assert.Equal(t, defaultMessage, Message(err)) +} + +func TestOp(t *testing.T) { + // should be an empty op if no error. + assert.Equal(t, "", Op(nil)) + // should be the chain of op in this order: + // Error 1.0 -> Error 1.1 -> Error 1.2. + err := scenario1() + assert.Equal(t, "foo: bar: baz", Op(err)) + /* + should be the chain of op in this order: + Error 2.0 -> Error 2.1. + + As Error 2.1 and Error 2.2 shares the same + op, Error 2.2 op is not displayed. + */ + err = scenario2() + assert.Equal(t, "foo: bar", Op(err)) + // should be an empty op if not Error. + err = errors.New("some error") + assert.Equal(t, "", Op(err)) +} diff --git a/internal/pkg/xexec/doc.go b/internal/pkg/xexec/doc.go new file mode 100644 index 00000000..e1605a43 --- /dev/null +++ b/internal/pkg/xexec/doc.go @@ -0,0 +1,8 @@ +/* +Package xexec helps creating exec.Cmd +with logging. + +All functions return our standard xerror.Error +in case of error. +*/ +package xexec diff --git a/internal/pkg/xexec/xexec.go b/internal/pkg/xexec/xexec.go new file mode 100644 index 00000000..4b4fec68 --- /dev/null +++ b/internal/pkg/xexec/xexec.go @@ -0,0 +1,99 @@ +package xexec + +import ( + "bufio" + "context" + "fmt" + "io" + "os/exec" + "strings" + + "github.com/thecodingmachine/gotenberg/internal/pkg/xerror" + "github.com/thecodingmachine/gotenberg/internal/pkg/xlog" +) + +/* +Command is a wrapper around exec.Command. + +If given xlog.Logger has a xlog.DebugLevel, +also logs the output from the command. +*/ +func Command(logger xlog.Logger, binary string, args ...string) (*exec.Cmd, error) { + const op string = "xexec.Command" + cmd := exec.Command(binary, args...) + if err := pipe(logger, cmd); err != nil { + return nil, xerror.New(op, err) + } + return cmd, nil +} + +/* +CommandContext is a wrapper around exec.CommandContext. + +If given xlog.Logger has a xlog.DebugLevel, +also logs the output from the command. +*/ +func CommandContext(ctx context.Context, logger xlog.Logger, binary string, args ...string) (*exec.Cmd, error) { + const op string = "xexec.CommandContext" + cmd := exec.CommandContext(ctx, binary, args...) + if err := pipe(logger, cmd); err != nil { + return nil, xerror.New(op, err) + } + return cmd, nil +} + +// LogBeforeExecute logs a command before its execution. +func LogBeforeExecute(logger xlog.Logger, cmd *exec.Cmd) { + const op string = "xexec.LogBeforeExecute" + logger.DebugfOp(op, "executing command: %s", strings.Join(cmd.Args, " ")) +} + +func pipe(logger xlog.Logger, cmd *exec.Cmd) error { + const op string = "xexec.pipe" + if logger.Level() != xlog.DebugLevel { + return nil + } + // if xlog.DebugLevel, log the output + // from the command. + resolver := func() error { + stdout, err := cmd.StdoutPipe() + if err != nil { + return err + } + stderr, err := cmd.StderrPipe() + if err != nil { + return err + } + go logCommandOutput(logger, stdout, "stdout", cmd) + go logCommandOutput(logger, stderr, "stderr", cmd) + return nil + } + if err := resolver(); err != nil { + return xerror.New(op, err) + } + return nil +} + +func logCommandOutput(logger xlog.Logger, reader io.ReadCloser, outputType string, cmd *exec.Cmd) { + var op string + if len(cmd.Args) >= 2 { + op = fmt.Sprintf("%s.%s.%s", cmd.Args[0], cmd.Args[1], outputType) + } else { + // len(cmd.Args) should always be >= 1. + op = fmt.Sprintf("%s.%s", cmd.Args[0], outputType) + } + r := bufio.NewReader(reader) + defer reader.Close() // nolint: errcheck + for { + line, _, err := r.ReadLine() + if err != nil { + if err != io.EOF { + logger.ErrorOp(op, err) + } + break + } + if len(line) != 0 { + logger.DebugOp(op, string(line)) + } + } +} diff --git a/internal/pkg/xexec/xexec_test.go b/internal/pkg/xexec/xexec_test.go new file mode 100644 index 00000000..caeb03a8 --- /dev/null +++ b/internal/pkg/xexec/xexec_test.go @@ -0,0 +1,39 @@ +package xexec + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/thecodingmachine/gotenberg/test/internalpkg/xlogtest" +) + +func TestCommand(t *testing.T) { + logger := xlogtest.DebugLogger() + // should pipe command output as + // xlog.Logger has a xlog.DebugLevel. + cmd, err := Command(logger, "echo", "Hello", "World") + assert.Nil(t, err) + LogBeforeExecute(logger, cmd) + // should not pipe command output as + // xlog.Logger has a xlog.InfoLevel. + logger = xlogtest.InfoLogger() + cmd, err = Command(logger, "echo", "Hello", "World") + LogBeforeExecute(logger, cmd) + assert.Nil(t, err) +} + +func TestCommandContext(t *testing.T) { + logger := xlogtest.DebugLogger() + // should pipe command output as + // xlog.Logger has a xlog.DebugLevel. + cmd, err := CommandContext(context.Background(), logger, "echo") + assert.Nil(t, err) + LogBeforeExecute(logger, cmd) + // should not pipe command output as + // xlog.Logger has a xlog.InfoLevel. + logger = xlogtest.InfoLogger() + cmd, err = CommandContext(context.Background(), logger, "echo", "Hello", "World") + LogBeforeExecute(logger, cmd) + assert.Nil(t, err) +} diff --git a/internal/pkg/xlog/doc.go b/internal/pkg/xlog/doc.go new file mode 100644 index 00000000..7aab9e5c --- /dev/null +++ b/internal/pkg/xlog/doc.go @@ -0,0 +1,17 @@ +/* +Package xlog defines a standard logger +for the application. + +It uses structured logging thanks to +https://github.com/sirupsen/logrus. + +All messages have at least two fields: + +A "trace" field which helps to identify +messages belonging to the same context. + +An "op" field which helps to identify +the logical operation associated +with the message. +*/ +package xlog diff --git a/internal/pkg/xlog/xlog.go b/internal/pkg/xlog/xlog.go new file mode 100644 index 00000000..b8aa271a --- /dev/null +++ b/internal/pkg/xlog/xlog.go @@ -0,0 +1,141 @@ +package xlog + +import ( + "fmt" + "os" + + "github.com/mattn/go-isatty" + "github.com/sirupsen/logrus" +) + +// Level helps setting the severity +// of the messages displayed. +type Level string + +const ( + // DebugLevel is the lowest level. + DebugLevel Level = "DEBUG" + // InfoLevel is the intermediate level. + InfoLevel Level = "INFO" + // ErrorLevel is the highest level. + ErrorLevel Level = "ERROR" +) + +// Logger enforces specific log message formats. +type Logger struct { + entry *logrus.Entry + level Level +} + +// New returns a xlog.Logger. +func New(level Level, trace string) Logger { + l := logrus.New() + l.SetLevel(mustLogrusLevel(level)) + if !isatty.IsTerminal(os.Stdout.Fd()) { + l.SetFormatter(&logrus.JSONFormatter{}) + } + return Logger{ + entry: l.WithField("trace", trace), + level: level, + } +} + +func mustLogrusLevel(level Level) logrus.Level { + const op string = "xlog.mustLogrusLevel" + switch level { + case DebugLevel: + return logrus.DebugLevel + case InfoLevel: + return logrus.InfoLevel + case ErrorLevel: + return logrus.ErrorLevel + default: + panic(fmt.Sprintf("%s: '%s' is not associated with any logrus.Level", op, level)) + } +} + +// Levels returns a slice of string +// with all severities. +func Levels() []string { + return []string{ + string(DebugLevel), + string(InfoLevel), + string(ErrorLevel), + } +} + +/* +MustParseLevel returns the Level corresponding +to given string. + +It panics if no correspondence. +*/ +func MustParseLevel(level string) Level { + const op string = "xlog.MustParseLevel" + switch level { + case string(DebugLevel): + return DebugLevel + case string(InfoLevel): + return InfoLevel + case string(ErrorLevel): + return ErrorLevel + default: + panic(fmt.Sprintf("%s: '%s' is not one of '%v'", op, level, Levels())) + } +} + +// Level returns the current Level. +func (l Logger) Level() Level { + return l.level +} + +// WithFields returns a new xlog.Logger with +// given fields. +func (l Logger) WithFields(fields map[string]interface{}) Logger { + return Logger{ + entry: l.entry.WithFields(fields), + level: l.level, + } +} + +// DebugOp logs a debug message for given +// logical operation. +func (l Logger) DebugOp(op, message string) { + l.entry.WithField("op", op).Debug(message) +} + +// DebugfOp logs a debug message for given +// logical operation and format. +func (l Logger) DebugfOp(op, format string, args ...interface{}) { + l.entry.WithField("op", op).Debugf(format, args...) +} + +// InfoOp logs an info message for given +// logical operation. +func (l Logger) InfoOp(op, message string) { + l.entry.WithField("op", op).Info(message) +} + +// InfofOp logs an info message for given +// logical operation and format. +func (l Logger) InfofOp(op, format string, args ...interface{}) { + l.entry.WithField("op", op).Infof(format, args...) +} + +// ErrorOp logs an error for given +// logical operation. +func (l Logger) ErrorOp(op string, err error) { + l.entry.WithField("op", op).Error(err.Error()) +} + +// ErrorfOp logs an error message for given +// logical operation and format. +func (l Logger) ErrorfOp(op, format string, args ...interface{}) { + l.entry.WithField("op", op).Errorf(format, args...) +} + +// FatalOp logs an error for given +// logical operation and exit 1. +func (l Logger) FatalOp(op string, err error) { + l.entry.WithField("op", op).Fatal(err.Error()) +} diff --git a/internal/pkg/xrand/doc.go b/internal/pkg/xrand/doc.go new file mode 100644 index 00000000..672af2b3 --- /dev/null +++ b/internal/pkg/xrand/doc.go @@ -0,0 +1,3 @@ +// Package xrand helps generating +// random strings. +package xrand diff --git a/internal/pkg/random/random.go b/internal/pkg/xrand/xrand.go similarity index 89% rename from internal/pkg/random/random.go rename to internal/pkg/xrand/xrand.go index 07556b5b..bdac8fab 100644 --- a/internal/pkg/random/random.go +++ b/internal/pkg/xrand/xrand.go @@ -1,4 +1,4 @@ -package random +package xrand import ( "github.com/labstack/gommon/random" diff --git a/internal/pkg/random/random_test.go b/internal/pkg/xrand/xrand_test.go similarity index 97% rename from internal/pkg/random/random_test.go rename to internal/pkg/xrand/xrand_test.go index aaf94f17..e5880f99 100644 --- a/internal/pkg/random/random_test.go +++ b/internal/pkg/xrand/xrand_test.go @@ -1,4 +1,4 @@ -package random +package xrand import ( "testing" diff --git a/internal/pkg/xtime/doc.go b/internal/pkg/xtime/doc.go new file mode 100644 index 00000000..2dbfa55a --- /dev/null +++ b/internal/pkg/xtime/doc.go @@ -0,0 +1,6 @@ +/* +Package xtime helps generating +time.Duration from seconds represented +as float64. +*/ +package xtime diff --git a/internal/pkg/xtime/xtime.go b/internal/pkg/xtime/xtime.go new file mode 100644 index 00000000..c0af3bdb --- /dev/null +++ b/internal/pkg/xtime/xtime.go @@ -0,0 +1,10 @@ +package xtime + +import ( + "time" +) + +// Duration creates a time.Duration from seconds. +func Duration(seconds float64) time.Duration { + return time.Duration(1000*seconds) * time.Millisecond +} diff --git a/internal/pkg/xtime/xtime_test.go b/internal/pkg/xtime/xtime_test.go new file mode 100644 index 00000000..67fee437 --- /dev/null +++ b/internal/pkg/xtime/xtime_test.go @@ -0,0 +1,14 @@ +package xtime + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestDuration(t *testing.T) { + expected := time.Duration(1500) * time.Millisecond + result := Duration(1.5) + assert.Equal(t, expected.String(), result.String()) +} diff --git a/scripts/publish.sh b/scripts/publish.sh index 966cf8f9..ed7a5e9b 100755 --- a/scripts/publish.sh +++ b/scripts/publish.sh @@ -18,9 +18,7 @@ if [ $VERSION_LENGTH -ne 3 ]; then exit 1 fi -docker build -t thecodingmachine/gotenberg:base -f build/base/Dockerfile . docker build \ - --build-arg GOLANG_VERSION=${GOLANG_VERSION} \ --build-arg VERSION=${VERSION} \ -t thecodingmachine/gotenberg:latest \ -t thecodingmachine/gotenberg:${SEMVER[0]} \ diff --git a/test/cmd/pm2/pm2.go b/test/cmd/pm2/pm2.go new file mode 100644 index 00000000..525fc6e0 --- /dev/null +++ b/test/cmd/pm2/pm2.go @@ -0,0 +1,18 @@ +package main + +import ( + "github.com/thecodingmachine/gotenberg/internal/pkg/pm2" + "github.com/thecodingmachine/gotenberg/test/internalpkg/xlogtest" +) + +func main() { + logger := xlogtest.DebugLogger() + process := pm2.NewChromeProcess(logger) + if err := process.Start(); err != nil { + panic(err) + } + process = pm2.NewUnoconvProcess(logger) + if err := process.Start(); err != nil { + panic(err) + } +} diff --git a/test/doc.go b/test/doc.go new file mode 100644 index 00000000..edc5a4d1 --- /dev/null +++ b/test/doc.go @@ -0,0 +1,3 @@ +// Package test contains useful +// functions used across tests. +package test diff --git a/test/internalpkg/xerrortest/doc.go b/test/internalpkg/xerrortest/doc.go new file mode 100644 index 00000000..cc1f7ef9 --- /dev/null +++ b/test/internalpkg/xerrortest/doc.go @@ -0,0 +1,6 @@ +/* +Package xerrortest contains useful +functions for tests related +to xerror package. +*/ +package xerrortest diff --git a/test/internalpkg/xerrortest/xerrortest.go b/test/internalpkg/xerrortest/xerrortest.go new file mode 100644 index 00000000..f59b4265 --- /dev/null +++ b/test/internalpkg/xerrortest/xerrortest.go @@ -0,0 +1,18 @@ +package xerrortest + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/thecodingmachine/gotenberg/internal/pkg/xerror" +) + +// AssertError validates that given error +// is of an instance of xerror.Error. +// If so, returns the instance of xerror.Error. +func AssertError(t *testing.T, err error) *xerror.Error { + assert.NotNil(t, err) + standardized, ok := err.(*xerror.Error) + assert.Equal(t, true, ok) + return standardized +} diff --git a/test/internalpkg/xlogtest/doc.go b/test/internalpkg/xlogtest/doc.go new file mode 100644 index 00000000..5450edfe --- /dev/null +++ b/test/internalpkg/xlogtest/doc.go @@ -0,0 +1,6 @@ +/* +Package xlogtest contains useful +functions for tests related +to xlog package. +*/ +package xlogtest diff --git a/test/internalpkg/xlogtest/xlogtest.go b/test/internalpkg/xlogtest/xlogtest.go new file mode 100644 index 00000000..f720c604 --- /dev/null +++ b/test/internalpkg/xlogtest/xlogtest.go @@ -0,0 +1,23 @@ +package xlogtest + +import ( + "github.com/thecodingmachine/gotenberg/internal/pkg/xlog" +) + +// DebugLogger creates a xlog.Logger +// with xlog.DebugLevel for our tests. +func DebugLogger() xlog.Logger { + return xlog.New(xlog.DebugLevel, "tests") +} + +// InfoLogger creates a xlog.Logger +// with xlog.InfoLevel for our tests. +func InfoLogger() xlog.Logger { + return xlog.New(xlog.DebugLevel, "tests") +} + +// ErrorLogger creates a xlog.Logger +// with xlog.ErrorLevel for our tests. +func ErrorLogger() xlog.Logger { + return xlog.New(xlog.ErrorLevel, "tests") +} diff --git a/test/testfunc.go b/test/testfunc.go index ba597b25..8467f390 100644 --- a/test/testfunc.go +++ b/test/testfunc.go @@ -1,4 +1,3 @@ -// Package test contains useful functions used across tests. package test import ( @@ -14,11 +13,9 @@ import ( "runtime" "testing" - "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/thecodingmachine/gotenberg/internal/pkg/logger" - "github.com/thecodingmachine/gotenberg/internal/pkg/standarderror" + "github.com/thecodingmachine/gotenberg/internal/pkg/xerror" "golang.org/x/sync/errgroup" ) @@ -55,21 +52,16 @@ func AssertConcurrent(t *testing.T, fn func() error, amount int) { assert.NoError(t, err) } -// RequireStandardError validates that given error -// is of an instance of standarderror.Error. -// If so, returns the instance of standarderror.Error. -func RequireStandardError(t *testing.T, err error) *standarderror.Error { - standardized, ok := err.(*standarderror.Error) - require.Equal(t, true, ok) +// AssertStandardError validates that given error +// is of an instance of xerror.Error. +// If so, returns the instance of xerror.Error. +func AssertStandardError(t *testing.T, err error) *xerror.Error { + assert.NotNil(t, err) + standardized, ok := err.(*xerror.Error) + assert.Equal(t, true, ok) return standardized } -// CreateTestLogger create a default logger -// for our tests. -func CreateTestLogger() *logger.Logger { - return logger.New(logrus.DebugLevel, "tests") -} - // HTMLTestMultipartForm returns the body // for a multipate/form-data request with all // files under "html" folder. From 0d827e490ee18de4f7bf310762c39ce38f2688e1 Mon Sep 17 00:00:00 2001 From: Julien Neuhart Date: Tue, 23 Jul 2019 14:25:31 +0200 Subject: [PATCH 26/34] using (again) Golang binaries from official Docker image instead of standard install without checkum + added a TODO for healtheck --- Makefile | 2 +- build/workspace/Dockerfile | 10 +++++----- internal/app/xhttp/handler.go | 1 + 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/Makefile b/Makefile index 3a72b36e..401c7088 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -GOLANG_VERSION=1.12.7 +GOLANG_VERSION=1.12 VERSION=snapshot DOCKER_USER= DOCKER_PASSWORD= diff --git a/build/workspace/Dockerfile b/build/workspace/Dockerfile index 2ae20d7e..ecf13478 100644 --- a/build/workspace/Dockerfile +++ b/build/workspace/Dockerfile @@ -1,7 +1,9 @@ -FROM thecodingmachine/gotenberg:base - ARG GOLANG_VERSION +FROM golang:${GOLANG_VERSION}-stretch as golang + +FROM thecodingmachine/gotenberg:base + # |-------------------------------------------------------------------------- # | Common libraries # |-------------------------------------------------------------------------- @@ -25,9 +27,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ # | Installs Golang. # | -RUN wget https://dl.google.com/go/go${GOLANG_VERSION}.linux-amd64.tar.gz &&\ - tar -xvf go${GOLANG_VERSION}.linux-amd64.tar.gz &&\ - mv go /usr/local +COPY --from=golang /usr/local/go /usr/local/go ENV GOPATH /gotenberg/go ENV PATH $GOPATH/bin:/usr/local/go/bin:$PATH diff --git a/internal/app/xhttp/handler.go b/internal/app/xhttp/handler.go index 52152fcc..ebd38499 100644 --- a/internal/app/xhttp/handler.go +++ b/internal/app/xhttp/handler.go @@ -31,6 +31,7 @@ func pingHandler(c echo.Context) error { if err := ctx.ProcessesHealthcheck(); err != nil { return xerror.New(op, err) } + // TODO return processes info return nil } From c7d9e24c0058f90ec375aab36344c7a6b3520ea7 Mon Sep 17 00:00:00 2001 From: Julien Neuhart Date: Tue, 23 Jul 2019 15:11:37 +0200 Subject: [PATCH 27/34] now using Gateway timeout status code in case of timeout --- internal/app/xhttp/middleware.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/internal/app/xhttp/middleware.go b/internal/app/xhttp/middleware.go index 3d0dd6be..72552a88 100644 --- a/internal/app/xhttp/middleware.go +++ b/internal/app/xhttp/middleware.go @@ -115,8 +115,7 @@ func errorMiddleware() echo.MiddlewareFunc { case xerror.InvalidCode: httpErr = echo.NewHTTPError(http.StatusBadRequest, errMessage) case xerror.TimeoutCode: - // TODO status - httpErr = echo.NewHTTPError(http.StatusBadGateway, errMessage) + httpErr = echo.NewHTTPError(http.StatusGatewayTimeout, errMessage) default: httpErr = echo.NewHTTPError(http.StatusInternalServerError, errMessage) } From 0b2312a407fee12c02fff801893f2014b5c352a6 Mon Sep 17 00:00:00 2001 From: Julien Neuhart Date: Tue, 23 Jul 2019 16:25:55 +0200 Subject: [PATCH 28/34] better logical operation for xexec command output --- internal/pkg/xexec/xexec.go | 12 ++++++------ internal/pkg/xexec/xexec_test.go | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/internal/pkg/xexec/xexec.go b/internal/pkg/xexec/xexec.go index 4b4fec68..604bb8d7 100644 --- a/internal/pkg/xexec/xexec.go +++ b/internal/pkg/xexec/xexec.go @@ -2,6 +2,7 @@ package xexec import ( "bufio" + "bytes" "context" "fmt" "io" @@ -75,13 +76,12 @@ func pipe(logger xlog.Logger, cmd *exec.Cmd) error { } func logCommandOutput(logger xlog.Logger, reader io.ReadCloser, outputType string, cmd *exec.Cmd) { - var op string - if len(cmd.Args) >= 2 { - op = fmt.Sprintf("%s.%s.%s", cmd.Args[0], cmd.Args[1], outputType) - } else { - // len(cmd.Args) should always be >= 1. - op = fmt.Sprintf("%s.%s", cmd.Args[0], outputType) + var buf bytes.Buffer + buf.WriteString(fmt.Sprintf("%s", outputType)) + for _, arg := range cmd.Args { + buf.WriteString(fmt.Sprintf(".%s", arg)) } + op := buf.String() r := bufio.NewReader(reader) defer reader.Close() // nolint: errcheck for { diff --git a/internal/pkg/xexec/xexec_test.go b/internal/pkg/xexec/xexec_test.go index caeb03a8..b52b796a 100644 --- a/internal/pkg/xexec/xexec_test.go +++ b/internal/pkg/xexec/xexec_test.go @@ -27,7 +27,7 @@ func TestCommandContext(t *testing.T) { logger := xlogtest.DebugLogger() // should pipe command output as // xlog.Logger has a xlog.DebugLevel. - cmd, err := CommandContext(context.Background(), logger, "echo") + cmd, err := CommandContext(context.Background(), logger, "echo", "Hello", "World") assert.Nil(t, err) LogBeforeExecute(logger, cmd) // should not pipe command output as From a524521fdb3bd92e46f257730eaacf855578025e Mon Sep 17 00:00:00 2001 From: Julien Neuhart Date: Tue, 23 Jul 2019 16:40:39 +0200 Subject: [PATCH 29/34] improving lock mechanism for office conversion: now check if context has timeout when trying to acquire the lock + office printer test --- internal/pkg/printer/merge.go | 2 +- internal/pkg/printer/office.go | 28 ++++++---- internal/pkg/printer/office_test.go | 59 +++++++++++++++++++++ test/internalpkg/printertest/doc.go | 6 +++ test/internalpkg/printertest/printertest.go | 35 ++++++++++++ 5 files changed, 119 insertions(+), 11 deletions(-) create mode 100644 internal/pkg/printer/office_test.go create mode 100644 test/internalpkg/printertest/doc.go create mode 100644 test/internalpkg/printertest/printertest.go diff --git a/internal/pkg/printer/merge.go b/internal/pkg/printer/merge.go index fd350f38..d9fd87ad 100644 --- a/internal/pkg/printer/merge.go +++ b/internal/pkg/printer/merge.go @@ -34,13 +34,13 @@ func NewMergePrinter(logger xlog.Logger, fpaths []string, opts MergePrinterOptio func (p mergePrinter) Print(destination string) error { const op string = "printer.mergePrinter.Print" - logOptions(p.logger, p.opts) /* context.Context may be providen from an officePrinter which needs to merge its result files. */ if p.ctx == nil { + logOptions(p.logger, p.opts) ctx, cancel := xcontext.WithTimeout(p.logger, p.opts.WaitTimeout) defer cancel() p.ctx = ctx diff --git a/internal/pkg/printer/office.go b/internal/pkg/printer/office.go index 9ed66c0e..fb0d8f27 100644 --- a/internal/pkg/printer/office.go +++ b/internal/pkg/printer/office.go @@ -5,7 +5,6 @@ import ( "fmt" "os" "path/filepath" - "sync" "github.com/thecodingmachine/gotenberg/internal/pkg/xcontext" "github.com/thecodingmachine/gotenberg/internal/pkg/xerror" @@ -63,6 +62,7 @@ func (p officePrinter) Print(destination string) error { return nil } m := mergePrinter{ + logger: p.logger, ctx: ctx, fpaths: fpaths, } @@ -78,15 +78,10 @@ func (p officePrinter) Print(destination string) error { } // nolint: gochecknoglobals -var mu sync.Mutex +var lock = make(chan struct{}, 1) func unoconv(ctx context.Context, logger xlog.Logger, fpath, destination string, opts OfficePrinterOptions) error { const op string = "printer.unoconv" - // TODO check if timeout while waiting for the lock. - logger.DebugOp(op, "waiting lock to be released...") - mu.Lock() - defer mu.Unlock() - logger.DebugOp(op, "lock released") resolver := func() error { args := []string{ "--format", @@ -108,10 +103,23 @@ func unoconv(ctx context.Context, logger xlog.Logger, fpath, destination string, xexec.LogBeforeExecute(logger, cmd) return cmd.Run() } - if err := resolver(); err != nil { - return xerror.New(op, err) + logger.DebugOp(op, "waiting lock to be acquired...") + select { + case lock <- struct{}{}: + // lock acquired. + logger.DebugOp(op, "lock acquired") + if err := resolver(); err != nil { + <-lock // we release the lock. + return xerror.New(op, err) + } + <-lock // we release the lock. + return nil + case <-ctx.Done(): + // failed to acquire lock before + // deadline. + logger.DebugOp(op, "failed to acquire lock before context.Context deadline") + return xerror.New(op, ctx.Err()) } - return nil } // Compile-time checks to ensure type implements desired interfaces. diff --git a/internal/pkg/printer/office_test.go b/internal/pkg/printer/office_test.go new file mode 100644 index 00000000..836fe0d2 --- /dev/null +++ b/internal/pkg/printer/office_test.go @@ -0,0 +1,59 @@ +package printer + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/thecodingmachine/gotenberg/internal/pkg/xerror" + "github.com/thecodingmachine/gotenberg/internal/pkg/xlog" + "github.com/thecodingmachine/gotenberg/test/internalpkg/printertest" + "github.com/thecodingmachine/gotenberg/test/internalpkg/xerrortest" + "github.com/thecodingmachine/gotenberg/test/internalpkg/xlogtest" +) + +func TestOfficePrinter(t *testing.T) { + var ( + logger xlog.Logger = xlogtest.DebugLogger() + fpaths []string = printertest.OfficeFpaths(t) + opts OfficePrinterOptions + dest string + p Printer + err error + ) + // default options. + opts = OfficePrinterOptions{ + WaitTimeout: 10.0, + Landscape: false, + } + p = NewOfficePrinter(logger, fpaths, opts) + dest = printertest.GenerateDestination() + err = p.Print(dest) + assert.Nil(t, err) + err = os.RemoveAll(dest) + assert.Nil(t, err) + // options with landscape. + opts = OfficePrinterOptions{ + WaitTimeout: 10.0, + Landscape: true, + } + p = NewOfficePrinter(logger, fpaths, opts) + dest = printertest.GenerateDestination() + err = p.Print(dest) + assert.Nil(t, err) + err = os.RemoveAll(dest) + assert.Nil(t, err) + // should not be OK as context.Context + // should timeout. + opts = OfficePrinterOptions{ + WaitTimeout: 1.0, + Landscape: true, + } + p = NewOfficePrinter(logger, fpaths, opts) + dest = printertest.GenerateDestination() + err = p.Print(dest) + xerrortest.AssertError(t, err) + assert.Equal(t, xerror.TimeoutCode, xerror.Code(err)) + err = os.RemoveAll(dest) + assert.Nil(t, err) +} diff --git a/test/internalpkg/printertest/doc.go b/test/internalpkg/printertest/doc.go new file mode 100644 index 00000000..8b10544a --- /dev/null +++ b/test/internalpkg/printertest/doc.go @@ -0,0 +1,6 @@ +/* +Package printertest contains useful +functions for tests related +to printer package. +*/ +package printertest diff --git a/test/internalpkg/printertest/printertest.go b/test/internalpkg/printertest/printertest.go new file mode 100644 index 00000000..9c909856 --- /dev/null +++ b/test/internalpkg/printertest/printertest.go @@ -0,0 +1,35 @@ +package printertest + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" + "github.com/thecodingmachine/gotenberg/internal/pkg/xrand" +) + +const testdataDirectoryPath string = "/gotenberg/tests/test/testdata" + +// GenerateDestination simply generates +// a path for a resulting PDF file. +func GenerateDestination() string { + return fmt.Sprintf("/tmp/%s.pdf", xrand.Get()) +} + +// OfficeFpaths return the paths +// of the Office documents used in tests. +func OfficeFpaths(t *testing.T) []string { + return []string{ + fpath(t, "office", "document.docx"), + fpath(t, "office", "document.rtf"), + fpath(t, "office", "document.txt"), + } +} + +func fpath(t *testing.T, kind, filename string) string { + require.NotEmpty(t, kind) + require.NotEmpty(t, filename) + fpath := fmt.Sprintf("%s/%s/%s", testdataDirectoryPath, kind, filename) + require.FileExists(t, fpath) + return fpath +} From 08e7a2c0656a96ac6a72d00dd90124ae840ce883 Mon Sep 17 00:00:00 2001 From: Julien Neuhart Date: Tue, 23 Jul 2019 16:40:54 +0200 Subject: [PATCH 30/34] fixing lint issue in xexec package --- internal/pkg/xexec/xexec.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/pkg/xexec/xexec.go b/internal/pkg/xexec/xexec.go index 604bb8d7..b67d7b5a 100644 --- a/internal/pkg/xexec/xexec.go +++ b/internal/pkg/xexec/xexec.go @@ -77,7 +77,7 @@ func pipe(logger xlog.Logger, cmd *exec.Cmd) error { func logCommandOutput(logger xlog.Logger, reader io.ReadCloser, outputType string, cmd *exec.Cmd) { var buf bytes.Buffer - buf.WriteString(fmt.Sprintf("%s", outputType)) + buf.WriteString(outputType) for _, arg := range cmd.Args { buf.WriteString(fmt.Sprintf(".%s", arg)) } From 5feff0d0ec1bbc169550ffad5cfd1f4904b8502e Mon Sep 17 00:00:00 2001 From: Julien Neuhart Date: Tue, 23 Jul 2019 16:50:24 +0200 Subject: [PATCH 31/34] adding merge printer tests --- internal/pkg/printer/merge_test.go | 46 +++++++++++++++++++++ test/internalpkg/printertest/printertest.go | 14 +++++++ 2 files changed, 60 insertions(+) create mode 100644 internal/pkg/printer/merge_test.go diff --git a/internal/pkg/printer/merge_test.go b/internal/pkg/printer/merge_test.go new file mode 100644 index 00000000..94b6136f --- /dev/null +++ b/internal/pkg/printer/merge_test.go @@ -0,0 +1,46 @@ +package printer + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/thecodingmachine/gotenberg/internal/pkg/xerror" + "github.com/thecodingmachine/gotenberg/internal/pkg/xlog" + "github.com/thecodingmachine/gotenberg/test/internalpkg/printertest" + "github.com/thecodingmachine/gotenberg/test/internalpkg/xerrortest" + "github.com/thecodingmachine/gotenberg/test/internalpkg/xlogtest" +) + +func TestMergePrinter(t *testing.T) { + var ( + logger xlog.Logger = xlogtest.DebugLogger() + fpaths []string = printertest.MergeFpaths(t) + opts MergePrinterOptions + dest string + p Printer + err error + ) + // default options. + opts = MergePrinterOptions{ + WaitTimeout: 10.0, + } + p = NewMergePrinter(logger, fpaths, opts) + dest = printertest.GenerateDestination() + err = p.Print(dest) + assert.Nil(t, err) + err = os.RemoveAll(dest) + assert.Nil(t, err) + // should not be OK as context.Context + // should timeout. + opts = MergePrinterOptions{ + WaitTimeout: 0.1, + } + p = NewMergePrinter(logger, fpaths, opts) + dest = printertest.GenerateDestination() + err = p.Print(dest) + xerrortest.AssertError(t, err) + assert.Equal(t, xerror.TimeoutCode, xerror.Code(err)) + err = os.RemoveAll(dest) + assert.Nil(t, err) +} diff --git a/test/internalpkg/printertest/printertest.go b/test/internalpkg/printertest/printertest.go index 9c909856..bfa2a92f 100644 --- a/test/internalpkg/printertest/printertest.go +++ b/test/internalpkg/printertest/printertest.go @@ -8,6 +8,11 @@ import ( "github.com/thecodingmachine/gotenberg/internal/pkg/xrand" ) +/* +testdataDirectoryPath should be +the absolute of the testdata INSIDE +the Docker image. +*/ const testdataDirectoryPath string = "/gotenberg/tests/test/testdata" // GenerateDestination simply generates @@ -16,6 +21,15 @@ func GenerateDestination() string { return fmt.Sprintf("/tmp/%s.pdf", xrand.Get()) } +// MergeFpaths return the paths +// of the PDF files used in tests. +func MergeFpaths(t *testing.T) []string { + return []string{ + fpath(t, "pdf", "gotenberg.pdf"), + fpath(t, "pdf", "gotenberg_bis.pdf"), + } +} + // OfficeFpaths return the paths // of the Office documents used in tests. func OfficeFpaths(t *testing.T) []string { From ad4d87a138f43d8824cb0cabd5e4929ae07715d8 Mon Sep 17 00:00:00 2001 From: Julien Neuhart Date: Tue, 23 Jul 2019 16:59:32 +0200 Subject: [PATCH 32/34] improving context timeout tests for office and merge printers + test with only one file to convert for the office printer --- internal/pkg/printer/merge_test.go | 2 +- internal/pkg/printer/office_test.go | 13 ++++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/internal/pkg/printer/merge_test.go b/internal/pkg/printer/merge_test.go index 94b6136f..7f18a1e4 100644 --- a/internal/pkg/printer/merge_test.go +++ b/internal/pkg/printer/merge_test.go @@ -34,7 +34,7 @@ func TestMergePrinter(t *testing.T) { // should not be OK as context.Context // should timeout. opts = MergePrinterOptions{ - WaitTimeout: 0.1, + WaitTimeout: 0.0, } p = NewMergePrinter(logger, fpaths, opts) dest = printertest.GenerateDestination() diff --git a/internal/pkg/printer/office_test.go b/internal/pkg/printer/office_test.go index 836fe0d2..d2449dd8 100644 --- a/internal/pkg/printer/office_test.go +++ b/internal/pkg/printer/office_test.go @@ -32,6 +32,17 @@ func TestOfficePrinter(t *testing.T) { assert.Nil(t, err) err = os.RemoveAll(dest) assert.Nil(t, err) + // using one file. + opts = OfficePrinterOptions{ + WaitTimeout: 10.0, + Landscape: false, + } + p = NewOfficePrinter(logger, []string{fpaths[0]}, opts) + dest = printertest.GenerateDestination() + err = p.Print(dest) + assert.Nil(t, err) + err = os.RemoveAll(dest) + assert.Nil(t, err) // options with landscape. opts = OfficePrinterOptions{ WaitTimeout: 10.0, @@ -46,7 +57,7 @@ func TestOfficePrinter(t *testing.T) { // should not be OK as context.Context // should timeout. opts = OfficePrinterOptions{ - WaitTimeout: 1.0, + WaitTimeout: 0.0, Landscape: true, } p = NewOfficePrinter(logger, fpaths, opts) From 84c1175379a9d12968e7f1d2f297f7812d22b055 Mon Sep 17 00:00:00 2001 From: Julien Neuhart Date: Tue, 23 Jul 2019 17:06:30 +0200 Subject: [PATCH 33/34] better debug logs for Markdown printer --- internal/pkg/printer/markdown.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/pkg/printer/markdown.go b/internal/pkg/printer/markdown.go index 2332db4d..2b6e9ade 100644 --- a/internal/pkg/printer/markdown.go +++ b/internal/pkg/printer/markdown.go @@ -28,12 +28,14 @@ func NewMarkdownPrinter(logger xlog.Logger, fpath string, opts ChromePrinterOpti } dirPath := filepath.Dir(fpath) data := &templateData{DirPath: dirPath} + logger.DebugOp(op, "converting Markdown files to HTML...") var buffer bytes.Buffer if err := tmpl.Execute(&buffer, data); err != nil { return "", err } baseFilename := xrand.Get() dst := fmt.Sprintf("%s/%s.html", dirPath, baseFilename) + logger.DebugOp(op, "writing the HTML from previous conversion into new file...") if err := ioutil.WriteFile(dst, buffer.Bytes(), 0644); err != nil { return "", err } From 3ee4540f7d198ace8b78a34d6b7b2c038179a46c Mon Sep 17 00:00:00 2001 From: Julien Neuhart Date: Wed, 24 Jul 2019 10:20:36 +0200 Subject: [PATCH 34/34] fixing method HasResource --- internal/app/xhttp/pkg/context/context.go | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/internal/app/xhttp/pkg/context/context.go b/internal/app/xhttp/pkg/context/context.go index cf49d205..25047b16 100644 --- a/internal/app/xhttp/pkg/context/context.go +++ b/internal/app/xhttp/pkg/context/context.go @@ -3,6 +3,7 @@ package context import ( "fmt" "net/http" + "reflect" "strconv" "time" @@ -124,6 +125,12 @@ func (ctx *Context) WithResource(directoryName string) error { return nil } +// HasResource returns true if the Context +// has a resource.Resource. +func (ctx Context) HasResource() bool { + return !reflect.DeepEqual(ctx.resource, resource.Resource{}) +} + /* MustResource returns the resource.Resource associated with the Context. @@ -138,12 +145,6 @@ func (ctx Context) MustResource() resource.Resource { return ctx.resource } -// HasResource returns true if the Context -// has a resource.Resource. -func (ctx Context) HasResource() bool { - return &ctx.resource != nil -} - /* LogRequestResult logs the result of a request. This method should only be used by a middleware!