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.