diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md index 42204861..1165dd4e 100644 --- a/.github/CODE_OF_CONDUCT.md +++ b/.github/CODE_OF_CONDUCT.md @@ -43,4 +43,4 @@ Project maintainers who do not follow or enforce the Code of Conduct in good fai This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] [homepage]: http://contributor-covenant.org -[version]: http://contributor-covenant.org/version/1/4/ \ No newline at end of file +[version]: http://contributor-covenant.org/version/1/4/ diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index ffa62154..5fa0d548 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -8,7 +8,7 @@ find below useful information about how to contribute to the Gotenberg project. ### Install from sources 1. Install and run the latest version of Docker -2. Verify your Go version (>= 1.12) +2. Verify your Go version (>= 1.13) 3. Fork this repository 4. Clone it outside of your `GOPATH` (we're using Go modules) @@ -36,4 +36,4 @@ add a new one! * [Code of conduct](CODE_OF_CONDUCT.md) * [Issue template](ISSUE_TEMPLATE.md) -* [Pull request template](PULL_REQUEST_TEMPLATE.md) \ No newline at end of file +* [Pull request template](PULL_REQUEST_TEMPLATE.md) diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md index b48e9d81..fe595d44 100644 --- a/.github/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE.md @@ -21,6 +21,9 @@ Please search on the [issue tracker](../../../issues) before creating one. 3. 4. +## Logs (LOG_LEVEL="DEBUG") + + ## Context @@ -29,4 +32,4 @@ Please search on the [issue tracker](../../../issues) before creating one. * Version used: * Operating System and version: -* Link to your project: \ No newline at end of file +* Link to your project: diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index ccc07b30..b758496e 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -42,4 +42,4 @@ Fixes # - [ ] Have you successfully ran tests with your changes locally (`make tests`)? - [ ] Have you updated the documentation (`make doc`)? - [ ] I have squashed any insignificant commits -- [ ] This change has comments for package types, values, functions, and non-obvious lines of code \ No newline at end of file +- [ ] This change has comments for package types, values, functions, and non-obvious lines of code diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 00000000..6420779c --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,14 @@ +name: Publish +on: + push: + branches: + - releases/* + +jobs: + publish: + name: Publish to Docker Hub + runs-on: ubuntu-18.04 + steps: + - uses: actions/checkout@v1 + - run: echo $GITHUB_REF + #- run: make publish VERSION=$GITHUB_REF DOCKER_USER=${{ secrets.DOCKER_USER }} DOCKER_PASSWORD=${{ secrets.DOCKER_PASSWORD }} \ No newline at end of file diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml new file mode 100644 index 00000000..2060325a --- /dev/null +++ b/.github/workflows/push.yml @@ -0,0 +1,12 @@ +name: Push +on: push + +jobs: + tests: + name: Lint, tests and code coverage + runs-on: ubuntu-18.04 + steps: + - uses: actions/checkout@v1 + #- run: make lint + #- run: make tests CODE_COVERAGE=1 + #- run: bash <(curl -s https://codecov.io/bash) -t ${{ secrets.CODECOV_TOKEN }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index e69de29b..3c56ae0d 100644 --- a/.gitignore +++ b/.gitignore @@ -0,0 +1,2 @@ +.idea +coverage.txt \ No newline at end of file diff --git a/.travis.yml b/.travis.yml index 8003da9f..2c3a2d57 100644 --- a/.travis.yml +++ b/.travis.yml @@ -17,9 +17,10 @@ stages: jobs: include: - stage: tests - script: make lint - - stage: tests - script: make tests + script: + - make lint + - make tests CODE_COVERAGE=1 + - bash <(curl -s https://codecov.io/bash) - 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 b259902b..5fb6d156 100644 --- a/Makefile +++ b/Makefile @@ -1,12 +1,29 @@ -GOLANG_VERSION=1.12 +GOLANG_VERSION=1.13 VERSION=snapshot DOCKER_USER= DOCKER_PASSWORD= +DOCKER_REPOSITORY=thecodingmachine +GOLANGCI_LINT_VERSION=1.19.1 +CODE_COVERAGE=0 +TINI_VERSION=0.18.0 +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 thecodingmachine/gotenberg:docs -f build/docs/Dockerfile . - docker run --rm -it -v "$(PWD):/docs" thecodingmachine/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: @@ -15,24 +32,30 @@ fmt: # run all linters. lint: - docker build --build-arg GOLANG_VERSION=$(GOLANG_VERSION) -t thecodingmachine/gotenberg:lint -f build/lint/Dockerfile . - docker run --rm -it -v "$(PWD):/lint" thecodingmachine/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 $(DOCKER_REPOSITORY)/gotenberg:lint # run all tests. tests: - docker build -t thecodingmachine/gotenberg:base -f build/base/Dockerfile . - docker build --build-arg GOLANG_VERSION=$(GOLANG_VERSION) -t thecodingmachine/gotenberg:tests -f build/tests/Dockerfile . - docker run --rm -it -v "$(PWD):/tests" thecodingmachine/gotenberg:tests + make workspace + ./scripts/tests.sh $(DOCKER_REPOSITORY) $(CODE_COVERAGE) -# build Docker image. +# generate documentation. +doc: + 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 thecodingmachine/gotenberg:base -f build/base/Dockerfile . - docker build --build-arg GOLANG_VERSION=$(GOLANG_VERSION) --build-arg VERSION=$(VERSION) -t thecodingmachine/gotenberg:$(VERSION) -f build/package/Dockerfile . + make workspace + docker build --build-arg VERSION=$(VERSION) --build-arg TINI_VERSION=$(TINI_VERSION) -t $(DOCKER_REPOSITORY)/gotenberg:$(VERSION) -f build/package/Dockerfile . # start the API using previously built Docker image. gotenberg: - docker run -it --rm -p "3000:3000" thecodingmachine/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 "$(DEFAULT_LISTEN_PORT):$(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/README.md b/README.md index efd6c62c..68d014a3 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,6 @@ At TheCodingMachine, we build a lot of web applications (intranets, extranets an * HTML and Markdown conversions using Google Chrome headless * Office conversions (.txt, .rtf, .docx, .doc, .odt, .pptx, .ppt, .odp and so on) using [unoconv](https://github.com/dagwieers/unoconv) -* Performance :zap:: Google Chrome and LibreOffice (unoconv) started once in the background thanks to PM2 -* Failure prevention :broken_heart:: PM2 automatically restarts previous processes if they fail * Assets :package:: send your header, footer, images, fonts, stylesheets and so on for converting your HTML and Markdown to beaufitul PDFs! * Easily interact with the API using our [Go](https://github.com/thecodingmachine/gotenberg-go-client) and [PHP](https://github.com/thecodingmachine/gotenberg-php-client) libraries @@ -23,7 +21,7 @@ At TheCodingMachine, we build a lot of web applications (intranets, extranets an Open a terminal and run the following command: ```bash -$ docker run --rm -p 3000:3000 thecodingmachine/gotenberg:5 +$ docker run --rm -p 3000:3000 thecodingmachine/gotenberg:6 ``` The API is now available on your host at `http://localhost:3000`. @@ -33,9 +31,10 @@ to learn how to interact with it! ## Badges -[![Docker image layers](https://images.microbadger.com/badges/image/thecodingmachine/gotenberg:5.svg)](https://microbadger.com/images/thecodingmachine/gotenberg:5) +[![Docker image layers](https://images.microbadger.com/badges/image/thecodingmachine/gotenberg:6.svg)](https://microbadger.com/images/thecodingmachine/gotenberg:6) [![Travis CI](https://travis-ci.org/thecodingmachine/gotenberg.svg?branch=master)](https://travis-ci.org/thecodingmachine/gotenberg) [![GoDoc](https://godoc.org/github.com/thecodingmachine/gotenberg?status.svg)](https://godoc.org/github.com/thecodingmachine/gotenberg) +[![Codecov](https://codecov.io/gh/thecodingmachine/gotenberg/branch/master/graph/badge.svg)](https://codecov.io/gh/thecodingmachine/gotenberg) [![Go Report Card](https://goreportcard.com/badge/github.com/thecodingmachine/gotenberg)](https://goreportcard.com/report/thecodingmachine/gotenberg) --- diff --git a/build/base/Dockerfile b/build/base/Dockerfile index 102f6873..c40df230 100644 --- a/build/base/Dockerfile +++ b/build/base/Dockerfile @@ -1,4 +1,4 @@ -FROM debian:9.5-slim +FROM debian:buster-slim # |-------------------------------------------------------------------------- # | Common libraries @@ -7,21 +7,9 @@ FROM debian:9.5-slim # | Libraries used in the build process of this image. # | -RUN echo "deb http://httpredir.debian.org/debian/ stretch main contrib non-free" > /etc/apt/sources.list &&\ +RUN echo "deb http://httpredir.debian.org/debian/ buster main contrib non-free" > /etc/apt/sources.list &&\ apt-get update &&\ - apt-get install -y curl wget python3-pip ttf-mscorefonts-installer - -# |-------------------------------------------------------------------------- -# | PM2 -# |-------------------------------------------------------------------------- -# | -# | Installs PM2 for launching programs in background and with failure -# | recovering. In our case: Chrome (headless) and Office (headless). -# | - -RUN curl -sL https://deb.nodesource.com/setup_9.x | bash - &&\ - apt-get install -y nodejs &&\ - npm install -g pm2 + apt-get install -y curl wget gnupg ttf-mscorefonts-installer procps # |-------------------------------------------------------------------------- # | Chrome @@ -35,17 +23,32 @@ RUN wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | apt-key add apt-get update &&\ apt-get -y --allow-unauthenticated install google-chrome-stable +# |-------------------------------------------------------------------------- +# | LibreOffice +# |-------------------------------------------------------------------------- +# | +# | Installs LibreOffice. +# | + +# https://github.com/nextcloud/docker/issues/380 +RUN mkdir -p /usr/share/man/man1mkdir -p /usr/share/man/man1 &&\ + echo "deb http://httpredir.debian.org/debian/ buster-backports main contrib non-free" >> /etc/apt/sources.list &&\ + apt-get update &&\ + apt-get -t buster-backports -y install libreoffice + # |-------------------------------------------------------------------------- # | Unoconv # |-------------------------------------------------------------------------- # | -# | Installs unoconv and LibreOffice. +# | Installs unoconv. # | -RUN pip3 install unoconv &&\ - # https://github.com/nextcloud/docker/issues/380 - mkdir -p /usr/share/man/man1mkdir -p /usr/share/man/man1 &&\ - apt-get -y install libreoffice +ENV UNO_URL=https://raw.githubusercontent.com/dagwieers/unoconv/master/unoconv + +RUN curl -Ls $UNO_URL -o /usr/bin/unoconv &&\ + chmod +x /usr/bin/unoconv &&\ + ln -s /usr/bin/python3 /usr/bin/python &&\ + unoconv --version # |-------------------------------------------------------------------------- # | PDFtk @@ -83,11 +86,25 @@ RUN apt-get install -y \ fonts-sil-padauk \ fonts-telu \ fonts-thai-tlwg \ - ttf-liberation \ + fonts-liberation \ ttf-wqy-zenhei \ fonts-arphic-uming \ fonts-ipafont-mincho \ fonts-ipafont-gothic \ fonts-unfonts-core +COPY build/base/fonts/* /usr/local/share/fonts/ COPY build/base/fonts.conf /etc/fonts/conf.d/100-gotenberg.conf + +# |-------------------------------------------------------------------------- +# | Default user +# |-------------------------------------------------------------------------- +# | +# | All processes in the Docker container will run as a dedicated +# | non-root user. +# | + +RUN groupadd --gid 1001 gotenberg \ + && useradd --uid 1001 --gid gotenberg --shell /bin/bash --home /gotenberg --no-create-home gotenberg \ + && mkdir /gotenberg \ + && chown gotenberg: /gotenberg \ No newline at end of file diff --git a/build/base/fonts/NotoColorEmoji.ttf b/build/base/fonts/NotoColorEmoji.ttf new file mode 100644 index 00000000..69cf21a1 Binary files /dev/null and b/build/base/fonts/NotoColorEmoji.ttf differ 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/docs/content/00-introduction.md b/build/docs/content/00-introduction.md index ac1bf49a..21b6a0a4 100644 --- a/build/docs/content/00-introduction.md +++ b/build/docs/content/00-introduction.md @@ -6,7 +6,5 @@ title: Introduction * HTML and Markdown conversions using Google Chrome headless * Office conversions (.txt, .rtf, .docx, .doc, .odt, .pptx, .ppt, .odp and so on) using [unoconv](https://github.com/dagwieers/unoconv) -* Performance: Google Chrome and LibreOffice (unoconv) started once in the background thanks to PM2 -* Failure prevention: PM2 automatically restarts previous processes if they fail * Assets: send your header, footer, images, fonts, stylesheets and so on for converting your HTML and Markdown to beaufitul PDFs! -* Easily interact with the API using our [Go](https://github.com/thecodingmachine/gotenberg-go-client) and [PHP](https://github.com/thecodingmachine/gotenberg-php-client) libraries \ No newline at end of file +* Easily interact with the API using our [Go](https://github.com/thecodingmachine/gotenberg-go-client) and [PHP](https://github.com/thecodingmachine/gotenberg-php-client) libraries diff --git a/build/docs/content/01-install.md b/build/docs/content/01-install.md index 03585a66..6054cf75 100644 --- a/build/docs/content/01-install.md +++ b/build/docs/content/01-install.md @@ -4,10 +4,12 @@ title: Install Gotenberg is shipped within a Docker image. +> It uses a dedicated non-root user called `gotenberg` with uid and gid `1001`. + You may start it with: ```bash -$ docker run --rm -p 3000:3000 thecodingmachine/gotenberg:5 +$ docker run --rm -p 3000:3000 thecodingmachine/gotenberg:6 ``` > The API will be available at [http://localhost:3000](http://localhost:3000). @@ -24,7 +26,7 @@ services: # your others services gotenberg: - image: thecodingmachine/gotenberg:5 + image: thecodingmachine/gotenberg:6 ``` > The API will be available under `gotenberg:3000` in your Docker Compose network. @@ -34,9 +36,16 @@ services: It may also be deployed with Kubernetes. Make sure to provide enough memory and CPU requests (for instance `512Mi` and `0.2` CPU). -Otherwise the API will not be able to launch Google Chrome and LibreOffice (unoconv). > The more resources are granted, the quicker will be the conversions. +In the deployment specification of the pod, also specify the uid `1001` of the user `gotenberg`: + +``` +securityContext: + privileged: false + runAsUser: 1001 +``` + In the following examples, we will assume your -Gotenberg API is available at [http://localhost:3000](http://localhost:3000). \ No newline at end of file +Gotenberg API is available at [http://localhost:3000](http://localhost:3000). diff --git a/build/docs/content/02-clients.md b/build/docs/content/02-clients.md index 195fb85d..64de1e23 100644 --- a/build/docs/content/02-clients.md +++ b/build/docs/content/02-clients.md @@ -7,7 +7,7 @@ We provide clients in various languages for easing the interactions with the API ## Go client ```bash -$ go get -u github.com/thecodingmachine/gotenberg-go-client/v5 +$ go get -u github.com/thecodingmachine/gotenberg-go-client/v6 ``` ## PHP client @@ -22,4 +22,4 @@ Then the PHP client: ```bash $ composer require thecodingmachine/gotenberg-php-client -``` \ No newline at end of file +``` diff --git a/build/docs/content/03-environment-variables.md b/build/docs/content/03-environment-variables.md index 3f081d56..7e3c6b97 100644 --- a/build/docs/content/03-environment-variables.md +++ b/build/docs/content/03-environment-variables.md @@ -4,11 +4,31 @@ title: Environment variables You may customize the API behaviour thanks to environment variables. +## Log level + +The API provides structured logging allowing you to have relevant information +about what's going on. + +> If a TTY is attached, the log entries are displayed in text format with colors, otherwise in JSON format. + +You may customize the severity of the log entries thanks to the environment variable `LOG_LEVEL`. + +It accepts one of the following severities: `"DEBUG"`, `"INFO"` (default) and `"ERROR"`. + +## Default listen port + +By default, the API will listen on port `3000`. + +You may customize this value with the environment variable `DEFAULT_LISTEN_PORT`. + +This environment variable accepts any string that can be turned into a port number. + ## Disable Google Chrome -In order to save some resources, the Gotenberg image accepts the environment variable `DISABLE_GOOGLE_CHROME`. +In order to save some resources, the Gotenberg image accepts the environment variable `DISABLE_GOOGLE_CHROME` +for disabling Google Chrome. -It takes the strings `"0"` or `"1"` as value. +It takes the strings `"0"` or `"1"` as value where `1` means `true` > If Google Chrome is disabled, the following conversions will **not** be available anymore: > [HTML](#html), [URL](#url) and [Markdown](#markdown) @@ -23,6 +43,7 @@ You may also disable LibreOffice (unoconv) with `DISABLE_UNOCONV`. ## Default wait timeout By default, the API will wait 10 seconds before it considers the conversion to be unsuccessful. +If unsucessful, it returns a `504` HTTP code. You may customize this timeout thanks to the environment variable `DEFAULT_WAIT_TIMEOUT`. @@ -31,18 +52,41 @@ It takes a string representation of a float as value (e.g `"2.5"` for 2.5 second > The default timeout may also be overridden per request thanks to the form field `waitTimeout`. > See the [timeout section](#timeout). -## Disable logging on healthcheck +## Maximum wait timeout -By default, the API will add a log entry when the [healthcheck endpoint](#ping) is called. +By default, the value of the form field `waitTimeout` cannot be more than 30 seconds. -You may turn off this logging so as to avoid unnecessary entries in your logs with the environment variable `DISABLE_HEALTHCHECK_LOGGING`. +You may increase or decrease this limit thanks to the environment variable `MAXIMUM_WAIT_TIMEOUT`. -This environment variable operates in the same manner as the `DISABLE_GOOGLE_CHROME` and `DISABLE_UNOCONV` variables operate in that it accepts the strings `"0"` or `"1"` as values. +It takes a string representation of a float as value (e.g `"2.5"` for 2.5 seconds). -## Default listen port +## Default webhook URL timeout -By default, the API will listen on port `3000`. For most use cases this is perfectly fine, but at times there may be cases where you need to change this due to port conflicts. +By default, the API will wait 10 seconds before it considers the sending of the resulting PDF to be unsuccessful. -You may customize this port location with the environment variable `DEFAULT_LISTEN_PORT`. +> See the [webhook section](#webhook). -This environment variable accepts any string that can be turned into a port number (e.g., the string `"0"` up to the string `"65535"`). \ No newline at end of file +You may customize this timeout thanks to the environment variable `DEFAULT_WEBHOOK_URL_TIMEOUT`. + +It takes a string representation of a float as value (e.g `"2.5"` for 2.5 seconds). + +> The default timeout may also be overridden per request thanks to the form field `webhookURLTimeout`. +> See the [webhook timeout section](#webhook.timeout). + +## Maximum webhook URL timeout + +By default, the value of the form field `webhookURLTimeout` cannot be more than 30 seconds. + +You may increase or decrease this limit thanks to the environment variable `MAXIMUM_WEBHOOK_URL_TIMEOUT`. + +It takes a string representation of a float as value (e.g `"2.5"` for 2.5 seconds). + +## Maximum wait delay + +By default, the value of the form field `waitDelay` cannot be more than 10 seconds. + +> See the [wait delay section](#html.wait_delay). + +You may increase or decrease this limit thanks to the environment variable `MAXIMUM_WAIT_DELAY`. + +It takes a string representation of a float as value (e.g `"2.5"` for 2.5 seconds). diff --git a/build/docs/content/04-html.md b/build/docs/content/04-html.md index b2661e3c..858f4d74 100644 --- a/build/docs/content/04-html.md +++ b/build/docs/content/04-html.md @@ -39,7 +39,7 @@ $ curl --request POST \ ### Go ```golang -import "github.com/thecodingmachine/gotenberg-go-client/v5" +import "github.com/thecodingmachine/gotenberg-go-client/v6" func main() { c := &gotenberg.Client{Hostname: "http://localhost:3000"} @@ -98,6 +98,12 @@ The following classes allow you to inject printing values: > **Attention:** the CSS properties are independant of the ones used in the `index.html` file. > Also, `footer.html` CSS properties override the ones from `header.html`. +For images, the only solution currently is to use a `base64` encoded source: + +```html +Red dot +``` + ### cURL ```bash @@ -113,7 +119,7 @@ $ curl --request POST \ ### Go ```golang -import "github.com/thecodingmachine/gotenberg-go-client/v5" +import "github.com/thecodingmachine/gotenberg-go-client/v6" func main() { c := &gotenberg.Client{Hostname: "http://localhost:3000"} @@ -203,7 +209,7 @@ $ curl --request POST \ ### Go ```golang -import "github.com/thecodingmachine/gotenberg-go-client/v5" +import "github.com/thecodingmachine/gotenberg-go-client/v6" func main() { c := &gotenberg.Client{Hostname: "http://localhost:3000"} @@ -262,7 +268,7 @@ $ curl --request POST \ ### Go ```golang -import "github.com/thecodingmachine/gotenberg-go-client/v5" +import "github.com/thecodingmachine/gotenberg-go-client/v6" func main() { c := &gotenberg.Client{Hostname: "http://localhost:3000"} @@ -296,7 +302,8 @@ $client->store($request, $dest); ## Wait delay In some cases, you may want to wait a certain amount of time to make sure the -page you're trying to generate is fully rendered. +page you're trying to generate is fully rendered. For instance, if your page relies +a lot on JavaScript for rendering. > The wait delay is a duration in **seconds** (e.g `2.5` for 2.5 seconds). @@ -314,7 +321,7 @@ $ curl --request POST \ ### Go ```golang -import "github.com/thecodingmachine/gotenberg-go-client/v5" +import "github.com/thecodingmachine/gotenberg-go-client/v6" func main() { c := &gotenberg.Client{Hostname: "http://localhost:3000"} @@ -339,4 +346,4 @@ $request = new HTMLRequest($index); $request->setWaitDelay(5.5); $dest = "result.pdf"; $client->store($request, $dest); -``` \ No newline at end of file +``` diff --git a/build/docs/content/05-url.md b/build/docs/content/05-url.md index a26db834..6517843a 100644 --- a/build/docs/content/05-url.md +++ b/build/docs/content/05-url.md @@ -31,7 +31,7 @@ $ curl --request POST \ ### Go ```golang -import "github.com/thecodingmachine/gotenberg-go-client/v5" +import "github.com/thecodingmachine/gotenberg-go-client/v6" func main() { c := &gotenberg.Client{Hostname: "http://localhost:3000"} diff --git a/build/docs/content/06-markdown.md b/build/docs/content/06-markdown.md index 1dadb7c5..66c50ee4 100644 --- a/build/docs/content/06-markdown.md +++ b/build/docs/content/06-markdown.md @@ -42,7 +42,7 @@ $ curl --request POST \ ### Go ```golang -import "github.com/thecodingmachine/gotenberg-go-client/v5" +import "github.com/thecodingmachine/gotenberg-go-client/v6" func main() { c := &gotenberg.Client{Hostname: "http://localhost:3000"} diff --git a/build/docs/content/07-office.md b/build/docs/content/07-office.md index b17b8146..39e91e37 100644 --- a/build/docs/content/07-office.md +++ b/build/docs/content/07-office.md @@ -25,9 +25,7 @@ You may send one or more Office documents. Following file extensions are accepte All files will be merged into a single resulting PDF. -> **Attention:** currently, `unoconv` cannot perform concurrent conversions. -> That's why for Office conversions, the API does only one conversion at a time. -> See the [scalability section](#scalability) to find how to mitigate this issue. +> **Attention:** Gotenberg merges the PDF files alphabetically. ### cURL @@ -43,7 +41,7 @@ $ curl --request POST \ ### Go ```golang -import "github.com/thecodingmachine/gotenberg-go-client/v5" +import "github.com/thecodingmachine/gotenberg-go-client/v6" func main() { c := &gotenberg.Client{Hostname: "http://localhost:3000"} @@ -90,7 +88,7 @@ $ curl --request POST \ ### Go ```golang -import "github.com/thecodingmachine/gotenberg-go-client/v5" +import "github.com/thecodingmachine/gotenberg-go-client/v6" func main() { c := &gotenberg.Client{Hostname: "http://localhost:3000"} diff --git a/build/docs/content/08-merge.md b/build/docs/content/08-merge.md index 3b8b7dfc..49ac0b2b 100644 --- a/build/docs/content/08-merge.md +++ b/build/docs/content/08-merge.md @@ -2,7 +2,7 @@ title: Merge --- -Gotenberg provides the endpoint `/convert/merge` for merging PDFs. +Gotenberg provides the endpoint `/merge` for merging PDFs. It accepts `POST` requests with a `multipart/form-data` Content-Type. @@ -17,7 +17,7 @@ will merge them and return the resulting PDF file. ```bash $ curl --request POST \ - --url http://localhost:3000/convert/merge \ + --url http://localhost:3000/merge \ --header 'Content-Type: multipart/form-data' \ --form files=@file.pdf \ --form files=@file2.pdf \ @@ -27,7 +27,7 @@ $ curl --request POST \ ### Go ```golang -import "github.com/thecodingmachine/gotenberg-go-client/v5" +import "github.com/thecodingmachine/gotenberg-go-client/v6" func main() { c := &gotenberg.Client{Hostname: "http://localhost:3000"} @@ -52,4 +52,4 @@ $files = [ $request = new MergeRequest($files); $dest = "result.pdf"; $client->store($request, $dest); -``` \ No newline at end of file +``` diff --git a/build/docs/content/09-timeout.md b/build/docs/content/09-timeout.md index 92c78b83..9392b84f 100644 --- a/build/docs/content/09-timeout.md +++ b/build/docs/content/09-timeout.md @@ -5,6 +5,7 @@ title: Timeout All endpoints accept a form field named `waitTimeout`. The API will wait the given **seconds** before it considers the conversion to be unsucessful. +If unsucessful, it returns a `504` HTTP code. It takes a float as value (e.g `2.5` for 2.5 seconds). @@ -25,7 +26,7 @@ $ curl --request POST \ ### Go ```golang -import "github.com/thecodingmachine/gotenberg-go-client/v5" +import "github.com/thecodingmachine/gotenberg-go-client/v6" func main() { c := &gotenberg.Client{Hostname: "http://localhost:3000"} @@ -49,4 +50,4 @@ $request = new HTMLRequest($index); $request->setWaitTimeout(2.5); $dest = "result.pdf"; $client->store($request, $dest); -``` \ No newline at end of file +``` diff --git a/build/docs/content/10-webhook.md b/build/docs/content/10-webhook.md index 497baaca..fff2456d 100644 --- a/build/docs/content/10-webhook.md +++ b/build/docs/content/10-webhook.md @@ -24,7 +24,7 @@ $ curl --request POST \ ### Go ```golang -import "github.com/thecodingmachine/gotenberg-go-client/v5" +import "github.com/thecodingmachine/gotenberg-go-client/v6" func main() { c := &gotenberg.Client{Hostname: "http://localhost:3000"} @@ -46,4 +46,56 @@ $index = DocumentFactory::makeFromPath('index.html', 'index.html'); $request = new HTMLRequest($index); $request->setWebhookURL('http://myapp.com/webhook/'); $resp = $client->post($request); -``` \ No newline at end of file +``` + +## Timeout + +If a `webhookURL` is provided, you may also send a form field named `webhookURLTimeout`. + +The API will wait the given **seconds** before it considers the sending of the resulting PDF to be unsucessful. + +It takes a float as value (e.g `2.5` for 2.5 seconds). + +> You may also define this value globally: see the [environment variables](#environment_variables.default_webhook_url_timeout) section. + +### Examples + +#### cURL + +```bash +$ curl --request POST \ + --url http://localhost:3000/convert/html \ + --header 'Content-Type: multipart/form-data' \ + --form files=@index.html \ + --form webhookURL='http://myapp.com/webhook/' \ + --form webhookURLTimeout=2.5 +``` + +#### Go + +```golang +import "github.com/thecodingmachine/gotenberg-go-client/v6" + +func main() { + c := &gotenberg.Client{Hostname: "http://localhost:3000"} + req, _ := gotenberg.NewHTMLRequest("index.html") + req.WebhookURL("http://myapp.com/webhook/") + req.WebhookURLTimeout(2.5) + resp, _ := c.Post(req) +} +``` + +#### PHP + +```php +use TheCodingMachine\Gotenberg\Client; +use TheCodingMachine\Gotenberg\DocumentFactory; +use TheCodingMachine\Gotenberg\HTMLRequest; + +$client = new Client('http://localhost:3000', new \Http\Adapter\Guzzle6\Client()); +$index = DocumentFactory::makeFromPath('index.html', 'index.html'); +$request = new HTMLRequest($index); +$request->setWebhookURL('http://myapp.com/webhook/'); +$request->setWebhookURLTimeout(2.5); +$resp = $client->post($request); +``` diff --git a/build/docs/content/11-result-filename.md b/build/docs/content/11-result-filename.md index 22f2c184..aabb4177 100644 --- a/build/docs/content/11-result-filename.md +++ b/build/docs/content/11-result-filename.md @@ -24,7 +24,7 @@ $ curl --request POST \ ### Go ```golang -import "github.com/thecodingmachine/gotenberg-go-client/v5" +import "github.com/thecodingmachine/gotenberg-go-client/v6" func main() { c := &gotenberg.Client{Hostname: "http://localhost:3000"} @@ -47,4 +47,4 @@ $index = DocumentFactory::makeFromPath('index.html', 'index.html'); $request = new HTMLRequest($index); $request->setResultFilename('foo.pdf'); $resp = $client->post($request); -``` \ No newline at end of file +``` diff --git a/build/docs/content/12-scalability.md b/build/docs/content/12-scalability.md index e5d1225f..924012b4 100644 --- a/build/docs/content/12-scalability.md +++ b/build/docs/content/12-scalability.md @@ -2,6 +2,32 @@ title: Scalability --- +The API uses under the hood intricate programs. + +Gotenberg tries to abstract as much complexity as possible but it can +only do it to a certain extend. + +For instance, [Office](#office) and [Merge](#merge) endpoints will start respectively as many LibreOffice (unoconv) and PDTk +instances are there are requests. The limitation here is the available memory and CPU usage. + +On another hand, for the [HTML](#html), [URL](#url) and [Markdown](#markdown) endpoints, the API does only 6 conversions in parallel. +Indeed, Google Chrome misbehaves if there are too many concurrent conversions. + +**The more concurrent requests, the more `504` HTTP codes the API will return.** + +> See our [load testing use case](../loadtesting) for more details about the API behaviour under heavy load. + +## Strategies + +### Increase timeout + +You may increase the conversion timeout. In other words, you accept that a conversion takes more time +if the API is under heavy load. + +> See [timeout section](#timeout). + +### Scaling + The API being stateless, you may scale it as much as you want. For instance, using the following Docker Compose file: @@ -14,7 +40,7 @@ services: # your others services gotenberg: - image: thecodingmachine/gotenberg:5 + image: thecodingmachine/gotenberg:6 ``` You may now launch your services using: @@ -24,4 +50,4 @@ $ docker-compose up --scale gotenberg=your_number_of_instances ``` When requesting the Gotenberg service with your client(s), Docker will automatically -redirect a request to a Gotenberg container according to the round-robin strategy. \ No newline at end of file +redirect a request to a Gotenberg container according to the round-robin strategy. diff --git a/build/docs/content/13-ping.md b/build/docs/content/13-ping.md index 3222cf55..1535b607 100644 --- a/build/docs/content/13-ping.md +++ b/build/docs/content/13-ping.md @@ -5,7 +5,8 @@ title: Ping Gotenberg provides the endpoint `/ping` for checking the API availability with a simple `GET` request. -This feature is especially useful for liveness/readiness probes in Kubernetes: +Currently this endpoint does nothing special. A better way to monitor +Gotenberg would be by checking the memory usage. -* [Pod lifecycle](https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/#container-probes) -* [Configure Liveness and Readiness Probes](https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-probes/) \ No newline at end of file +Also, as the API uses under the hood intricate programs, you should +restart your Gotenberg instances from time to time to ensure a nominal behaviour. diff --git a/build/docs/content/14-fonts.md b/build/docs/content/14-fonts.md index fa120864..18d7c3ec 100644 --- a/build/docs/content/14-fonts.md +++ b/build/docs/content/14-fonts.md @@ -7,7 +7,7 @@ By default, a handful of fonts are installed. Asian characters are also supporte If you wish to use more fonts, you will have to create your own image: ```Dockerfile -FROM thecodingmachine/gotenberg:5 +FROM thecodingmachine/gotenberg:6 RUN apt-get -y install yourfonts -``` \ No newline at end of file +``` diff --git a/build/docs/content/15-links.md b/build/docs/content/15-links.md index 4f2dbf3f..23ac7d55 100644 --- a/build/docs/content/15-links.md +++ b/build/docs/content/15-links.md @@ -4,5 +4,6 @@ title: Links * Follow the progress on the [GitHub repository](https://github.com/thecodingmachine/gotenberg) * Follow [@gulnap](https://twitter.com/gulnap) on Twitter +* Thanks to [@mafredri](https://github.com/mafredri) for its help and its wonderful [cdp](https://github.com/mafredri/cdp) library -Psst: TheCodingMachine is always looking for [talented coders](https://coders.thecodingmachine.com). \ No newline at end of file +Psst: TheCodingMachine is always looking for [talented coders](https://coders.thecodingmachine.com). diff --git a/build/lint/Dockerfile b/build/lint/Dockerfile index 869447e6..fd08c9c5 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 @@ -22,14 +20,17 @@ RUN curl -sfL https://install.goreleaser.com/github.com/golangci/golangci-lint.s # | Last instructions of this build. # | -# Define our workding outside of $GOPATH (we're using go modules). -WORKDIR /lint - -# Copy our module dependencies definitions. -COPY go.mod . -COPY go.sum . +# Define our working directory outside of $GOPATH (we're using go modules). +USER gotenberg +WORKDIR /gotenberg/lint # Install module dependencies. -RUN go mod download +COPY --chown=gotenberg:gotenberg go.mod go.sum ./ -CMD ["golangci-lint", "run" ,"--tests=false", "--enable-all", "--disable=dupl" ] \ No newline at end of file +RUN go mod download &&\ + go mod verify + +# Copy our code source. +COPY --chown=gotenberg:gotenberg . . + +CMD ["golangci-lint", "run" ,"--tests=false", "--enable-all", "--disable=dupl", "--disable=funlen" ] \ No newline at end of file diff --git a/build/package/Dockerfile b/build/package/Dockerfile index f9028a72..dc36ad1f 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,13 +14,37 @@ ENV GOOS=linux \ CGO_ENABLED=0 # Define our workding outside of $GOPATH (we're using go modules). -WORKDIR /gotenberg +WORKDIR /gotenberg/package + +# Install module dependencies. +COPY go.mod go.sum ./ + +RUN go mod download &&\ + go mod verify # Copy our source code. -COPY . . +COPY internal ./internal +COPY cmd ./cmd # 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 + +FROM thecodingmachine/gotenberg:base + +LABEL authors="Julien Neuhart " + +# |-------------------------------------------------------------------------- +# | Tini +# |-------------------------------------------------------------------------- +# | +# | An helper for reaping zombie processes. +# | + +ARG TINI_VERSION + +ADD https://github.com/krallin/tini/releases/download/v${TINI_VERSION}/tini /tini +RUN chmod +x /tini +ENTRYPOINT [ "/tini", "--" ] # |-------------------------------------------------------------------------- # | Final touch @@ -31,12 +53,9 @@ RUN go build -o /gotenberg/gotenberg -ldflags "-X main.version=${VERSION}" cmd/g # | Last instructions of this build. # | -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/ +USER gotenberg WORKDIR /gotenberg EXPOSE 3000 diff --git a/build/tests/Dockerfile b/build/tests/Dockerfile index 0c8b7c32..cccf031a 100644 --- a/build/tests/Dockerfile +++ b/build/tests/Dockerfile @@ -1,48 +1,16 @@ -ARG GOLANG_VERSION - -FROM golang:${GOLANG_VERSION}-stretch AS golang - -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. -# | +FROM thecodingmachine/gotenberg:workspace # Define our workding outside of $GOPATH (we're using go modules). -WORKDIR /tests - -# Copy our module dependencies definitions. -COPY go.mod . -COPY go.sum . +USER gotenberg +WORKDIR /gotenberg/tests # Install module dependencies. -RUN go mod download +COPY --chown=gotenberg:gotenberg go.mod go.sum ./ + +RUN go mod download &&\ + go mod verify + +# Copy our code source. +COPY --chown=gotenberg:gotenberg . . 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 fa2f311a..f51b3776 100755 --- a/build/tests/docker-entrypoint.sh +++ b/build/tests/docker-entrypoint.sh @@ -2,16 +2,19 @@ set -xe -# 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 +# Make sure the user running the +# tests is the Gotenberg user. +CURRENT_USER=$(whoami) +if [ "$CURRENT_USER" != "gotenberg" ]; then + exit 1 +fi -# Running others tests. -go test -race -cover -covermode=atomic github.com/thecodingmachine/gotenberg/internal/app/api -go test -race -cover -covermode=atomic github.com/thecodingmachine/gotenberg/internal/pkg/rand +# Start Google Chrome headless. +go run test/cmd/chrome.go -# 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 +# Run our tests. +if [ "$CODE_COVERAGE" = "1" ]; then + go test -race -coverprofile=coverage.txt -covermode=atomic ./... +else + go test -race -cover ./... +fi \ No newline at end of file diff --git a/build/workspace/Dockerfile b/build/workspace/Dockerfile new file mode 100644 index 00000000..ecf13478 --- /dev/null +++ b/build/workspace/Dockerfile @@ -0,0 +1,52 @@ +ARG GOLANG_VERSION + +FROM golang:${GOLANG_VERSION}-stretch as golang + +FROM thecodingmachine/gotenberg:base + +# |-------------------------------------------------------------------------- +# | 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. +# | + +COPY --from=golang /usr/local/go /usr/local/go + +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 0d7d7cea..78df8917 100644 --- a/cmd/gotenberg/main.go +++ b/cmd/gotenberg/main.go @@ -1,137 +1,47 @@ package main import ( - "context" "fmt" "net/http" "os" "os/signal" - "strconv" - "time" - "github.com/labstack/echo/v4" - "github.com/thecodingmachine/gotenberg/internal/app/api" - "github.com/thecodingmachine/gotenberg/internal/pkg/notify" - "github.com/thecodingmachine/gotenberg/internal/pkg/pm2" + "github.com/thecodingmachine/gotenberg/internal/app/xhttp" + "github.com/thecodingmachine/gotenberg/internal/pkg/chrome" + "github.com/thecodingmachine/gotenberg/internal/pkg/conf" + "github.com/thecodingmachine/gotenberg/internal/pkg/xcontext" + "github.com/thecodingmachine/gotenberg/internal/pkg/xlog" ) // version will be set on build time. // nolint: gochecknoglobals var version = "snapshot" -const ( - defaultWaitTimeoutEnvVar = "DEFAULT_WAIT_TIMEOUT" - defaultListenPortEnvVar = "DEFAULT_LISTEN_PORT" - disableGoogleChromeEnvVar = "DISABLE_GOOGLE_CHROME" - disableUnoconvEnvVar = "DISABLE_UNOCONV" - disableHealthcheckLoggingEnvVar = "DISABLE_HEALTHCHECK_LOGGING" -) - -func mustParseEnvVar() *api.Options { - opts := api.DefaultOptions() - if os.Getenv(defaultWaitTimeoutEnvVar) != "" { - defaultWaitTimeout, err := strconv.ParseFloat(os.Getenv(defaultWaitTimeoutEnvVar), 64) - if err != nil { - notify.ErrPrint(fmt.Errorf("%s: wrong value: want float got %v", defaultWaitTimeoutEnvVar, err)) - os.Exit(1) - } - opts.DefaultWaitTimeout = defaultWaitTimeout - } - if v, ok := os.LookupEnv(defaultListenPortEnvVar); ok { - defaultListener, err := strconv.ParseUint(os.Getenv(defaultListenPortEnvVar), 10, 64) - if err != nil { - notify.ErrPrint(fmt.Errorf("%s: wrong value: want uint got %v", defaultListenPortEnvVar, err)) - os.Exit(1) - } - if defaultListener > 65535 { - notify.ErrPrint(fmt.Errorf("%s: wrong value: want uint < 65535 got %v", defaultListenPortEnvVar, defaultListener)) - os.Exit(1) - } - opts.DefaultListenPort = v - } - if v, ok := os.LookupEnv(disableGoogleChromeEnvVar); ok { - if v != "1" && v != "0" { - notify.ErrPrint(fmt.Errorf("%s: wrong value: want \"0\" or \"1\" got %v", disableGoogleChromeEnvVar, v)) - os.Exit(1) - } - opts.EnableChromeEndpoints = v != "1" - } - if v, ok := os.LookupEnv(disableUnoconvEnvVar); ok { - if v != "1" && v != "0" { - notify.ErrPrint(fmt.Errorf("%s: wrong value: want \"0\" or \"1\" got %v", disableUnoconvEnvVar, v)) - os.Exit(1) - } - opts.EnableUnoconvEndpoints = v != "1" - } - if v, ok := os.LookupEnv(disableHealthcheckLoggingEnvVar); ok { - if v != "1" && v != "0" { - notify.ErrPrint(fmt.Errorf("%s: wrong value: want \"0\" or \"1\" got %v", disableHealthcheckLoggingEnvVar, v)) - os.Exit(1) - } - opts.EnableHealthcheckLogging = v != "1" - } - return opts -} - -func mustStartProcesses(opts *api.Options) []pm2.Process { - var processes []pm2.Process - if opts.EnableChromeEndpoints { - processes = append(processes, pm2.NewChrome()) - } - if opts.EnableUnoconvEndpoints { - processes = append(processes, pm2.NewUnoconv()) - } - for _, p := range processes { - notify.Printf("starting %s with PM2...", p.Fullname()) - if err := p.Start(); err != nil { - notify.ErrPrint(err) - os.Exit(1) - } - } - return processes -} - -func mustStartAPI(srv *echo.Echo, port string) { - notify.Printf("http server started on port %v", port) - if err := srv.Start(fmt.Sprintf(":%v", port)); err != nil { - if err != http.ErrServerClosed { - notify.ErrPrint(err) - os.Exit(1) - } - } -} - -func mustShutdownProcesses(processes []pm2.Process) { - for _, p := range processes { - notify.Printf("shutting down %s with PM2... (Ctrl+C to force)", p.Fullname()) - if err := p.Shutdown(); err != nil { - notify.ErrPrint(err) - os.Exit(1) - } - } -} - -func mustShutdownAPI(srv *echo.Echo) { - // create a deadline to wait for. - ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) - defer cancel() - // doesn't block if no connections, but will otherwise wait - // until the timeout deadline. - notify.Print("shutting down http server... (Ctrl+C to force)") - if err := srv.Shutdown(ctx); err != nil { - notify.ErrPrint(err) - os.Exit(1) - } -} - func main() { - notify.Printf("Gotenberg %s", version) - opts := mustParseEnvVar() - srv := api.New(opts) - processes := mustStartProcesses(opts) - // run our API in a goroutine so that it doesn't block.s + const op string = "main" + config, err := conf.FromEnv() + systemLogger := xlog.New(config.LogLevel(), "system") + if err != nil { + systemLogger.FatalOp(op, err) + } + systemLogger.InfofOp(op, "Gotenberg %s", version) + systemLogger.DebugfOp(op, "configuration: %+v", config) + if !config.DisableGoogleChrome() { + // start Google Chrome headless. + if err := chrome.Start(systemLogger); err != nil { + systemLogger.FatalOp(op, err) + } + } + // create our API. + srv := xhttp.New(config) + // run our API in a goroutine so that it doesn't block. go func() { - mustStartAPI(srv, opts.DefaultListenPort) + 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) + } + } }() quit := make(chan os.Signal, 1) // we'll accept graceful shutdowns when quit via SIGINT (Ctrl+C) @@ -139,8 +49,15 @@ func main() { signal.Notify(quit, os.Interrupt) // block until we receive our signal. <-quit - mustShutdownAPI(srv) - mustShutdownProcesses(processes) - notify.Print("bye!") + // create a deadline to wait for. + ctx, cancel := xcontext.WithTimeout(systemLogger, 120) + defer cancel() + // doesn't block if no connections, but will otherwise wait + // until the timeout deadline. + systemLogger.InfoOp(op, "shutting down http server...") + if err := srv.Shutdown(ctx); err != nil { + systemLogger.FatalOp(op, err) + } + systemLogger.InfoOp(op, "bye!") os.Exit(0) } diff --git a/docs/index.html b/docs/index.html index bcf2fd81..611db81c 100755 --- a/docs/index.html +++ b/docs/index.html @@ -120,8 +120,6 @@ @@ -134,9 +132,13 @@ Install

Gotenberg is shipped within a Docker image.

+
+

It uses a dedicated non-root user called gotenberg with uid and gid 1001.

+
+

You may start it with:

-
$ docker run --rm -p 3000:3000 thecodingmachine/gotenberg:5
+
$ docker run --rm -p 3000:3000 thecodingmachine/gotenberg:6
 
@@ -156,7 +158,7 @@ # your others services gotenberg: - image: thecodingmachine/gotenberg:5 + image: thecodingmachine/gotenberg:6
@@ -169,13 +171,19 @@

It may also be deployed with Kubernetes.

-

Make sure to provide enough memory and CPU requests (for instance 512Mi and 0.2 CPU). -Otherwise the API will not be able to launch Google Chrome and LibreOffice (unoconv).

+

Make sure to provide enough memory and CPU requests (for instance 512Mi and 0.2 CPU).

The more resources are granted, the quicker will be the conversions.

+

In the deployment specification of the pod, also specify the uid 1001 of the user gotenberg:

+ +
securityContext:
+  privileged: false
+  runAsUser: 1001
+
+

In the following examples, we will assume your Gotenberg API is available at http://localhost:3000.

@@ -191,7 +199,7 @@ Gotenberg API is available at http://localhost:3 Go client -
$ go get -u github.com/thecodingmachine/gotenberg-go-client/v5
+
$ go get -u github.com/thecodingmachine/gotenberg-go-client/v6
 

http://localhost:3 Environment variables

You may customize the API behaviour thanks to environment variables.

+

Log level

+ +

The API provides structured logging allowing you to have relevant information +about what’s going on.

+ +
+

If a TTY is attached, the log entries are displayed in text format with colors, otherwise in JSON format.

+
+ +

You may customize the severity of the log entries thanks to the environment variable LOG_LEVEL.

+ +

It accepts one of the following severities: "DEBUG", "INFO" (default) and "ERROR".

+ +

Default listen port

+ +

By default, the API will listen on port 3000.

+ +

You may customize this value with the environment variable DEFAULT_LISTEN_PORT.

+ +

This environment variable accepts any string that can be turned into a port number.

+

Disable Google Chrome

-

In order to save some resources, the Gotenberg image accepts the environment variable DISABLE_GOOGLE_CHROME.

+

In order to save some resources, the Gotenberg image accepts the environment variable DISABLE_GOOGLE_CHROME +for disabling Google Chrome.

-

It takes the strings "0" or "1" as value.

+

It takes the strings "0" or "1" as value where 1 means true

If Google Chrome is disabled, the following conversions will not be available anymore: @@ -244,7 +278,8 @@ Gotenberg API is available at http://localhost:3 Default wait timeout -

By default, the API will wait 10 seconds before it considers the conversion to be unsuccessful.

+

By default, the API will wait 10 seconds before it considers the conversion to be unsuccessful. +If unsucessful, it returns a 504 HTTP code.

You may customize this timeout thanks to the environment variable DEFAULT_WAIT_TIMEOUT.

@@ -255,25 +290,58 @@ Gotenberg API is available at http://localhost:3 See the timeout section.

-

Disable logging on healthcheck

+Maximum wait timeout

-

By default, the API will add a log entry when the healthcheck endpoint is called.

+

By default, the value of the form field waitTimeout cannot be more than 30 seconds.

-

You may turn off this logging so as to avoid unnecessary entries in your logs with the environment variable DISABLE_HEALTHCHECK_LOGGING.

+

You may increase or decrease this limit thanks to the environment variable MAXIMUM_WAIT_TIMEOUT.

-

This environment variable operates in the same manner as the DISABLE_GOOGLE_CHROME and DISABLE_UNOCONV variables operate in that it accepts the strings "0" or "1" as values.

+

It takes a string representation of a float as value (e.g "2.5" for 2.5 seconds).

-

Default listen port

+Default webhook URL timeout

-

By default, the API will listen on port 3000. For most use cases this is perfectly fine, but at times there may be cases where you need to change this due to port conflicts.

+

By default, the API will wait 10 seconds before it considers the sending of the resulting PDF to be unsuccessful.

-

You may customize this port location with the environment variable DEFAULT_LISTEN_PORT.

+
+

See the webhook section.

+
-

This environment variable accepts any string that can be turned into a port number (e.g., the string "0" up to the string "65535").

+

You may customize this timeout thanks to the environment variable DEFAULT_WEBHOOK_URL_TIMEOUT.

+ +

It takes a string representation of a float as value (e.g "2.5" for 2.5 seconds).

+ +
+

The default timeout may also be overridden per request thanks to the form field webhookURLTimeout. +See the webhook timeout section.

+
+ +

Maximum webhook URL timeout

+ +

By default, the value of the form field webhookURLTimeout cannot be more than 30 seconds.

+ +

You may increase or decrease this limit thanks to the environment variable MAXIMUM_WEBHOOK_URL_TIMEOUT.

+ +

It takes a string representation of a float as value (e.g "2.5" for 2.5 seconds).

+ +

Maximum wait delay

+ +

By default, the value of the form field waitDelay cannot be more than 10 seconds.

+ +
+

See the wait delay section.

+
+ +

You may increase or decrease this limit thanks to the environment variable MAXIMUM_WAIT_DELAY.

+ +

It takes a string representation of a float as value (e.g "2.5" for 2.5 seconds).

@@ -321,7 +389,7 @@ which will be converted to PDF.

Go -
import "github.com/thecodingmachine/gotenberg-go-client/v5"
+
import "github.com/thecodingmachine/gotenberg-go-client/v6"
 
 func main() {
     c := &gotenberg.Client{Hostname: "http://localhost:3000"}
@@ -386,6 +454,11 @@ Respectively, a file named header.html and footer.html
 Also, footer.html CSS properties override the ones from header.html.

+

For images, the only solution currently is to use a base64 encoded source:

+ +
<img src="data:image/png;base64, iVBORw0KGgoAAAANSUhEUgAAAAUA AAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO 9TXL0Y4OHwAAAABJRU5ErkJggg==" alt="Red dot" />
+
+

cURL

@@ -403,7 +476,7 @@ Also, footer.html CSS properties override the ones from heade Go -
import "github.com/thecodingmachine/gotenberg-go-client/v5"
+
import "github.com/thecodingmachine/gotenberg-go-client/v6"
 
 func main() {
     c := &gotenberg.Client{Hostname: "http://localhost:3000"}
@@ -498,7 +571,7 @@ see to the fonts section.

Go -
import "github.com/thecodingmachine/gotenberg-go-client/v5"
+
import "github.com/thecodingmachine/gotenberg-go-client/v6"
 
 func main() {
     c := &gotenberg.Client{Hostname: "http://localhost:3000"}
@@ -564,7 +637,7 @@ $client->store($request, $dest);
 	
 Go
 
-
import "github.com/thecodingmachine/gotenberg-go-client/v5"
+
import "github.com/thecodingmachine/gotenberg-go-client/v6"
 
 func main() {
     c := &gotenberg.Client{Hostname: "http://localhost:3000"}
@@ -601,7 +674,8 @@ $client->store($request, $dest);
 Wait delay
 
 

In some cases, you may want to wait a certain amount of time to make sure the -page you’re trying to generate is fully rendered.

+page you’re trying to generate is fully rendered. For instance, if your page relies +a lot on JavaScript for rendering.

The wait delay is a duration in seconds (e.g 2.5 for 2.5 seconds).

@@ -623,7 +697,7 @@ page you’re trying to generate is fully rendered.

Go -
import "github.com/thecodingmachine/gotenberg-go-client/v5"
+
import "github.com/thecodingmachine/gotenberg-go-client/v6"
 
 func main() {
     c := &gotenberg.Client{Hostname: "http://localhost:3000"}
@@ -692,7 +766,7 @@ If not, some of the content of the page might be hidden.

Go -
import "github.com/thecodingmachine/gotenberg-go-client/v5"
+
import "github.com/thecodingmachine/gotenberg-go-client/v6"
 
 func main() {
     c := &gotenberg.Client{Hostname: "http://localhost:3000"}
@@ -766,7 +840,7 @@ in the file index.html. This function will convert a given markdown
 	
 Go
 
-
import "github.com/thecodingmachine/gotenberg-go-client/v5"
+
import "github.com/thecodingmachine/gotenberg-go-client/v6"
 
 func main() {
     c := &gotenberg.Client{Hostname: "http://localhost:3000"}
@@ -828,9 +902,7 @@ $client->store($request, $dest);
 

All files will be merged into a single resulting PDF.

-

Attention: currently, unoconv cannot perform concurrent conversions. -That’s why for Office conversions, the API does only one conversion at a time. -See the scalability section to find how to mitigate this issue.

+

Attention: Gotenberg merges the PDF files alphabetically.

scalability section to find how to mitigate t Go

-
import "github.com/thecodingmachine/gotenberg-go-client/v5"
+
import "github.com/thecodingmachine/gotenberg-go-client/v6"
 
 func main() {
     c := &gotenberg.Client{Hostname: "http://localhost:3000"}
@@ -901,7 +973,7 @@ $client->store($request, $dest);
 	
 Go
 
-
import "github.com/thecodingmachine/gotenberg-go-client/v5"
+
import "github.com/thecodingmachine/gotenberg-go-client/v6"
 
 func main() {
     c := &gotenberg.Client{Hostname: "http://localhost:3000"}
@@ -936,7 +1008,7 @@ $client->store($request, $dest);
                 

Merge

-

Gotenberg provides the endpoint /convert/merge for merging PDFs.

+

Gotenberg provides the endpoint /merge for merging PDFs.

It accepts POST requests with a multipart/form-data Content-Type.

@@ -956,7 +1028,7 @@ will merge them and return the resulting PDF file.

cURL
$ curl --request POST \
-    --url http://localhost:3000/convert/merge \
+    --url http://localhost:3000/merge \
     --header 'Content-Type: multipart/form-data' \
     --form files=@file.pdf \
     --form files=@file2.pdf \
@@ -967,7 +1039,7 @@ will merge them and return the resulting PDF file.

Go -
import "github.com/thecodingmachine/gotenberg-go-client/v5"
+
import "github.com/thecodingmachine/gotenberg-go-client/v6"
 
 func main() {
     c := &gotenberg.Client{Hostname: "http://localhost:3000"}
@@ -1003,7 +1075,8 @@ $client->store($request, $dest);
 Timeout
                 

All endpoints accept a form field named waitTimeout.

-

The API will wait the given seconds before it considers the conversion to be unsucessful.

+

The API will wait the given seconds before it considers the conversion to be unsucessful. +If unsucessful, it returns a 504 HTTP code.

It takes a float as value (e.g 2.5 for 2.5 seconds).

@@ -1030,7 +1103,7 @@ $client->store($request, $dest); Go -
import "github.com/thecodingmachine/gotenberg-go-client/v5"
+
import "github.com/thecodingmachine/gotenberg-go-client/v6"
 
 func main() {
     c := &gotenberg.Client{Hostname: "http://localhost:3000"}
@@ -1089,7 +1162,7 @@ to given URL.

Go -
import "github.com/thecodingmachine/gotenberg-go-client/v5"
+
import "github.com/thecodingmachine/gotenberg-go-client/v6"
 
 func main() {
     c := &gotenberg.Client{Hostname: "http://localhost:3000"}
@@ -1112,6 +1185,67 @@ $index = DocumentFactory::makeFromPath('index.html', 'index.html'
 $request = new HTMLRequest($index);
 $request->setWebhookURL('http://myapp.com/webhook/');
 $resp = $client->post($request);
+
+ +

Timeout

+ +

If a webhookURL is provided, you may also send a form field named webhookURLTimeout.

+ +

The API will wait the given seconds before it considers the sending of the resulting PDF to be unsucessful.

+ +

It takes a float as value (e.g 2.5 for 2.5 seconds).

+ +
+

You may also define this value globally: see the environment variables section.

+
+ +

Examples

+ +

cURL

+ +
$ curl --request POST \
+    --url http://localhost:3000/convert/html \
+    --header 'Content-Type: multipart/form-data' \
+    --form files=@index.html \
+    --form webhookURL='http://myapp.com/webhook/' \
+    --form webhookURLTimeout=2.5
+
+ +

Go

+ +
import "github.com/thecodingmachine/gotenberg-go-client/v6"
+
+func main() {
+    c := &gotenberg.Client{Hostname: "http://localhost:3000"}
+    req, _ := gotenberg.NewHTMLRequest("index.html")
+    req.WebhookURL("http://myapp.com/webhook/")
+    req.WebhookURLTimeout(2.5)
+    resp, _ := c.Post(req)
+}
+
+ +

PHP

+ +
use TheCodingMachine\Gotenberg\Client;
+use TheCodingMachine\Gotenberg\DocumentFactory;
+use TheCodingMachine\Gotenberg\HTMLRequest;
+
+$client = new Client('http://localhost:3000', new \Http\Adapter\Guzzle6\Client());
+$index = DocumentFactory::makeFromPath('index.html', 'index.html');
+$request = new HTMLRequest($index);
+$request->setWebhookURL('http://myapp.com/webhook/');
+$request->setWebhookURLTimeout(2.5);
+$resp = $client->post($request);
 
@@ -1148,7 +1282,7 @@ Otherwise a random filename is used.

Go -
import "github.com/thecodingmachine/gotenberg-go-client/v5"
+
import "github.com/thecodingmachine/gotenberg-go-client/v6"
 
 func main() {
     c := &gotenberg.Client{Hostname: "http://localhost:3000"}
@@ -1180,7 +1314,43 @@ $resp = $client->post($request);
                 

Scalability

-

The API being stateless, you may scale it as much as you want.

+

The API uses under the hood intricate programs.

+ +

Gotenberg tries to abstract as much complexity as possible but it can +only do it to a certain extend.

+ +

For instance, Office and Merge endpoints will start respectively as many LibreOffice (unoconv) and PDTk +instances are there are requests. The limitation here is the available memory and CPU usage.

+ +

On another hand, for the HTML, URL and Markdown endpoints, the API does only 6 conversions in parallel. +Indeed, Google Chrome misbehaves if there are too many concurrent conversions.

+ +

The more concurrent requests, the more 504 HTTP codes the API will return.

+ +
+

See our load testing use case for more details about the API behaviour under heavy load.

+
+ +

Strategies

+ +

Increase timeout

+ +

You may increase the conversion timeout. In other words, you accept that a conversion takes more time +if the API is under heavy load.

+ +
+

See timeout section.

+
+ +

Scaling

+ +

The API being stateless, you may scale it as much as you want.

For instance, using the following Docker Compose file:

@@ -1191,7 +1361,7 @@ $resp = $client->post($request); # your others services gotenberg: - image: thecodingmachine/gotenberg:5 + image: thecodingmachine/gotenberg:6

You may now launch your services using:

@@ -1211,12 +1381,11 @@ redirect a request to a Gotenberg container according to the round-robin strateg

Gotenberg provides the endpoint /ping for checking the API availability with a simple GET request.

-

This feature is especially useful for liveness/readiness probes in Kubernetes:

+

Currently this endpoint does nothing special. A better way to monitor +Gotenberg would be by checking the memory usage.

- +

Also, as the API uses under the hood intricate programs, you should +restart your Gotenberg instances from time to time to ensure a nominal behaviour.

@@ -1228,7 +1397,7 @@ a simple GET request.

If you wish to use more fonts, you will have to create your own image:

-
FROM thecodingmachine/gotenberg:5
+
FROM thecodingmachine/gotenberg:6
 
 RUN apt-get -y install yourfonts
 
@@ -1242,6 +1411,7 @@ a simple GET request.

Psst: TheCodingMachine is always looking for talented coders.

diff --git a/go.mod b/go.mod index d67246a6..70503022 100644 --- a/go.mod +++ b/go.mod @@ -1,23 +1,27 @@ module github.com/thecodingmachine/gotenberg -go 1.12 +go 1.13 require ( - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/google/go-cmp v0.2.0 // indirect - github.com/gorilla/websocket v1.4.0 // indirect - github.com/labstack/echo/v4 v4.0.0 - github.com/labstack/gommon v0.2.8 - github.com/mafredri/cdp v0.22.0 - github.com/mattn/go-colorable v0.1.1 // indirect - github.com/mattn/go-isatty v0.0.7 // indirect - github.com/microcosm-cc/bluemonday v1.0.1 + github.com/dustin/go-humanize v1.0.0 + github.com/google/go-cmp v0.3.1 // indirect + github.com/gorilla/websocket v1.4.1 // indirect + github.com/konsorten/go-windows-terminal-sequences v1.0.2 // indirect + github.com/kr/pretty v0.1.0 // indirect + github.com/labstack/echo/v4 v4.1.10 + github.com/labstack/gommon v0.3.0 + github.com/mafredri/cdp v0.24.2 + github.com/mattn/go-isatty v0.0.9 + github.com/microcosm-cc/bluemonday v1.0.2 + github.com/phayes/freeport v0.0.0-20180830031419-95f893ade6f2 github.com/russross/blackfriday/v2 v2.0.1 - github.com/shurcooL/sanitized_anchor_name v0.0.0-20170918181015-86672fcb3f95 // indirect - github.com/stretchr/testify v1.3.0 - github.com/valyala/fasttemplate v1.0.1 // indirect - golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c // indirect - golang.org/x/net v0.0.0-20181201002055-351d144fa1fc // indirect - golang.org/x/sync v0.0.0-20181108010431-42b317875d0f - golang.org/x/sys v0.0.0-20190322080309-f49334f85ddc // indirect + github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect + github.com/sirupsen/logrus v1.4.2 + github.com/stretchr/testify v1.4.0 + golang.org/x/crypto v0.0.0-20190927123631-a832865fa7ad // indirect + golang.org/x/net v0.0.0-20190930134127-c5a3c61f89f3 // indirect + golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e + golang.org/x/sys v0.0.0-20190927073244-c990c680b611 // indirect + golang.org/x/text v0.3.2 + gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect ) diff --git a/go.sum b/go.sum index 2e6eae15..0f3dfd85 100644 --- a/go.sum +++ b/go.sum @@ -3,51 +3,79 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= -github.com/google/go-cmp v0.2.0 h1:+dTQ8DZQJz0Mb/HjFlkptS1FeQ4cWSnN941F8aEG4SQ= -github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= -github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q= -github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= -github.com/labstack/echo/v4 v4.0.0 h1:q1GH+caIXPP7H2StPIdzy/ez9CO0EepqYeUg6vi9SWM= -github.com/labstack/echo/v4 v4.0.0/go.mod h1:tZv7nai5buKSg5h/8E6zz4LsD/Dqh9/91Mvs7Z5Zyno= -github.com/labstack/gommon v0.2.8 h1:JvRqmeZcfrHC5u6uVleB4NxxNbzx6gpbJiQknDbKQu0= -github.com/labstack/gommon v0.2.8/go.mod h1:/tj9csK2iPSBvn+3NLM9e52usepMtrd5ilFYA+wQNJ4= -github.com/mafredri/cdp v0.22.0 h1:BV17j8hXLDWczo2SZIAFuOjMpQMIOq5DOcd9sgB2hv0= -github.com/mafredri/cdp v0.22.0/go.mod h1:hgdiA0yp1uqhSaDOHJWPgXpMbh+LAfUdD9vbN2AM8gE= -github.com/mattn/go-colorable v0.0.9 h1:UVL0vNpWh04HeJXV0KLcaT7r06gOH2l4OW6ddYRUIY4= -github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= -github.com/mattn/go-colorable v0.1.1 h1:G1f5SKeVxmagw/IyvzvtZE4Gybcc4Tr1tf7I8z0XgOg= -github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= -github.com/mattn/go-isatty v0.0.4 h1:bnP0vzxcAdeI1zdubAl5PjU6zsERjGZb7raWodagDYs= -github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= -github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= -github.com/mattn/go-isatty v0.0.7 h1:UvyT9uN+3r7yLEYSlJsbQGdsaB/a0DlgWP3pql6iwOc= -github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= -github.com/microcosm-cc/bluemonday v1.0.1 h1:SIYunPjnlXcW+gVfvm0IlSeR5U3WZUOLfVmqg85Go44= -github.com/microcosm-cc/bluemonday v1.0.1/go.mod h1:hsXNsILzKxV+sX77C5b8FSuKF00vh2OMYv+xgHpAMF4= +github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= +github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/google/go-cmp v0.3.1 h1:Xye71clBPdm5HgqGwUkwhbynsUJZhDbS20FvLhQ2izg= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM= +github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.2 h1:DB17ag19krx9CFsz4o3enTrPXyIXCl+2iCXH/aMAp9s= +github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/labstack/echo/v4 v4.1.10 h1:/yhIpO50CBInUbE/nHJtGIyhBv0dJe2cDAYxc3V3uMo= +github.com/labstack/echo/v4 v4.1.10/go.mod h1:i541M3Fj6f76NZtHSj7TXnyM8n2gaodfvfxNnFqi74g= +github.com/labstack/gommon v0.3.0 h1:JEeO0bvc78PKdyHxloTKiF8BD5iGrH8T6MSeGvSgob0= +github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k= +github.com/mafredri/cdp v0.24.2 h1:Rzhj/EQw9opbiwUpNML7P+4Hvf0/nSYPaDbiCEpILOM= +github.com/mafredri/cdp v0.24.2/go.mod h1:hgdiA0yp1uqhSaDOHJWPgXpMbh+LAfUdD9vbN2AM8gE= +github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU= +github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-isatty v0.0.8 h1:HLtExJ+uU2HOZ+wI0Tt5DtUDrx8yhUqDcp7fYERX4CE= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.9 h1:d5US/mDsogSGW37IV293h//ZFaeajb69h+EHFsv2xGg= +github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ= +github.com/microcosm-cc/bluemonday v1.0.2 h1:5lPfLTTAvAbtS0VqT+94yOtFnGfUWYyx0+iToC3Os3s= +github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/leAFZyRl6bYmGDlGc= +github.com/phayes/freeport v0.0.0-20180830031419-95f893ade6f2 h1:JhzVVoYvbOACxoUmOs6V/G4D5nPVUW73rKvXxP4XUJc= +github.com/phayes/freeport v0.0.0-20180830031419-95f893ade6f2/go.mod h1:iIss55rKnNBTvrwdmkUpLnDpZoAHvWaiq5+iMmen4AE= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/shurcooL/sanitized_anchor_name v0.0.0-20170918181015-86672fcb3f95 h1:/vdW8Cb7EXrkqWGufVMES1OH2sU9gKVb2n9/1y5NMBY= -github.com/shurcooL/sanitized_anchor_name v0.0.0-20170918181015-86672fcb3f95/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasttemplate v0.0.0-20170224212429-dcecefd839c4 h1:gKMu1Bf6QINDnvyZuTaACm9ofY+PRh+5vFz4oxBZeF8= -github.com/valyala/fasttemplate v0.0.0-20170224212429-dcecefd839c4/go.mod h1:50wTf68f99/Zt14pr046Tgt3Lp2vLyFZKzbFXTOabXw= github.com/valyala/fasttemplate v1.0.1 h1:tY9CJiPnMXf1ERmG2EyK7gNUd+c6RKGD0IfU8WdUSz8= github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8= -golang.org/x/crypto v0.0.0-20190130090550-b01c7a725664/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c h1:Vj5n4GlwjmQteupaxJ9+0FNOmBrHfq7vN4btdGoDZgI= -golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/net v0.0.0-20181201002055-351d144fa1fc h1:a3CU5tJYVj92DY2LaA1kUkrsqD5/3mLDhx2NcNqyW+0= -golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/sync v0.0.0-20181108010431-42b317875d0f h1:Bl/8QSvNqXvPGPGXa2z5xUTmV7VDcZyvRZ+QQXkXTZQ= -golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20190129075346-302c3dd5f1cc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4 h1:HuIa8hRrWRSrqYzx1qI49NNxhdi2PrY7gxVSq1JjLDc= +golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190927123631-a832865fa7ad h1:5E5raQxcv+6CZ11RrBYQe5WRbUIWpScjh0kvHZkZIrQ= +golang.org/x/crypto v0.0.0-20190927123631-a832865fa7ad/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190930134127-c5a3c61f89f3 h1:6KET3Sqa7fkVfD63QnAM81ZeYg5n4HwApOJkufONnHA= +golang.org/x/net v0.0.0-20190930134127-c5a3c61f89f3/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e h1:vcxGaoTs7kV8m5Np9uUNQin4BrLOthgV7252N8V+FwY= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190322080309-f49334f85ddc h1:4gbWbmmPFp4ySWICouJl6emP0MyS31yy9SrTlAGFT+g= -golang.org/x/sys v0.0.0-20190322080309-f49334f85ddc/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a h1:aYOabOQFp6Vj6W1F80affTUvO9UxmJRx8K0gsfABByQ= +golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190927073244-c990c680b611 h1:q9u40nxWT5zRClI/uU9dHCiYGottAg6Nzz4YUQyHxdA= +golang.org/x/sys v0.0.0-20190927073244-c990c680b611/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/internal/app/api/api.go b/internal/app/api/api.go deleted file mode 100644 index 1edd6063..00000000 --- a/internal/app/api/api.go +++ /dev/null @@ -1,53 +0,0 @@ -package api - -import ( - "github.com/labstack/echo/v4" -) - -const pingEndpoint = "/ping" - -// Options allows to customize the behaviour -// of the API. -type Options struct { - DefaultWaitTimeout float64 - DefaultListenPort string - EnableChromeEndpoints bool - EnableUnoconvEndpoints bool - EnableHealthcheckLogging bool -} - -// DefaultOptions returns default options. -func DefaultOptions() *Options { - return &Options{ - DefaultWaitTimeout: 10, - DefaultListenPort: "3000", - EnableChromeEndpoints: true, - EnableUnoconvEndpoints: true, - EnableHealthcheckLogging: true, - } -} - -// New returns an API. -func New(opts *Options) *echo.Echo { - api := echo.New() - api.HideBanner = true - api.HidePort = true - api.Use(handleLogging(opts.EnableHealthcheckLogging)) - api.GET(pingEndpoint, func(c echo.Context) error { return nil }) - g := api.Group("/convert") - g.Use(handleContext(opts)) - g.Use(handleError()) - g.POST("/merge", merge) - if !opts.EnableChromeEndpoints && !opts.EnableUnoconvEndpoints { - return api - } - if opts.EnableChromeEndpoints { - g.POST("/html", convertHTML) - g.POST("/url", convertURL) - g.POST("/markdown", convertMarkdown) - } - if opts.EnableUnoconvEndpoints { - g.POST("/office", convertOffice) - } - return api -} diff --git a/internal/app/api/api_test.go b/internal/app/api/api_test.go deleted file mode 100644 index 1f29b33c..00000000 --- a/internal/app/api/api_test.go +++ /dev/null @@ -1,128 +0,0 @@ -package api - -import ( - "net/http" - "net/http/httptest" - "testing" - - "github.com/labstack/echo/v4" - "github.com/thecodingmachine/gotenberg/test" -) - -func TestDefaultWaitTimeout(t *testing.T) { - opts := DefaultOptions() - opts.DefaultWaitTimeout = 0 - srv := New(opts) - // testing if timeout. - body, contentType := test.URLTestMultipartForm(t, nil) - req := httptest.NewRequest(http.MethodPost, "/convert/url", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusRequestTimeout, srv, req) - // testing if no timeout. - body, contentType = test.URLTestMultipartForm(t, map[string]string{waitTimeout: "10"}) - req = httptest.NewRequest(http.MethodPost, "/convert/url", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusOK, srv, req) -} - -func TestDisableChromeEndpoints(t *testing.T) { - opts := DefaultOptions() - opts.EnableChromeEndpoints = false - srv := New(opts) - // Ping. - req := httptest.NewRequest(http.MethodGet, "/ping", nil) - test.AssertStatusCode(t, http.StatusOK, srv, req) - // Merge. - body, contentType := test.PDFTestMultipartForm(t, nil) - req = httptest.NewRequest(http.MethodPost, "/convert/merge", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusOK, srv, req) - // HTML. - body, contentType = test.HTMLTestMultipartForm(t, nil) - req = httptest.NewRequest(http.MethodPost, "/convert/html", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusNotFound, srv, req) - // Markdown. - body, contentType = test.MarkdownTestMultipartForm(t, nil) - req = httptest.NewRequest(http.MethodPost, "/convert/markdown", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusNotFound, srv, req) - // URL. - body, contentType = test.URLTestMultipartForm(t, nil) - req = httptest.NewRequest(http.MethodPost, "/convert/url", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusNotFound, srv, req) - // Office. - body, contentType = test.OfficeTestMultipartForm(t, nil) - req = httptest.NewRequest(http.MethodPost, "/convert/office", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusOK, srv, req) -} - -func TestDisableUnoconvEndpoints(t *testing.T) { - opts := DefaultOptions() - opts.EnableUnoconvEndpoints = false - srv := New(opts) - // Ping. - req := httptest.NewRequest(http.MethodGet, "/ping", nil) - test.AssertStatusCode(t, http.StatusOK, srv, req) - // Merge. - body, contentType := test.PDFTestMultipartForm(t, nil) - req = httptest.NewRequest(http.MethodPost, "/convert/merge", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusOK, srv, req) - // HTML. - body, contentType = test.HTMLTestMultipartForm(t, nil) - req = httptest.NewRequest(http.MethodPost, "/convert/html", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusOK, srv, req) - // Markdown. - body, contentType = test.MarkdownTestMultipartForm(t, nil) - req = httptest.NewRequest(http.MethodPost, "/convert/markdown", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusOK, srv, req) - // URL. - body, contentType = test.URLTestMultipartForm(t, nil) - req = httptest.NewRequest(http.MethodPost, "/convert/url", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusOK, srv, req) - // Office. - body, contentType = test.OfficeTestMultipartForm(t, nil) - req = httptest.NewRequest(http.MethodPost, "/convert/office", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusNotFound, srv, req) -} -func TestDisableChromeAndUnoconvEndpoints(t *testing.T) { - opts := DefaultOptions() - opts.EnableChromeEndpoints = false - opts.EnableUnoconvEndpoints = false - srv := New(opts) - // Ping. - req := httptest.NewRequest(http.MethodGet, "/ping", nil) - test.AssertStatusCode(t, http.StatusOK, srv, req) - // Merge. - body, contentType := test.PDFTestMultipartForm(t, nil) - req = httptest.NewRequest(http.MethodPost, "/convert/merge", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusOK, srv, req) - // HTML. - body, contentType = test.HTMLTestMultipartForm(t, nil) - req = httptest.NewRequest(http.MethodPost, "/convert/html", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusNotFound, srv, req) - // Markdown. - body, contentType = test.MarkdownTestMultipartForm(t, nil) - req = httptest.NewRequest(http.MethodPost, "/convert/markdown", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusNotFound, srv, req) - // URL. - body, contentType = test.URLTestMultipartForm(t, nil) - req = httptest.NewRequest(http.MethodPost, "/convert/url", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusNotFound, srv, req) - // Office. - body, contentType = test.OfficeTestMultipartForm(t, nil) - req = httptest.NewRequest(http.MethodPost, "/convert/office", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusNotFound, srv, req) -} diff --git a/internal/app/api/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/handler.go b/internal/app/api/handler.go deleted file mode 100644 index f350e452..00000000 --- a/internal/app/api/handler.go +++ /dev/null @@ -1,157 +0,0 @@ -package api - -import ( - "fmt" - "net/http" - "os" - - "github.com/labstack/echo/v4" - "github.com/thecodingmachine/gotenberg/internal/pkg/printer" - "github.com/thecodingmachine/gotenberg/internal/pkg/rand" -) - -type errBadRequest struct { - err error -} - -func (e *errBadRequest) Error() string { - return e.err.Error() -} - -func merge(c echo.Context) error { - ctx := c.(*resourceContext) - opts, err := ctx.resource.mergePrinterOptions() - if err != nil { - return &errBadRequest{err} - } - fpaths, err := ctx.resource.fpaths(".pdf") - if err != nil { - return &errBadRequest{err} - } - p := printer.NewMerge(fpaths, opts) - return convert(ctx, p) -} - -func convertHTML(c echo.Context) error { - ctx := c.(*resourceContext) - opts, err := ctx.resource.chromePrinterOptions() - if err != nil { - return &errBadRequest{err} - } - fpath, err := ctx.resource.fpath("index.html") - if err != nil { - return &errBadRequest{err} - } - p := printer.NewHTML(fpath, opts) - return convert(ctx, p) -} - -func convertMarkdown(c echo.Context) error { - ctx := c.(*resourceContext) - opts, err := ctx.resource.chromePrinterOptions() - if err != nil { - return &errBadRequest{err} - } - fpath, err := ctx.resource.fpath("index.html") - if err != nil { - return &errBadRequest{err} - } - p, err := printer.NewMarkdown(fpath, opts) - if err != nil { - return err - } - return convert(ctx, p) -} - -func convertURL(c echo.Context) error { - ctx := c.(*resourceContext) - opts, err := ctx.resource.chromePrinterOptions() - if err != nil { - return &errBadRequest{err} - } - remote, err := ctx.resource.get(remoteURL) - if err != nil { - return &errBadRequest{err} - } - p := printer.NewURL(remote, opts) - return convert(ctx, p) -} - -func convertOffice(c echo.Context) error { - ctx := c.(*resourceContext) - opts, err := ctx.resource.officePrinterOptions() - if err != nil { - return &errBadRequest{err} - } - fpaths, err := ctx.resource.fpaths( - ".txt", - ".rtf", - ".fodt", - ".doc", - ".docx", - ".odt", - ".xls", - ".xlsx", - ".ods", - ".ppt", - ".pptx", - ".odp", - ) - if err != nil { - return &errBadRequest{err} - } - p := printer.NewOffice(fpaths, opts) - return convert(ctx, p) -} - -func convert(ctx *resourceContext, p printer.Printer) error { - baseFilename, err := rand.Get() - if err != nil { - return err - } - filename := fmt.Sprintf("%s.pdf", baseFilename) - fpath := fmt.Sprintf("%s/%s", ctx.resource.formFilesDirPath, filename) - // if no webhook URL given, run conversion - // and directly return the resulting PDF file - // or an error. - if !ctx.resource.has(webhookURL) { - if err := p.Print(fpath); err != nil { - return err - } - if ctx.resource.has(resultFilename) { - filename, err = ctx.resource.get(resultFilename) - if err != nil { - return &errBadRequest{err} - } - } - return ctx.Attachment(fpath, filename) - } - // as a webhook URL has been given, we - // run the following lines in a goroutine so that - // it doesn't block. - go func() { - defer ctx.resource.close() // nolint: errcheck - if err := p.Print(fpath); err != nil { - ctx.Logger().Error(err) - return - } - f, err := os.Open(fpath) - if err != nil { - ctx.Logger().Error(err) - return - } - defer f.Close() // nolint: errcheck - webhook, err := ctx.resource.get(webhookURL) - if err != nil { - ctx.Logger().Error(err) - return - } - resp, err := http.Post(webhook, "application/pdf", f) /* #nosec */ - if err != nil { - ctx.Logger().Error(err) - return - } - defer resp.Body.Close() // nolint: errcheck - }() - return nil -} diff --git a/internal/app/api/handler_test.go b/internal/app/api/handler_test.go deleted file mode 100644 index 32cfd159..00000000 --- a/internal/app/api/handler_test.go +++ /dev/null @@ -1,360 +0,0 @@ -package api - -import ( - "errors" - "fmt" - "io/ioutil" - "net/http" - "net/http/httptest" - "testing" - - "github.com/labstack/echo/v4" - "github.com/stretchr/testify/assert" - "github.com/thecodingmachine/gotenberg/test" -) - -func TestMerge(t *testing.T) { - opts := DefaultOptions() - srv := New(opts) - // OK. - body, contentType := test.PDFTestMultipartForm(t, nil) - req := httptest.NewRequest(http.MethodPost, "/convert/merge", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusOK, srv, req) - // Bad request. - body, contentType = test.PDFTestMultipartForm(t, map[string]string{waitTimeout: "not a float"}) - req = httptest.NewRequest(http.MethodPost, "/convert/merge", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.URLTestMultipartForm(t, nil) - req = httptest.NewRequest(http.MethodPost, "/convert/merge", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - // Timeout. - body, contentType = test.PDFTestMultipartForm(t, map[string]string{waitTimeout: "0"}) - req = httptest.NewRequest(http.MethodPost, "/convert/merge", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusRequestTimeout, srv, req) -} - -func TestHTML(t *testing.T) { - opts := DefaultOptions() - srv := New(opts) - // OK. - body, contentType := test.HTMLTestMultipartForm(t, nil) - req := httptest.NewRequest(http.MethodPost, "/convert/html", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusOK, srv, req) - // Bad request. - body, contentType = test.HTMLTestMultipartForm(t, map[string]string{waitTimeout: "not a float"}) - req = httptest.NewRequest(http.MethodPost, "/convert/html", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.HTMLTestMultipartForm(t, map[string]string{waitDelay: "not a float"}) - req = httptest.NewRequest(http.MethodPost, "/convert/html", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.HTMLTestMultipartForm(t, map[string]string{paperWidth: "not a float"}) - req = httptest.NewRequest(http.MethodPost, "/convert/html", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.HTMLTestMultipartForm(t, map[string]string{paperHeight: "not a float"}) - req = httptest.NewRequest(http.MethodPost, "/convert/html", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.HTMLTestMultipartForm(t, map[string]string{marginTop: "not a float"}) - req = httptest.NewRequest(http.MethodPost, "/convert/html", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.HTMLTestMultipartForm(t, map[string]string{marginBottom: "not a float"}) - req = httptest.NewRequest(http.MethodPost, "/convert/html", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.HTMLTestMultipartForm(t, map[string]string{marginLeft: "not a float"}) - req = httptest.NewRequest(http.MethodPost, "/convert/html", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.HTMLTestMultipartForm(t, map[string]string{marginRight: "not a float"}) - req = httptest.NewRequest(http.MethodPost, "/convert/html", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.HTMLTestMultipartForm(t, map[string]string{landscape: "not a bool"}) - req = httptest.NewRequest(http.MethodPost, "/convert/html", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.URLTestMultipartForm(t, nil) - req = httptest.NewRequest(http.MethodPost, "/convert/html", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - // Timeout. - body, contentType = test.HTMLTestMultipartForm(t, map[string]string{waitTimeout: "0"}) - req = httptest.NewRequest(http.MethodPost, "/convert/html", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusRequestTimeout, srv, req) -} - -func TestMarkdown(t *testing.T) { - opts := DefaultOptions() - srv := New(opts) - // OK. - body, contentType := test.MarkdownTestMultipartForm(t, nil) - req := httptest.NewRequest(http.MethodPost, "/convert/markdown", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusOK, srv, req) - // Bad request. - body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{waitTimeout: "not a float"}) - req = httptest.NewRequest(http.MethodPost, "/convert/markdown", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{waitDelay: "not a float"}) - req = httptest.NewRequest(http.MethodPost, "/convert/markdown", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{paperWidth: "not a float"}) - req = httptest.NewRequest(http.MethodPost, "/convert/markdown", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{paperHeight: "not a float"}) - req = httptest.NewRequest(http.MethodPost, "/convert/markdown", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{marginTop: "not a float"}) - req = httptest.NewRequest(http.MethodPost, "/convert/markdown", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{marginBottom: "not a float"}) - req = httptest.NewRequest(http.MethodPost, "/convert/markdown", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{marginLeft: "not a float"}) - req = httptest.NewRequest(http.MethodPost, "/convert/markdown", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{marginRight: "not a float"}) - req = httptest.NewRequest(http.MethodPost, "/convert/markdown", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{landscape: "not a bool"}) - req = httptest.NewRequest(http.MethodPost, "/convert/markdown", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.URLTestMultipartForm(t, nil) - req = httptest.NewRequest(http.MethodPost, "/convert/markdown", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - // Timeout. - body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{waitTimeout: "0"}) - req = httptest.NewRequest(http.MethodPost, "/convert/markdown", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusRequestTimeout, srv, req) -} - -func TestURL(t *testing.T) { - opts := DefaultOptions() - srv := New(opts) - // OK. - body, contentType := test.URLTestMultipartForm(t, nil) - req := httptest.NewRequest(http.MethodPost, "/convert/url", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusOK, srv, req) - // Bad request. - body, contentType = test.URLTestMultipartForm(t, map[string]string{waitTimeout: "not a float"}) - req = httptest.NewRequest(http.MethodPost, "/convert/url", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.URLTestMultipartForm(t, map[string]string{waitDelay: "not a float"}) - req = httptest.NewRequest(http.MethodPost, "/convert/url", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.URLTestMultipartForm(t, map[string]string{paperWidth: "not a float"}) - req = httptest.NewRequest(http.MethodPost, "/convert/url", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.URLTestMultipartForm(t, map[string]string{paperHeight: "not a float"}) - req = httptest.NewRequest(http.MethodPost, "/convert/url", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.URLTestMultipartForm(t, map[string]string{marginTop: "not a float"}) - req = httptest.NewRequest(http.MethodPost, "/convert/url", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.URLTestMultipartForm(t, map[string]string{marginBottom: "not a float"}) - req = httptest.NewRequest(http.MethodPost, "/convert/url", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.URLTestMultipartForm(t, map[string]string{marginLeft: "not a float"}) - req = httptest.NewRequest(http.MethodPost, "/convert/url", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.URLTestMultipartForm(t, map[string]string{marginRight: "not a float"}) - req = httptest.NewRequest(http.MethodPost, "/convert/url", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.URLTestMultipartForm(t, map[string]string{landscape: "not a bool"}) - req = httptest.NewRequest(http.MethodPost, "/convert/url", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - // Timeout. - body, contentType = test.URLTestMultipartForm(t, map[string]string{waitTimeout: "0"}) - req = httptest.NewRequest(http.MethodPost, "/convert/url", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusRequestTimeout, srv, req) -} - -func TestOffice(t *testing.T) { - opts := DefaultOptions() - srv := New(opts) - // OK. - body, contentType := test.OfficeTestMultipartForm(t, nil) - req := httptest.NewRequest(http.MethodPost, "/convert/office", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusOK, srv, req) - // Bad request. - body, contentType = test.OfficeTestMultipartForm(t, map[string]string{waitTimeout: "not a float"}) - req = httptest.NewRequest(http.MethodPost, "/convert/office", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.OfficeTestMultipartForm(t, map[string]string{landscape: "not a bool"}) - req = httptest.NewRequest(http.MethodPost, "/convert/office", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - body, contentType = test.URLTestMultipartForm(t, nil) - req = httptest.NewRequest(http.MethodPost, "/convert/office", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusBadRequest, srv, req) - // Timeout. - body, contentType = test.OfficeTestMultipartForm(t, map[string]string{waitTimeout: "0"}) - req = httptest.NewRequest(http.MethodPost, "/convert/office", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusRequestTimeout, srv, req) -} - -func TestConcurrent(t *testing.T) { - opts := DefaultOptions() - opts.DefaultWaitTimeout = 30 - srv := New(opts) - // Merge. - test.AssertConcurrent( - t, - func() error { - body, contentType := test.MarkdownTestMultipartForm(t, nil) - req := httptest.NewRequest(http.MethodPost, "/convert/html", body) - req.Header.Set(echo.HeaderContentType, contentType) - rec := httptest.NewRecorder() - srv.ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - return fmt.Errorf("wrong status code: want %d got %d", http.StatusOK, rec.Code) - } - return nil - }, - 10, - ) - // HTML. - test.AssertConcurrent( - t, - func() error { - body, contentType := test.HTMLTestMultipartForm(t, nil) - req := httptest.NewRequest(http.MethodPost, "/convert/html", body) - req.Header.Set(echo.HeaderContentType, contentType) - rec := httptest.NewRecorder() - srv.ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - return fmt.Errorf("wrong status code: want %d got %d", http.StatusOK, rec.Code) - } - return nil - }, - 10, - ) - // Markdown. - test.AssertConcurrent( - t, - func() error { - body, contentType := test.MarkdownTestMultipartForm(t, nil) - req := httptest.NewRequest(http.MethodPost, "/convert/markdown", body) - req.Header.Set(echo.HeaderContentType, contentType) - rec := httptest.NewRecorder() - srv.ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - return fmt.Errorf("wrong status code: want %d got %d", http.StatusOK, rec.Code) - } - return nil - }, - 10, - ) - // URL. - test.AssertConcurrent( - t, - func() error { - body, contentType := test.URLTestMultipartForm(t, nil) - req := httptest.NewRequest(http.MethodPost, "/convert/url", body) - req.Header.Set(echo.HeaderContentType, contentType) - rec := httptest.NewRecorder() - srv.ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - return fmt.Errorf("wrong status code: want %d got %d", http.StatusOK, rec.Code) - } - return nil - }, - 10, - ) - // Office. - test.AssertConcurrent( - t, - func() error { - body, contentType := test.OfficeTestMultipartForm(t, nil) - req := httptest.NewRequest(http.MethodPost, "/convert/office", body) - req.Header.Set(echo.HeaderContentType, contentType) - rec := httptest.NewRecorder() - srv.ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - return fmt.Errorf("wrong status code: want %d got %d", http.StatusOK, rec.Code) - } - return nil - }, - 10, - ) -} - -func TestWebhook(t *testing.T) { - status := make(chan error, 2) - rcv := echo.New() - rcv.POST("/foo", func(c echo.Context) error { - if c.Request().Header.Get("Content-type") != "application/pdf" { - status <- fmt.Errorf("wrong Content-type: got %s want %s", c.Request().Header.Get("Content-type"), "application/pdf") - return nil - } - body, err := ioutil.ReadAll(c.Request().Body) - if err != nil { - status <- err - return nil - } - if body == nil || len(body) == 0 { - status <- errors.New("empty body") - return nil - } - status <- nil - return nil - }) - go func() { - rcv.Start(":3001") - }() - opts := DefaultOptions() - srv := New(opts) - body, contentType := test.PDFTestMultipartForm(t, map[string]string{webhookURL: "http://localhost:3001/foo"}) - req := httptest.NewRequest(http.MethodPost, "/convert/merge", body) - req.Header.Set(echo.HeaderContentType, contentType) - test.AssertStatusCode(t, http.StatusOK, srv, req) - err := <-status - assert.NoError(t, err) -} - -func TestResultFilename(t *testing.T) { - opts := DefaultOptions() - srv := New(opts) - body, contentType := test.PDFTestMultipartForm(t, map[string]string{resultFilename: "foo.pdf"}) - req := httptest.NewRequest(http.MethodPost, "/convert/merge", body) - req.Header.Set(echo.HeaderContentType, contentType) - rec := httptest.NewRecorder() - srv.ServeHTTP(rec, req) - assert.Equal(t, "attachment; filename=\"foo.pdf\"", rec.Header().Get("Content-Disposition")) -} diff --git a/internal/app/api/middleware.go b/internal/app/api/middleware.go deleted file mode 100644 index a3329f6d..00000000 --- a/internal/app/api/middleware.go +++ /dev/null @@ -1,75 +0,0 @@ -package api - -import ( - "context" - "net/http" - "strings" - - "github.com/labstack/echo/v4" - "github.com/labstack/echo/v4/middleware" -) - -func handleLogging(enableHealthcheckLogging bool) echo.MiddlewareFunc { - if enableHealthcheckLogging { - // default logging middleware. - return middleware.Logger() - } - // middleware for skipping logging when the ping endpoint is called. - return middleware.LoggerWithConfig(middleware.LoggerConfig{ - Skipper: func(c echo.Context) bool { - return c.Request().URL.Path == pingEndpoint - }, - }) -} - -func handleContext(opts *Options) echo.MiddlewareFunc { - // middleware for extending default context with our - // custom constext. - return func(next echo.HandlerFunc) echo.HandlerFunc { - return func(c echo.Context) error { - ctx := &resourceContext{c, opts, nil} - r, err := newResource(ctx) - if err != nil { - if resourceErr := r.close(); resourceErr != nil { - c.Logger().Error(resourceErr) - } - return err - } - ctx.resource = r - return next(ctx) - } - } -} - -func handleError() echo.MiddlewareFunc { - // middleware for handling errors and removing resources - // once the request has been handled. - return func(next echo.HandlerFunc) echo.HandlerFunc { - return func(c echo.Context) error { - err := next(c) - ctx := c.(*resourceContext) - // if a webhookURL has been given, - // do not remove the resources here because - // we don't know if the result file has been - // generated or sent. - if !ctx.resource.has(webhookURL) { - if resourceErr := ctx.resource.close(); resourceErr != nil { - c.Logger().Error(resourceErr) - } - } - if err != nil { - if _, ok := err.(*echo.HTTPError); ok { - return err - } - if _, ok := err.(*errBadRequest); ok { - return echo.NewHTTPError(http.StatusBadRequest, err.Error()) - } - if strings.Contains(err.Error(), context.DeadlineExceeded.Error()) { - return echo.NewHTTPError(http.StatusRequestTimeout) - } - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) - } - return nil - } - } -} diff --git a/internal/app/api/resource.go b/internal/app/api/resource.go deleted file mode 100644 index 6648466d..00000000 --- a/internal/app/api/resource.go +++ /dev/null @@ -1,310 +0,0 @@ -package api - -import ( - "fmt" - "io" - "io/ioutil" - "os" - "path/filepath" - "strconv" - - "github.com/labstack/echo/v4" - "github.com/thecodingmachine/gotenberg/internal/pkg/printer" - "github.com/thecodingmachine/gotenberg/internal/pkg/rand" -) - -const ( - resultFilename string = "resultFilename" - waitTimeout string = "waitTimeout" - webhookURL string = "webhookURL" - remoteURL string = "remoteURL" - waitDelay string = "waitDelay" - paperWidth string = "paperWidth" - paperHeight string = "paperHeight" - marginTop string = "marginTop" - marginBottom string = "marginBottom" - marginLeft string = "marginLeft" - marginRight string = "marginRight" - landscape string = "landscape" -) - -type resource struct { - formValues map[string]string - formFilesDirPath string - opts *Options -} - -type resourceContext struct { - echo.Context - opts *Options - resource *resource -} - -func newResource(ctx *resourceContext) (*resource, error) { - r := &resource{ - formValues: formValues(ctx), - opts: ctx.opts, - } - dirPath, err := rand.Get() - if err != nil { - return r, err - } - r.formFilesDirPath = dirPath - if err := os.MkdirAll(dirPath, 0755); err != nil { - return nil, fmt.Errorf("%s: making directory: %v", dirPath, err) - } - if err := formFiles(ctx, dirPath); err != nil { - return r, err - } - return r, nil -} - -func formValues(ctx *resourceContext) map[string]string { - v := make(map[string]string) - v[resultFilename] = ctx.FormValue(resultFilename) - v[waitTimeout] = ctx.FormValue(waitTimeout) - v[webhookURL] = ctx.FormValue(webhookURL) - v[remoteURL] = ctx.FormValue(remoteURL) - v[waitDelay] = ctx.FormValue(waitDelay) - v[paperWidth] = ctx.FormValue(paperWidth) - v[paperHeight] = ctx.FormValue(paperHeight) - v[marginTop] = ctx.FormValue(marginTop) - v[marginBottom] = ctx.FormValue(marginBottom) - v[marginLeft] = ctx.FormValue(marginLeft) - v[marginRight] = ctx.FormValue(marginRight) - v[landscape] = ctx.FormValue(landscape) - return v -} - -func formFiles(ctx *resourceContext, dirPath string) error { - form, err := ctx.MultipartForm() - if err != nil { - return fmt.Errorf("getting multipart form: %v", err) - } - for _, files := range form.File { - for _, fh := range files { - in, err := fh.Open() - if err != nil { - return fmt.Errorf("%s: opening file: %v", fh.Filename, err) - } - defer in.Close() // nolint: errcheck - fpath := fmt.Sprintf("%s/%s", dirPath, fh.Filename) - out, err := os.Create(fpath) - if err != nil { - return fmt.Errorf("%s: creating new file: %v", fpath, err) - } - defer out.Close() // nolint: errcheck - if err := out.Chmod(0644); err != nil { - return fmt.Errorf("%s: changing file mode: %v", fpath, err) - } - if _, err := io.Copy(out, in); err != nil { - return fmt.Errorf("%s: writing file: %v", fpath, err) - } - if _, err := out.Seek(0, 0); err != nil { - return fmt.Errorf("%s: resetting read pointer: %v", fpath, err) - } - } - } - return nil -} - -func (r *resource) close() error { - if _, err := os.Stat(r.formFilesDirPath); os.IsNotExist(err) { - return nil - } - return os.RemoveAll(r.formFilesDirPath) -} - -const defaultHeaderFooterHTML string = "" - -func (r *resource) chromePrinterOptions() (*printer.ChromeOptions, error) { - timeout, err := r.float64(waitTimeout, r.opts.DefaultWaitTimeout) - if err != nil { - return nil, err - } - delay, err := r.float64(waitDelay, 0.0) - if err != nil { - return nil, err - } - header, err := r.content("header.html", defaultHeaderFooterHTML) - if err != nil { - return nil, err - } - footer, err := r.content("footer.html", defaultHeaderFooterHTML) - if err != nil { - return nil, err - } - width, err := r.float64(paperWidth, 8.27) - if err != nil { - return nil, err - } - height, err := r.float64(paperHeight, 11.7) - if err != nil { - return nil, err - } - top, err := r.float64(marginTop, 1) - if err != nil { - return nil, err - } - bottom, err := r.float64(marginBottom, 1) - if err != nil { - return nil, err - } - left, err := r.float64(marginLeft, 1) - if err != nil { - return nil, err - } - right, err := r.float64(marginRight, 1) - if err != nil { - return nil, err - } - landscape, err := r.bool(landscape, false) - if err != nil { - return nil, err - } - return &printer.ChromeOptions{ - WaitTimeout: timeout, - WaitDelay: delay, - HeaderHTML: header, - FooterHTML: footer, - PaperWidth: width, - PaperHeight: height, - MarginTop: top, - MarginBottom: bottom, - MarginLeft: left, - MarginRight: right, - Landscape: landscape, - }, nil -} - -func (r *resource) officePrinterOptions() (*printer.OfficeOptions, error) { - timeout, err := r.float64(waitTimeout, r.opts.DefaultWaitTimeout) - if err != nil { - return nil, err - } - landscape, err := r.bool(landscape, false) - if err != nil { - return nil, err - } - return &printer.OfficeOptions{ - WaitTimeout: timeout, - Landscape: landscape, - }, nil -} - -func (r *resource) mergePrinterOptions() (*printer.MergeOptions, error) { - timeout, err := r.float64(waitTimeout, r.opts.DefaultWaitTimeout) - if err != nil { - return nil, err - } - return &printer.MergeOptions{ - WaitTimeout: timeout, - }, nil -} - -func (r *resource) has(key string) bool { - v, ok := r.formValues[key] - if ok { - ok = v != "" - } - return ok -} - -func (r *resource) hasFile(filename string) bool { - fpath := fmt.Sprintf("%s/%s", r.formFilesDirPath, filename) - _, err := os.Stat(fpath) - return !os.IsNotExist(err) -} - -func (r *resource) get(key string) (string, error) { - v, ok := r.formValues[key] - if !ok { - return "", fmt.Errorf("form value %s does not exist", key) - } - return v, nil -} - -func (r *resource) float64(key string, defaultValue float64) (float64, error) { - if !r.has(key) { - return defaultValue, nil - } - v, err := r.get(key) - if err != nil { - return 0.0, err - } - f, err := strconv.ParseFloat(v, 64) - if err != nil { - return 0.0, fmt.Errorf("form value %s: %v", key, err) - } - return f, nil -} - -func (r *resource) bool(key string, defaultValue bool) (bool, error) { - if !r.has(key) { - return defaultValue, nil - } - v, err := r.get(key) - if err != nil { - return false, err - } - b, err := strconv.ParseBool(v) - if err != nil { - return false, fmt.Errorf("form value %s: %v", key, err) - } - return b, nil -} - -func (r *resource) fpath(filename string) (string, error) { - fpath := fmt.Sprintf("%s/%s", r.formFilesDirPath, filename) - _, err := os.Stat(fpath) - if os.IsNotExist(err) { - return "", fmt.Errorf("%s: form file does not exist", filename) - } - absPath, err := filepath.Abs(fpath) - if err != nil { - return "", fmt.Errorf("%s: getting absolute path: %v", fpath, err) - } - return absPath, nil -} - -func (r *resource) content(filename string, defaultValue string) (string, error) { - if !r.hasFile(filename) { - return defaultValue, nil - } - fpath, err := r.fpath(filename) - if err != nil { - return "", err - } - b, err := ioutil.ReadFile(fpath) - if err != nil { - return "", fmt.Errorf("%s: reading form file: %v", fpath, err) - } - return string(b), nil -} - -func (r *resource) fpaths(exts ...string) ([]string, error) { - var fpaths []string - err := filepath.Walk(r.formFilesDirPath, func(path string, info os.FileInfo, _ error) error { - if info.IsDir() { - return nil - } - fpath, err := r.fpath(info.Name()) - if err != nil { - return err - } - for _, ext := range exts { - if filepath.Ext(fpath) == ext { - fpaths = append(fpaths, fpath) - return nil - } - } - return nil - }) - if err != nil { - return nil, err - } - if len(fpaths) == 0 { - return nil, fmt.Errorf("no form files found for extensions: %v", exts) - } - return fpaths, nil -} diff --git a/internal/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..dda5d68e --- /dev/null +++ b/internal/app/xhttp/handler.go @@ -0,0 +1,301 @@ +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" + "github.com/thecodingmachine/gotenberg/internal/pkg/xtime" +) + +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) + logger := ctx.XLogger() + logger.DebugOp(op, "handling ping request...") + 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() + webhookURL, err := r.StringArg(resource.WebhookURLArgKey, "") + if err != nil { + return xerror.New(op, err) + } + webhookURLTimeout, err := resource.WebhookURLTimeoutArg(r, ctx.Config()) + if err != nil { + return xerror.New(op, err) + } + 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 + logger.DebugfOp( + op, + "sending result file '%s' to '%s'", + filename, + webhookURL, + ) + httpClient := &http.Client{ + Timeout: xtime.Duration(webhookURLTimeout), + } + resp, err := httpClient.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/handler_test.go b/internal/app/xhttp/handler_test.go new file mode 100644 index 00000000..11c0731d --- /dev/null +++ b/internal/app/xhttp/handler_test.go @@ -0,0 +1,533 @@ +package xhttp + +import ( + "errors" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" + + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/assert" + "github.com/thecodingmachine/gotenberg/internal/app/xhttp/pkg/resource" + "github.com/thecodingmachine/gotenberg/internal/pkg/conf" + "github.com/thecodingmachine/gotenberg/test" +) + +func TestPingHandler(t *testing.T) { + // should return 200. + config := conf.DefaultConfig() + srv := New(config) + srv = New(config) + req := httptest.NewRequest(http.MethodGet, pingEndpoint, nil) + test.AssertStatusCode(t, http.StatusOK, srv, req) +} + +func TestMergeHandler(t *testing.T) { + config := conf.DefaultConfig() + srv := New(config) + // should return 200. + body, contentType := test.MergeMultipartForm(t, nil) + req := httptest.NewRequest(http.MethodPost, mergeEndpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusOK, srv, req) + // should return 400 as "waitTimeout" form field + // value is < 0. + body, contentType = test.MergeMultipartForm(t, map[string]string{string(resource.WaitTimeoutArgKey): "-1"}) + req = httptest.NewRequest(http.MethodPost, mergeEndpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + // should return 400 as "waitTimeout" form field + // value is is > config.MaximumWaitTimeout(). + body, contentType = test.MergeMultipartForm(t, map[string]string{string(resource.WaitTimeoutArgKey): "31"}) + req = httptest.NewRequest(http.MethodPost, mergeEndpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + // should return 400 as "waitTimeout" form field + // value is invalid. + body, contentType = test.MergeMultipartForm(t, map[string]string{string(resource.WaitTimeoutArgKey): "not a float"}) + req = httptest.NewRequest(http.MethodPost, mergeEndpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + // should return 504. + body, contentType = test.MergeMultipartForm(t, map[string]string{string(resource.WaitTimeoutArgKey): "0"}) + req = httptest.NewRequest(http.MethodPost, mergeEndpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusGatewayTimeout, srv, req) +} + +func TestHTMLHandler(t *testing.T) { + config := conf.DefaultConfig() + srv := New(config) + endpoint := fmt.Sprintf("%s%s", convertGroupEndpoint, htmlEndpoint) + // should return 200. + body, contentType := test.HTMLMultipartForm(t, nil) + req := httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusOK, srv, req) + // should return 400 as "waitTimeout" form field + // value is < 0. + body, contentType = test.HTMLMultipartForm(t, map[string]string{string(resource.WaitTimeoutArgKey): "-1"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + // should return 400 as "waitTimeout" form field + // value is is > config.MaximumWaitTimeout(). + body, contentType = test.HTMLMultipartForm(t, map[string]string{string(resource.WaitTimeoutArgKey): "31"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + // should return 400 as "waitTimeout" form field + // value is invalid. + body, contentType = test.HTMLMultipartForm(t, map[string]string{string(resource.WaitTimeoutArgKey): "not a float"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + // should return 504. + body, contentType = test.HTMLMultipartForm(t, map[string]string{string(resource.WaitTimeoutArgKey): "0"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusGatewayTimeout, srv, req) + // should return 400 as "waitDelay" form field + // value is < 0. + body, contentType = test.HTMLMultipartForm(t, map[string]string{string(resource.WaitDelayArgKey): "-1"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + // should return 400 as "waitDelay" form field + // value is is > config.MaximumWaitDelay(). + body, contentType = test.HTMLMultipartForm(t, map[string]string{string(resource.WaitDelayArgKey): "31"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + // should return 400 as "waitDelay" form field + // value is invalid. + body, contentType = test.HTMLMultipartForm(t, map[string]string{string(resource.WaitDelayArgKey): "not a float"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + // should return 400 as "paperWidth" form field + // value is < 0. + body, contentType = test.HTMLMultipartForm(t, map[string]string{string(resource.PaperWidthArgKey): "-1"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + // should return 400 as "paperWidth" form field + // value is invalid. + body, contentType = test.HTMLMultipartForm(t, map[string]string{string(resource.PaperWidthArgKey): "not a float"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + // should return 400 as "paperHeight" form field + // value is < 0. + body, contentType = test.HTMLMultipartForm(t, map[string]string{string(resource.PaperHeightArgKey): "-1"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + // should return 400 as "paperHeight" form field + // value is invalid. + body, contentType = test.HTMLMultipartForm(t, map[string]string{string(resource.PaperHeightArgKey): "not a float"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + // should return 400 as "marginTop" form field + // value is < 0. + body, contentType = test.HTMLMultipartForm(t, map[string]string{string(resource.MarginTopArgKey): "-1"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + // should return 400 as "marginTop" form field + // value is invalid. + body, contentType = test.HTMLMultipartForm(t, map[string]string{string(resource.MarginTopArgKey): "not a float"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + // should return 400 as "marginBottom" form field + // value is < 0. + body, contentType = test.HTMLMultipartForm(t, map[string]string{string(resource.MarginBottomArgKey): "-1"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + // should return 400 as "marginBottom" form field + // value is invalid. + body, contentType = test.HTMLMultipartForm(t, map[string]string{string(resource.MarginBottomArgKey): "not a float"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + // should return 400 as "marginLeft" form field + // value is < 0. + body, contentType = test.HTMLMultipartForm(t, map[string]string{string(resource.MarginLeftArgKey): "-1"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + // should return 400 as "marginLeft" form field + // value is invalid. + body, contentType = test.HTMLMultipartForm(t, map[string]string{string(resource.MarginLeftArgKey): "not a float"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + // should return 400 as "marginRight" form field + // value is < 0. + body, contentType = test.HTMLMultipartForm(t, map[string]string{string(resource.MarginRightArgKey): "-1"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + // should return 400 as "marginRight" form field + // value is invalid. + body, contentType = test.HTMLMultipartForm(t, map[string]string{string(resource.MarginRightArgKey): "not a float"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + // should return 400 as "landscape" form field + // value is invalid. + body, contentType = test.HTMLMultipartForm(t, map[string]string{string(resource.LandscapeArgKey): "not a boolean"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) +} + +func TestURLHandler(t *testing.T) { + config := conf.DefaultConfig() + srv := New(config) + endpoint := fmt.Sprintf("%s%s", convertGroupEndpoint, urlEndpoint) + // should return 200. + body, contentType := test.URLMultipartForm(t, nil) + req := httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusOK, srv, req) + // should return 400 as "waitTimeout" form field + // value is < 0. + body, contentType = test.URLMultipartForm(t, map[string]string{string(resource.WaitTimeoutArgKey): "-1"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + // should return 400 as "waitTimeout" form field + // value is is > config.MaximumWaitTimeout(). + body, contentType = test.URLMultipartForm(t, map[string]string{string(resource.WaitTimeoutArgKey): "31"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + // should return 400 as "waitTimeout" form field + // value is invalid. + body, contentType = test.URLMultipartForm(t, map[string]string{string(resource.WaitTimeoutArgKey): "not a float"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + // should return 504. + body, contentType = test.URLMultipartForm(t, map[string]string{string(resource.WaitTimeoutArgKey): "0"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusGatewayTimeout, srv, req) + // should return 400 as "waitDelay" form field + // value is < 0. + body, contentType = test.URLMultipartForm(t, map[string]string{string(resource.WaitDelayArgKey): "-1"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + // should return 400 as "waitDelay" form field + // value is is > config.MaximumWaitDelay(). + body, contentType = test.URLMultipartForm(t, map[string]string{string(resource.WaitDelayArgKey): "31"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + // should return 400 as "waitDelay" form field + // value is invalid. + body, contentType = test.URLMultipartForm(t, map[string]string{string(resource.WaitDelayArgKey): "not a float"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + // should return 400 as "paperWidth" form field + // value is < 0. + body, contentType = test.URLMultipartForm(t, map[string]string{string(resource.PaperWidthArgKey): "-1"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + // should return 400 as "paperWidth" form field + // value is invalid. + body, contentType = test.URLMultipartForm(t, map[string]string{string(resource.PaperWidthArgKey): "not a float"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + // should return 400 as "paperHeight" form field + // value is < 0. + body, contentType = test.URLMultipartForm(t, map[string]string{string(resource.PaperHeightArgKey): "-1"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + // should return 400 as "paperHeight" form field + // value is invalid. + body, contentType = test.URLMultipartForm(t, map[string]string{string(resource.PaperHeightArgKey): "not a float"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + // should return 400 as "marginTop" form field + // value is < 0. + body, contentType = test.URLMultipartForm(t, map[string]string{string(resource.MarginTopArgKey): "-1"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + // should return 400 as "marginTop" form field + // value is invalid. + body, contentType = test.URLMultipartForm(t, map[string]string{string(resource.MarginTopArgKey): "not a float"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + // should return 400 as "marginBottom" form field + // value is < 0. + body, contentType = test.URLMultipartForm(t, map[string]string{string(resource.MarginBottomArgKey): "-1"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + // should return 400 as "marginBottom" form field + // value is invalid. + body, contentType = test.URLMultipartForm(t, map[string]string{string(resource.MarginBottomArgKey): "not a float"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + // should return 400 as "marginLeft" form field + // value is < 0. + body, contentType = test.URLMultipartForm(t, map[string]string{string(resource.MarginLeftArgKey): "-1"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + // should return 400 as "marginLeft" form field + // value is invalid. + body, contentType = test.URLMultipartForm(t, map[string]string{string(resource.MarginLeftArgKey): "not a float"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + // should return 400 as "marginRight" form field + // value is < 0. + body, contentType = test.URLMultipartForm(t, map[string]string{string(resource.MarginRightArgKey): "-1"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + // should return 400 as "marginRight" form field + // value is invalid. + body, contentType = test.URLMultipartForm(t, map[string]string{string(resource.MarginRightArgKey): "not a float"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + // should return 400 as "landscape" form field + // value is invalid. + body, contentType = test.URLMultipartForm(t, map[string]string{string(resource.LandscapeArgKey): "not a boolean"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) +} + +func TestMarkdownHandler(t *testing.T) { + config := conf.DefaultConfig() + srv := New(config) + endpoint := fmt.Sprintf("%s%s", convertGroupEndpoint, markdownEndpoint) + // should return 200. + body, contentType := test.MarkdownMultipartForm(t, nil) + req := httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusOK, srv, req) + // should return 400 as "waitTimeout" form field + // value is < 0. + body, contentType = test.MarkdownMultipartForm(t, map[string]string{string(resource.WaitTimeoutArgKey): "-1"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + // should return 400 as "waitTimeout" form field + // value is is > config.MaximumWaitTimeout(). + body, contentType = test.MarkdownMultipartForm(t, map[string]string{string(resource.WaitTimeoutArgKey): "31"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + // should return 400 as "waitTimeout" form field + // value is invalid. + body, contentType = test.MarkdownMultipartForm(t, map[string]string{string(resource.WaitTimeoutArgKey): "not a float"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + // should return 504. + body, contentType = test.MarkdownMultipartForm(t, map[string]string{string(resource.WaitTimeoutArgKey): "0"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusGatewayTimeout, srv, req) + // should return 400 as "waitDelay" form field + // value is < 0. + body, contentType = test.MarkdownMultipartForm(t, map[string]string{string(resource.WaitDelayArgKey): "-1"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + // should return 400 as "waitDelay" form field + // value is is > config.MaximumWaitDelay(). + body, contentType = test.MarkdownMultipartForm(t, map[string]string{string(resource.WaitDelayArgKey): "31"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + // should return 400 as "waitDelay" form field + // value is invalid. + body, contentType = test.MarkdownMultipartForm(t, map[string]string{string(resource.WaitDelayArgKey): "not a float"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + // should return 400 as "paperWidth" form field + // value is < 0. + body, contentType = test.MarkdownMultipartForm(t, map[string]string{string(resource.PaperWidthArgKey): "-1"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + // should return 400 as "paperWidth" form field + // value is invalid. + body, contentType = test.MarkdownMultipartForm(t, map[string]string{string(resource.PaperWidthArgKey): "not a float"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + // should return 400 as "paperHeight" form field + // value is < 0. + body, contentType = test.MarkdownMultipartForm(t, map[string]string{string(resource.PaperHeightArgKey): "-1"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + // should return 400 as "paperHeight" form field + // value is invalid. + body, contentType = test.MarkdownMultipartForm(t, map[string]string{string(resource.PaperHeightArgKey): "not a float"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + // should return 400 as "marginTop" form field + // value is < 0. + body, contentType = test.MarkdownMultipartForm(t, map[string]string{string(resource.MarginTopArgKey): "-1"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + // should return 400 as "marginTop" form field + // value is invalid. + body, contentType = test.MarkdownMultipartForm(t, map[string]string{string(resource.MarginTopArgKey): "not a float"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + // should return 400 as "marginBottom" form field + // value is < 0. + body, contentType = test.MarkdownMultipartForm(t, map[string]string{string(resource.MarginBottomArgKey): "-1"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + // should return 400 as "marginBottom" form field + // value is invalid. + body, contentType = test.MarkdownMultipartForm(t, map[string]string{string(resource.MarginBottomArgKey): "not a float"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + // should return 400 as "marginLeft" form field + // value is < 0. + body, contentType = test.MarkdownMultipartForm(t, map[string]string{string(resource.MarginLeftArgKey): "-1"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + // should return 400 as "marginLeft" form field + // value is invalid. + body, contentType = test.MarkdownMultipartForm(t, map[string]string{string(resource.MarginLeftArgKey): "not a float"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + // should return 400 as "marginRight" form field + // value is < 0. + body, contentType = test.MarkdownMultipartForm(t, map[string]string{string(resource.MarginRightArgKey): "-1"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + // should return 400 as "marginRight" form field + // value is invalid. + body, contentType = test.MarkdownMultipartForm(t, map[string]string{string(resource.MarginRightArgKey): "not a float"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + // should return 400 as "landscape" form field + // value is invalid. + body, contentType = test.MarkdownMultipartForm(t, map[string]string{string(resource.LandscapeArgKey): "not a boolean"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) +} + +func TestOfficeHandler(t *testing.T) { + config := conf.DefaultConfig() + srv := New(config) + endpoint := fmt.Sprintf("%s%s", convertGroupEndpoint, officeEndpoint) + // should return 200. + body, contentType := test.OfficeMultipartForm(t, nil) + req := httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusOK, srv, req) + // should return 400 as "waitTimeout" form field + // value is < 0. + body, contentType = test.OfficeMultipartForm(t, map[string]string{string(resource.WaitTimeoutArgKey): "-1"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + // should return 400 as "waitTimeout" form field + // value is is > config.MaximumWaitTimeout(). + body, contentType = test.OfficeMultipartForm(t, map[string]string{string(resource.WaitTimeoutArgKey): "31"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + // should return 400 as "waitTimeout" form field + // value is invalid. + body, contentType = test.OfficeMultipartForm(t, map[string]string{string(resource.WaitTimeoutArgKey): "not a float"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + // should return 504. + body, contentType = test.OfficeMultipartForm(t, map[string]string{string(resource.WaitTimeoutArgKey): "0"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusGatewayTimeout, srv, req) + // should return 400 as "landscape" form field + // value is invalid. + body, contentType = test.OfficeMultipartForm(t, map[string]string{string(resource.LandscapeArgKey): "not a boolean"}) + req = httptest.NewRequest(http.MethodPost, endpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) +} + +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") + }() + config := conf.DefaultConfig() + srv := New(config) + // our custom server should receive the PDF. + body, contentType := test.MergeMultipartForm(t, map[string]string{string(resource.WebhookURLArgKey): "http://localhost:3001/foo"}) + req := httptest.NewRequest(http.MethodPost, mergeEndpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusOK, srv, req) + err := <-status + assert.NoError(t, err) +} + +func TestResultFilename(t *testing.T) { + config := conf.DefaultConfig() + srv := New(config) + body, contentType := test.MergeMultipartForm(t, map[string]string{string(resource.ResultFilenameArgKey): "foo.pdf"}) + req := httptest.NewRequest(http.MethodPost, mergeEndpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + assert.Equal(t, "attachment; filename=\"foo.pdf\"", rec.Header().Get("Content-Disposition")) +} diff --git a/internal/app/xhttp/middleware.go b/internal/app/xhttp/middleware.go new file mode 100644 index 00000000..5e5ca0b3 --- /dev/null +++ b/internal/app/xhttp/middleware.go @@ -0,0 +1,136 @@ +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/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) 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) + // 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 { + err = doCleanup(ctx, err) + err = doErr(ctx, 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 { + err := next(c) + ctx := context.MustCastFromEchoContext(c) + return doCleanup(ctx, 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 + } + return doErr(ctx, err) + } + } +} + +func doCleanup(ctx context.Context, err error) error { + const op string = "xhttp.cleanup" + 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 +} + +func doErr(ctx context.Context, err error) error { + // if it's an error from echo + // like 404 not found and so on. + if echoHTTPErr, ok := err.(*echo.HTTPError); ok { + // required to have a correct status code. + ctx.Error(echoHTTPErr) + 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: + httpErr = echo.NewHTTPError(http.StatusGatewayTimeout, 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..d79841e8 --- /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, config) + if err != nil { + return printer.ChromePrinterOptions{}, err + } + paperWidth, paperHeight, + err := resource.PaperSizeArgs(r, config) + if err != nil { + return printer.ChromePrinterOptions{}, err + } + marginTop, marginBottom, marginLeft, marginRight, + err := resource.MarginArgs(r, config) + 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..d1065b58 --- /dev/null +++ b/internal/app/xhttp/pkg/context/context.go @@ -0,0 +1,208 @@ +package context + +import ( + "fmt" + "io" + "net/http" + "reflect" + "strconv" + "strings" + "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/normalize" + "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 + resource resource.Resource + startTime time.Time +} + +// New creates a new Context. +func New(c echo.Context, logger xlog.Logger, config conf.Config) Context { + return Context{ + c, + logger, + config, + 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 +} + +// 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 { + /* + (very) special case: one and + only one file has been sent + and it is empty. + */ + if strings.Contains(err.Error(), io.EOF.Error()) { + return r, xerror.Invalid(op, "one file has been sent but it is empty: does it exist?", err) + } + 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 + filename, err := normalize.String(fh.Filename) + if err != nil { + return r, err + } + if err := r.WithFile(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 +} + +// HasResource returns true if the Context +// has a resource.Resource. +func (ctx Context) HasResource() bool { + return !reflect.DeepEqual(ctx.resource, resource.Resource{}) +} + +/* +MustResource returns the resource.Resource +associated with the Context. + +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 +} + +/* +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/context_test.go b/internal/app/xhttp/pkg/context/context_test.go new file mode 100644 index 00000000..9b803460 --- /dev/null +++ b/internal/app/xhttp/pkg/context/context_test.go @@ -0,0 +1,78 @@ +package context + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/thecodingmachine/gotenberg/internal/pkg/conf" + "github.com/thecodingmachine/gotenberg/test" +) + +func TestMustCastFromEchoContext(t *testing.T) { + // should be OK. + ctx := New( + test.DummyEchoContext(), + test.DebugLogger(), + conf.DefaultConfig(), + ) + assert.NotPanics(t, func() { + result := MustCastFromEchoContext(ctx) + assert.Equal(t, ctx, result) + }) + // should not be OK. + assert.Panics(t, func() { + MustCastFromEchoContext(test.DummyEchoContext()) + }) +} + +func TestLogRequestResult(t *testing.T) { + ctx := New( + test.DummyEchoContext(), + test.DebugLogger(), + conf.DefaultConfig(), + ) + // Info log. + err := ctx.LogRequestResult(nil, false) + assert.Nil(t, err) + // Debug log. + err = ctx.LogRequestResult(nil, true) + assert.Nil(t, err) + // Error log. + err = ctx.LogRequestResult(errors.New("foo"), true) + assert.NotNil(t, err) +} + +func TestGetters(t *testing.T) { + const resourceDirectoryName string = "foo" + logger := test.DebugLogger() + config := conf.DefaultConfig() + ctx := New( + test.DummyEchoContext(), + logger, + config, + ) + // Logger. + assert.Equal(t, logger, ctx.XLogger()) + // Config. + assert.Equal(t, config, ctx.Config()) + // Context should not have a resource.Resource. + assert.Equal(t, false, ctx.HasResource()) + assert.Panics(t, func() { + ctx.MustResource() + }) + // Context should have a resource.Resource. + ctx = New( + test.EchoContextMultipart(t), + logger, + config, + ) + err := ctx.WithResource(resourceDirectoryName) + assert.Nil(t, err) + assert.Equal(t, true, ctx.HasResource()) + assert.NotPanics(t, func() { + r := ctx.MustResource() + err = r.Close() + assert.Nil(t, err) + }) +} 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..35c7800b --- /dev/null +++ b/internal/app/xhttp/pkg/resource/arg.go @@ -0,0 +1,267 @@ +package resource + +import ( + "github.com/thecodingmachine/gotenberg/internal/pkg/conf" + "github.com/thecodingmachine/gotenberg/internal/pkg/printer" + "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 +} + +/* +WebhookURLTimeoutArg is a helper for retrieving +the "webhookURLTimeout" argument as float64. + +It also validates it against the application +configuration. +*/ +func WebhookURLTimeoutArg(r Resource, config conf.Config) (float64, error) { + const op string = "resource.WebhookURLTimeoutArg" + result, err := r.Float64Arg( + WebhookURLTimeoutArgKey, + config.DefaultWebhookURLTimeout(), + xassert.Float64NotInferiorTo(0), + xassert.Float64NotSuperiorTo(config.MaximumWebhookURLTimeout()), + ) + 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, config conf.Config) (float64, float64, error) { + const op string = "resource.PaperSizeArgs" + opts := printer.DefaultChromePrinterOptions(config) + resolver := func() (float64, float64, error) { + paperWidth, err := r.Float64Arg( + PaperWidthArgKey, + opts.PaperWidth, + xassert.Float64NotInferiorTo(0.0), + ) + if err != nil { + return opts.PaperWidth, + opts.PaperHeight, + err + } + paperHeight, err := r.Float64Arg( + PaperHeightArgKey, + opts.PaperHeight, + xassert.Float64NotInferiorTo(0.0), + ) + if err != nil { + return opts.PaperWidth, + opts.PaperHeight, + 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, config conf.Config) (float64, float64, float64, float64, error) { + const op string = "resource.MarginArgs" + opts := printer.DefaultChromePrinterOptions(config) + resolver := func() (float64, float64, float64, float64, error) { + marginTop, err := r.Float64Arg( + MarginTopArgKey, + opts.MarginTop, + xassert.Float64NotInferiorTo(0.0), + ) + if err != nil { + return opts.MarginTop, + opts.MarginBottom, + opts.MarginLeft, + opts.MarginRight, + err + } + marginBottom, err := r.Float64Arg( + MarginBottomArgKey, + opts.MarginBottom, + xassert.Float64NotInferiorTo(0.0), + ) + if err != nil { + return opts.MarginTop, + opts.MarginBottom, + opts.MarginLeft, + opts.MarginRight, + err + } + marginLeft, err := r.Float64Arg( + MarginLeftArgKey, + opts.MarginLeft, + xassert.Float64NotInferiorTo(0.0), + ) + if err != nil { + return opts.MarginTop, + opts.MarginBottom, + opts.MarginLeft, + opts.MarginRight, + err + } + marginRight, err := r.Float64Arg( + MarginRightArgKey, + opts.MarginRight, + xassert.Float64NotInferiorTo(0.0), + ) + if err != nil { + return opts.MarginTop, + opts.MarginBottom, + opts.MarginLeft, + opts.MarginRight, + 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/arg_test.go b/internal/app/xhttp/pkg/resource/arg_test.go new file mode 100644 index 00000000..3c7f6b02 --- /dev/null +++ b/internal/app/xhttp/pkg/resource/arg_test.go @@ -0,0 +1,302 @@ +package resource + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/thecodingmachine/gotenberg/internal/pkg/conf" + "github.com/thecodingmachine/gotenberg/internal/pkg/printer" + "github.com/thecodingmachine/gotenberg/test" +) + +func TestArgKeys(t *testing.T) { + expected := []ArgKey{ + ResultFilenameArgKey, + WaitTimeoutArgKey, + WebhookURLArgKey, + WebhookURLTimeoutArgKey, + RemoteURLArgKey, + WaitDelayArgKey, + PaperWidthArgKey, + PaperHeightArgKey, + MarginTopArgKey, + MarginBottomArgKey, + MarginLeftArgKey, + MarginRightArgKey, + LandscapeArgKey, + } + assert.Equal(t, expected, ArgKeys()) +} + +func TestWaitTimeoutArg(t *testing.T) { + const resourceDirectoryName string = "foo" + var expected float64 + logger := test.DebugLogger() + config := conf.DefaultConfig() + r, err := New(logger, resourceDirectoryName) + assert.Nil(t, err) + // argument does not exist. + expected = config.DefaultWaitTimeout() + v, err := WaitTimeoutArg(r, config) + assert.Nil(t, err) + assert.Equal(t, expected, v) + // argument exist. + expected = 5.0 + r.WithArg(WaitTimeoutArgKey, "5.0") + v, err = WaitTimeoutArg(r, config) + assert.Nil(t, err) + assert.Equal(t, expected, v) + // should not be OK as argument + // value is < 0. + expected = config.DefaultWaitTimeout() + r.WithArg(WaitTimeoutArgKey, "-1.0") + v, err = WaitTimeoutArg(r, config) + test.AssertError(t, err) + assert.Equal(t, expected, v) + // should not be OK as argument + // value is > config.MaximumWaitTimeout(). + expected = config.DefaultWaitTimeout() + r.WithArg(WaitTimeoutArgKey, "31.0") + v, err = WaitTimeoutArg(r, config) + test.AssertError(t, err) + assert.Equal(t, expected, v) + // should not be OK as + // argument value is invalid. + expected = config.DefaultWaitTimeout() + r.WithArg(WaitTimeoutArgKey, "foo") + v, err = WaitTimeoutArg(r, config) + test.AssertError(t, err) + assert.Equal(t, expected, v) + // finally... + err = r.Close() + assert.Nil(t, err) +} + +func TestWaitDelayArg(t *testing.T) { + const ( + resourceDirectoryName string = "foo" + defaultValue float64 = 0.0 + ) + var expected float64 + logger := test.DebugLogger() + config := conf.DefaultConfig() + r, err := New(logger, resourceDirectoryName) + assert.Nil(t, err) + // argument does not exist. + expected = defaultValue + v, err := WaitDelayArg(r, config) + assert.Nil(t, err) + assert.Equal(t, expected, v) + // argument exist. + expected = 5.0 + r.WithArg(WaitDelayArgKey, "5.0") + v, err = WaitDelayArg(r, config) + assert.Nil(t, err) + assert.Equal(t, expected, v) + // should not be OK as argument + // value is < 0. + expected = defaultValue + r.WithArg(WaitDelayArgKey, "-1.0") + v, err = WaitDelayArg(r, config) + test.AssertError(t, err) + assert.Equal(t, expected, v) + // should not be OK as argument + // value is > config.MaximumWaitDelay(). + expected = defaultValue + r.WithArg(WaitDelayArgKey, "31.0") + v, err = WaitDelayArg(r, config) + test.AssertError(t, err) + assert.Equal(t, expected, v) + // should not be OK as + // argument value is invalid. + expected = defaultValue + r.WithArg(WaitDelayArgKey, "foo") + v, err = WaitDelayArg(r, config) + test.AssertError(t, err) + assert.Equal(t, expected, v) + // finally... + err = r.Close() + assert.Nil(t, err) +} + +func TestWebhookURLTimeoutArg(t *testing.T) { + const resourceDirectoryName string = "foo" + var expected float64 + logger := test.DebugLogger() + config := conf.DefaultConfig() + r, err := New(logger, resourceDirectoryName) + assert.Nil(t, err) + // argument does not exist. + expected = config.DefaultWebhookURLTimeout() + v, err := WebhookURLTimeoutArg(r, config) + assert.Nil(t, err) + assert.Equal(t, expected, v) + // argument exist. + expected = 5.0 + r.WithArg(WebhookURLTimeoutArgKey, "5.0") + v, err = WebhookURLTimeoutArg(r, config) + assert.Nil(t, err) + assert.Equal(t, expected, v) + // should not be OK as argument + // value is < 0. + expected = config.DefaultWebhookURLTimeout() + r.WithArg(WebhookURLTimeoutArgKey, "-1.0") + v, err = WebhookURLTimeoutArg(r, config) + test.AssertError(t, err) + assert.Equal(t, expected, v) + // should not be OK as argument + // value is > config.MaximumWebhookURLTimeout(). + expected = config.DefaultWebhookURLTimeout() + r.WithArg(WebhookURLTimeoutArgKey, "31.0") + v, err = WebhookURLTimeoutArg(r, config) + test.AssertError(t, err) + assert.Equal(t, expected, v) + // should not be OK as + // argument value is invalid. + expected = config.DefaultWebhookURLTimeout() + r.WithArg(WebhookURLTimeoutArgKey, "foo") + v, err = WebhookURLTimeoutArg(r, config) + test.AssertError(t, err) + assert.Equal(t, expected, v) + // finally... + err = r.Close() + assert.Nil(t, err) +} + +func TestPaperSizeArgs(t *testing.T) { + const resourceDirectoryName string = "foo" + var expected float64 + logger := test.DebugLogger() + config := conf.DefaultConfig() + opts := printer.DefaultChromePrinterOptions(config) + r, err := New(logger, resourceDirectoryName) + assert.Nil(t, err) + // arguments do not exist. + width, height, err := PaperSizeArgs(r, config) + assert.Nil(t, err) + assert.Equal(t, opts.PaperWidth, width) + assert.Equal(t, opts.PaperHeight, height) + // arguments exist. + expected = 5.0 + r.WithArg(PaperWidthArgKey, "5.0") + r.WithArg(PaperHeightArgKey, "5.0") + width, height, err = PaperSizeArgs(r, config) + assert.Nil(t, err) + assert.Equal(t, expected, width) + assert.Equal(t, expected, height) + // should not be OK as arguments + // value are < 0. + expected = opts.PaperWidth + r.WithArg(PaperWidthArgKey, "-1.0") + width, _, err = PaperSizeArgs(r, config) + test.AssertError(t, err) + assert.Equal(t, expected, width) + r.WithArg(PaperWidthArgKey, "5.0") + expected = opts.PaperHeight + r.WithArg(PaperHeightArgKey, "-1.0") + _, height, err = PaperSizeArgs(r, config) + test.AssertError(t, err) + assert.Equal(t, expected, height) + r.WithArg(PaperHeightArgKey, "5.0") + // should not be OK as + // arguments value are invalids. + expected = opts.PaperWidth + r.WithArg(PaperWidthArgKey, "foo") + width, _, err = PaperSizeArgs(r, config) + test.AssertError(t, err) + assert.Equal(t, expected, width) + r.WithArg(PaperWidthArgKey, "5.0") + expected = opts.PaperHeight + r.WithArg(PaperHeightArgKey, "foo") + _, height, err = PaperSizeArgs(r, config) + test.AssertError(t, err) + assert.Equal(t, expected, height) + r.WithArg(PaperHeightArgKey, "5.0") + // finally... + err = r.Close() + assert.Nil(t, err) +} + +func TestMarginArgs(t *testing.T) { + const resourceDirectoryName string = "foo" + var expected float64 + logger := test.DebugLogger() + config := conf.DefaultConfig() + opts := printer.DefaultChromePrinterOptions(config) + r, err := New(logger, resourceDirectoryName) + assert.Nil(t, err) + // arguments do not exist. + top, bottom, left, right, err := MarginArgs(r, config) + assert.Nil(t, err) + assert.Equal(t, opts.MarginTop, top) + assert.Equal(t, opts.MarginBottom, bottom) + assert.Equal(t, opts.MarginLeft, left) + assert.Equal(t, opts.MarginRight, right) + // arguments exist. + expected = 5.0 + r.WithArg(MarginTopArgKey, "5.0") + r.WithArg(MarginBottomArgKey, "5.0") + r.WithArg(MarginLeftArgKey, "5.0") + r.WithArg(MarginRightArgKey, "5.0") + top, bottom, left, right, err = MarginArgs(r, config) + assert.Nil(t, err) + assert.Equal(t, expected, top) + assert.Equal(t, expected, bottom) + assert.Equal(t, expected, left) + assert.Equal(t, expected, right) + // should not be OK as arguments + // value are < 0. + expected = opts.MarginTop + r.WithArg(MarginTopArgKey, "-1.0") + top, _, _, _, err = MarginArgs(r, config) + test.AssertError(t, err) + assert.Equal(t, expected, top) + r.WithArg(MarginTopArgKey, "5.0") + expected = opts.MarginBottom + r.WithArg(MarginBottomArgKey, "-1.0") + _, bottom, _, _, err = MarginArgs(r, config) + test.AssertError(t, err) + assert.Equal(t, expected, bottom) + r.WithArg(MarginBottomArgKey, "5.0") + expected = opts.MarginLeft + r.WithArg(MarginLeftArgKey, "-1.0") + _, _, left, _, err = MarginArgs(r, config) + test.AssertError(t, err) + assert.Equal(t, expected, left) + r.WithArg(MarginLeftArgKey, "5.0") + expected = opts.MarginRight + r.WithArg(MarginRightArgKey, "-1.0") + _, _, _, right, err = MarginArgs(r, config) + test.AssertError(t, err) + assert.Equal(t, expected, right) + r.WithArg(MarginRightArgKey, "5.0") + // should not be OK as + // arguments value are invalids. + expected = opts.MarginTop + r.WithArg(MarginTopArgKey, "foo") + top, _, _, _, err = MarginArgs(r, config) + test.AssertError(t, err) + assert.Equal(t, expected, top) + r.WithArg(MarginTopArgKey, "5.0") + expected = opts.MarginBottom + r.WithArg(MarginBottomArgKey, "foo") + _, bottom, _, _, err = MarginArgs(r, config) + test.AssertError(t, err) + assert.Equal(t, expected, bottom) + r.WithArg(MarginBottomArgKey, "5.0") + expected = opts.MarginLeft + r.WithArg(MarginLeftArgKey, "foo") + _, _, left, _, err = MarginArgs(r, config) + test.AssertError(t, err) + assert.Equal(t, expected, left) + r.WithArg(MarginLeftArgKey, "5.0") + expected = opts.MarginRight + r.WithArg(MarginRightArgKey, "foo") + _, _, _, right, err = MarginArgs(r, config) + test.AssertError(t, err) + assert.Equal(t, expected, right) + r.WithArg(MarginRightArgKey, "5.0") + // finally... + err = r.Close() + assert.Nil(t, err) +} 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..45f18fd1 --- /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/conf" + "github.com/thecodingmachine/gotenberg/internal/pkg/printer" + "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, config conf.Config) (string, string, error) { + const op string = "resource.HeaderFooterContents" + opts := printer.DefaultChromePrinterOptions(config) + resolver := func() (string, string, error) { + headerHTML, err := r.Fcontent("header.html", opts.HeaderHTML) + if err != nil { + return opts.HeaderHTML, + opts.FooterHTML, + err + } + footerHTML, err := r.Fcontent("footer.html", opts.FooterHTML) + if err != nil { + return opts.HeaderHTML, + opts.FooterHTML, + 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/file_test.go b/internal/app/xhttp/pkg/resource/file_test.go new file mode 100644 index 00000000..70b99370 --- /dev/null +++ b/internal/app/xhttp/pkg/resource/file_test.go @@ -0,0 +1,45 @@ +package resource + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/thecodingmachine/gotenberg/internal/pkg/conf" + "github.com/thecodingmachine/gotenberg/internal/pkg/printer" + "github.com/thecodingmachine/gotenberg/test" +) + +func TestHeaderFooterContents(t *testing.T) { + const resourceDirectoryName string = "foo" + var expected string + logger := test.DebugLogger() + config := conf.DefaultConfig() + opts := printer.DefaultChromePrinterOptions(config) + r, err := New(logger, resourceDirectoryName) + assert.Nil(t, err) + // arguments do not exist. + header, footer, err := HeaderFooterContents(r, config) + assert.Nil(t, err) + assert.Equal(t, opts.HeaderHTML, header) + assert.Equal(t, opts.FooterHTML, footer) + // arguments exist. + expected = "Gutenberg" + fpath := test.OfficeFpaths(t)[2] + f1, err := os.Open(fpath) + assert.Nil(t, err) + defer f1.Close() // nolint: errcheck + err = r.WithFile("header.html", f1) + assert.Nil(t, err) + f2, err := os.Open(fpath) + assert.Nil(t, err) + defer f2.Close() // nolint: errcheck + err = r.WithFile("footer.html", f2) + header, footer, err = HeaderFooterContents(r, config) + assert.Nil(t, err) + assert.Contains(t, header, expected) + assert.Contains(t, footer, expected) + // finally... + err = r.Close() + assert.Nil(t, err) +} 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/pkg/resource/resource_test.go b/internal/app/xhttp/pkg/resource/resource_test.go new file mode 100644 index 00000000..3faae9d3 --- /dev/null +++ b/internal/app/xhttp/pkg/resource/resource_test.go @@ -0,0 +1,286 @@ +package resource + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/thecodingmachine/gotenberg/internal/pkg/xassert" + "github.com/thecodingmachine/gotenberg/test" +) + +func TestStringArg(t *testing.T) { + const ( + resourceDirectoryName string = "foo" + defaultValue string = "FOO" + ) + var expected string + rule := xassert.StringOneOf([]string{"FOO", "BAR"}) + logger := test.DebugLogger() + r, err := New(logger, resourceDirectoryName) + assert.Nil(t, err) + // empty value, result should be equal + // to the default value. + r.WithArg(ResultFilenameArgKey, "") + v, err := r.StringArg(ResultFilenameArgKey, defaultValue) + assert.Nil(t, err) + assert.Equal(t, defaultValue, v) + // result should be equal to given value + // as it is one of "FOO" and "BAR". + expected = "BAR" + r.WithArg(ResultFilenameArgKey, expected) + v, err = r.StringArg(ResultFilenameArgKey, defaultValue, rule) + assert.Nil(t, err) + assert.Equal(t, expected, v) + // should not be OK as given value is not + // one of "FOO" and "BAR". + expected = defaultValue + r.WithArg(ResultFilenameArgKey, "BAZ") + v, err = r.StringArg(ResultFilenameArgKey, defaultValue, rule) + test.AssertError(t, err) + assert.Equal(t, expected, v) + // finally... + err = r.Close() + assert.Nil(t, err) +} + +func TestInt64Arg(t *testing.T) { + const ( + resourceDirectoryName string = "foo" + defaultValue int64 = 10 + ) + var expected int64 + rule := xassert.Int64NotInferiorTo(6) + logger := test.DebugLogger() + r, err := New(logger, resourceDirectoryName) + assert.Nil(t, err) + // empty value, result should be equal + // to the default value. + r.WithArg(WaitTimeoutArgKey, "") + v, err := r.Int64Arg(WaitTimeoutArgKey, defaultValue) + expected = defaultValue + assert.Equal(t, expected, v) + assert.Nil(t, err) + // result should be equal to given value + // but as integer. + r.WithArg(WaitTimeoutArgKey, "5") + v, err = r.Int64Arg(WaitTimeoutArgKey, 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. + r.WithArg(WaitTimeoutArgKey, "foo") + v, err = r.Int64Arg(WaitTimeoutArgKey, defaultValue) + expected = defaultValue + assert.Equal(t, expected, v) + test.AssertError(t, err) + // should not be OK as given value does not + // validate the rule x >= 6. + r.WithArg(WaitTimeoutArgKey, "5") + v, err = r.Int64Arg(WaitTimeoutArgKey, defaultValue, rule) + expected = defaultValue + assert.Equal(t, expected, v) + test.AssertError(t, err) + // finally... + err = r.Close() + assert.Nil(t, err) +} + +func TestFloat64Arg(t *testing.T) { + const ( + resourceDirectoryName string = "foo" + defaultValue float64 = 10.0 + ) + var expected float64 + rule := xassert.Float64NotInferiorTo(6.0) + logger := test.DebugLogger() + r, err := New(logger, resourceDirectoryName) + assert.Nil(t, err) + // empty value, result should be equal + // to the default value. + r.WithArg(WaitTimeoutArgKey, "") + v, err := r.Float64Arg(WaitTimeoutArgKey, defaultValue) + expected = defaultValue + assert.Equal(t, expected, v) + assert.Nil(t, err) + // result should be equal to given value + // but as float. + r.WithArg(WaitTimeoutArgKey, "5.5") + v, err = r.Float64Arg(WaitTimeoutArgKey, 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. + r.WithArg(WaitTimeoutArgKey, "foo") + v, err = r.Float64Arg(WaitTimeoutArgKey, defaultValue) + expected = defaultValue + assert.Equal(t, expected, v) + test.AssertError(t, err) + // should not be OK as given value does not + // validate the rule x >= 6. + r.WithArg(WaitTimeoutArgKey, "5.0") + v, err = r.Float64Arg(WaitTimeoutArgKey, defaultValue, rule) + expected = defaultValue + assert.Equal(t, expected, v) + test.AssertError(t, err) + // finally... + err = r.Close() + assert.Nil(t, err) +} + +func TestBoolArg(t *testing.T) { + const ( + resourceDirectoryName string = "foo" + defaultValue bool = true + ) + var expected bool + logger := test.DebugLogger() + r, err := New(logger, resourceDirectoryName) + assert.Nil(t, err) + // empty value, result should be equal + // to the default value. + r.WithArg(LandscapeArgKey, "") + v, err := r.BoolArg(LandscapeArgKey, defaultValue) + expected = defaultValue + assert.Equal(t, expected, v) + assert.Nil(t, err) + // result should be equal to given value + // but as boolean. + r.WithArg(LandscapeArgKey, "1") + v, err = r.BoolArg(LandscapeArgKey, defaultValue) + expected = true + assert.Equal(t, expected, v) + assert.Nil(t, err) + r.WithArg(LandscapeArgKey, "true") + v, err = r.BoolArg(LandscapeArgKey, defaultValue) + expected = true + assert.Equal(t, expected, v) + assert.Nil(t, err) + r.WithArg(LandscapeArgKey, "0") + v, err = r.BoolArg(LandscapeArgKey, defaultValue) + expected = false + assert.Equal(t, expected, v) + assert.Nil(t, err) + r.WithArg(LandscapeArgKey, "false") + v, err = r.BoolArg(LandscapeArgKey, 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. + r.WithArg(LandscapeArgKey, "foo") + v, err = r.BoolArg(LandscapeArgKey, defaultValue) + expected = defaultValue + assert.Equal(t, expected, v) + test.AssertError(t, err) + // finally... + err = r.Close() + assert.Nil(t, err) +} + +func TestFpath(t *testing.T) { + const resourceDirectoryName string = "foo" + logger := test.DebugLogger() + r, err := New(logger, resourceDirectoryName) + assert.Nil(t, err) + // file exists. + fpath := test.MergeFpaths(t)[0] + f, err := os.Open(fpath) + assert.Nil(t, err) + filename := "foo.pdf" + err = r.WithFile(filename, f) + assert.Nil(t, err) + absDirPath, err := filepath.Abs(fmt.Sprintf("%s/%s", TemporaryDirectory, resourceDirectoryName)) + assert.Nil(t, err) + expected := fmt.Sprintf("%s/%s", absDirPath, filename) + v, err := r.Fpath(filename) + assert.Nil(t, err) + assert.Equal(t, expected, v) + // should not be OK as file does + // not exist. + _, err = r.Fpath("bar.pdf") + test.AssertError(t, err) + // finally... + err = r.Close() + assert.Nil(t, err) +} + +func TestFpaths(t *testing.T) { + const resourceDirectoryName string = "foo" + logger := test.DebugLogger() + r, err := New(logger, resourceDirectoryName) + assert.Nil(t, err) + fpath := test.MergeFpaths(t)[0] + f, err := os.Open(fpath) + assert.Nil(t, err) + defer f.Close() // nolint: errcheck + filename := "foo.pdf" + err = r.WithFile(filename, f) + assert.Nil(t, err) + // file extension exists. + absDirPath, err := filepath.Abs(fmt.Sprintf("%s/%s", TemporaryDirectory, resourceDirectoryName)) + assert.Nil(t, err) + expected := []string{ + fmt.Sprintf("%s/%s", absDirPath, filename), + } + fpaths, err := r.Fpaths(".pdf") + assert.Nil(t, err) + assert.Equal(t, expected, fpaths) + // should not be OK as file extension + // does not exist. + _, err = r.Fpaths(".html") + test.AssertError(t, err) + // finally... + err = r.Close() + assert.Nil(t, err) +} + +func TestFcontent(t *testing.T) { + const ( + resourceDirectoryName string = "foo" + defaultValue string = "Gutenberg" + ) + logger := test.DebugLogger() + r, err := New(logger, resourceDirectoryName) + assert.Nil(t, err) + filename := "foo.txt" + // file does not exist, expecting + // value. + v, err := r.Fcontent(filename, defaultValue) + assert.Nil(t, err) + assert.Equal(t, defaultValue, v) + // file exists. + fpath := test.OfficeFpaths(t)[2] + f, err := os.Open(fpath) + assert.Nil(t, err) + defer f.Close() // nolint: errcheck + err = r.WithFile(filename, f) + assert.Nil(t, err) + v, err = r.Fcontent(filename, defaultValue) + assert.Contains(t, v, defaultValue) + // finally... + err = r.Close() + assert.Nil(t, err) +} + +func TestGetters(t *testing.T) { + const resourceDirectoryName string = "foo" + logger := test.DebugLogger() + r, err := New(logger, resourceDirectoryName) + assert.Nil(t, err) + // has arg. + assert.Equal(t, false, r.HasArg(LandscapeArgKey)) + r.WithArg(LandscapeArgKey, "foo") + assert.Equal(t, true, r.HasArg(LandscapeArgKey)) + // directory path. + absDirPath, err := filepath.Abs(fmt.Sprintf("%s/%s", TemporaryDirectory, resourceDirectoryName)) + assert.Nil(t, err) + assert.Equal(t, absDirPath, r.DirPath()) + // finally... + err = r.Close() + assert.Nil(t, err) +} diff --git a/internal/app/xhttp/xhttp.go b/internal/app/xhttp/xhttp.go new file mode 100644 index 00000000..7324997e --- /dev/null +++ b/internal/app/xhttp/xhttp.go @@ -0,0 +1,32 @@ +package xhttp + +import ( + "github.com/labstack/echo/v4" + "github.com/thecodingmachine/gotenberg/internal/pkg/conf" +) + +// New returns a custom echo.Echo. +func New(config conf.Config) *echo.Echo { + srv := echo.New() + srv.HideBanner = true + srv.HidePort = true + srv.Use(contextMiddleware(config)) + 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/app/xhttp/xhttp_test.go b/internal/app/xhttp/xhttp_test.go new file mode 100644 index 00000000..685fb0a1 --- /dev/null +++ b/internal/app/xhttp/xhttp_test.go @@ -0,0 +1,126 @@ +package xhttp + +import ( + "fmt" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/assert" + "github.com/thecodingmachine/gotenberg/internal/pkg/conf" + "github.com/thecodingmachine/gotenberg/test" +) + +func TestDisableChromeEndpoints(t *testing.T) { + os.Setenv(conf.DisableGoogleChromeEnvVar, "1") + config, err := conf.FromEnv() + assert.Nil(t, err) + srv := New(config) + // Ping endpoint should return 200. + req := httptest.NewRequest(http.MethodGet, pingEndpoint, nil) + test.AssertStatusCode(t, http.StatusOK, srv, req) + // Merge endpoint should return 200. + body, contentType := test.MergeMultipartForm(t, nil) + req = httptest.NewRequest(http.MethodPost, mergeEndpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusOK, srv, req) + // HTML endpoint should return 404. + body, contentType = test.HTMLMultipartForm(t, nil) + req = httptest.NewRequest(http.MethodPost, fmt.Sprintf("%s%s", convertGroupEndpoint, htmlEndpoint), body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusNotFound, srv, req) + // URL endpoint should return 404. + body, contentType = test.URLMultipartForm(t, nil) + req = httptest.NewRequest(http.MethodPost, fmt.Sprintf("%s%s", convertGroupEndpoint, urlEndpoint), body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusNotFound, srv, req) + // Markdown endpoint should return 404. + body, contentType = test.MarkdownMultipartForm(t, nil) + req = httptest.NewRequest(http.MethodPost, fmt.Sprintf("%s%s", convertGroupEndpoint, markdownEndpoint), body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusNotFound, srv, req) + // Office endpoint should return 200. + body, contentType = test.OfficeMultipartForm(t, nil) + req = httptest.NewRequest(http.MethodPost, fmt.Sprintf("%s%s", convertGroupEndpoint, officeEndpoint), body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusOK, srv, req) + // finally... + os.Setenv(conf.DisableGoogleChromeEnvVar, "0") +} + +func TestDisableUnoconvEndpoints(t *testing.T) { + os.Setenv(conf.DisableUnoconvEnvVar, "1") + config, err := conf.FromEnv() + assert.Nil(t, err) + srv := New(config) + // Ping endpoint should return 200. + req := httptest.NewRequest(http.MethodGet, pingEndpoint, nil) + test.AssertStatusCode(t, http.StatusOK, srv, req) + // Merge endpoint should return 200. + body, contentType := test.MergeMultipartForm(t, nil) + req = httptest.NewRequest(http.MethodPost, mergeEndpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusOK, srv, req) + // HTML endpoint should return 200. + body, contentType = test.HTMLMultipartForm(t, nil) + req = httptest.NewRequest(http.MethodPost, fmt.Sprintf("%s%s", convertGroupEndpoint, htmlEndpoint), body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusOK, srv, req) + // URL endpoint should return 200. + body, contentType = test.URLMultipartForm(t, nil) + req = httptest.NewRequest(http.MethodPost, fmt.Sprintf("%s%s", convertGroupEndpoint, urlEndpoint), body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusOK, srv, req) + // Markdown endpoint should return 404. + body, contentType = test.MarkdownMultipartForm(t, nil) + req = httptest.NewRequest(http.MethodPost, fmt.Sprintf("%s%s", convertGroupEndpoint, markdownEndpoint), body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusOK, srv, req) + // Office endpoint should return 404. + body, contentType = test.OfficeMultipartForm(t, nil) + req = httptest.NewRequest(http.MethodPost, fmt.Sprintf("%s%s", convertGroupEndpoint, officeEndpoint), body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusNotFound, srv, req) + // finally... + os.Setenv(conf.DisableUnoconvEnvVar, "0") +} +func TestDisableChromeAndUnoconvEndpoints(t *testing.T) { + os.Setenv(conf.DisableGoogleChromeEnvVar, "1") + os.Setenv(conf.DisableUnoconvEnvVar, "1") + config, err := conf.FromEnv() + assert.Nil(t, err) + srv := New(config) + // Ping endpoint should return 200. + req := httptest.NewRequest(http.MethodGet, pingEndpoint, nil) + test.AssertStatusCode(t, http.StatusOK, srv, req) + // Merge endpoint should return 200. + body, contentType := test.MergeMultipartForm(t, nil) + req = httptest.NewRequest(http.MethodPost, mergeEndpoint, body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusOK, srv, req) + // HTML endpoint should return 404. + body, contentType = test.HTMLMultipartForm(t, nil) + req = httptest.NewRequest(http.MethodPost, fmt.Sprintf("%s%s", convertGroupEndpoint, htmlEndpoint), body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusNotFound, srv, req) + // URL endpoint should return 404. + body, contentType = test.URLMultipartForm(t, nil) + req = httptest.NewRequest(http.MethodPost, fmt.Sprintf("%s%s", convertGroupEndpoint, urlEndpoint), body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusNotFound, srv, req) + // Markdown endpoint should return 404. + body, contentType = test.MarkdownMultipartForm(t, nil) + req = httptest.NewRequest(http.MethodPost, fmt.Sprintf("%s%s", convertGroupEndpoint, markdownEndpoint), body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusNotFound, srv, req) + // Office endpoint should return 404. + body, contentType = test.OfficeMultipartForm(t, nil) + req = httptest.NewRequest(http.MethodPost, fmt.Sprintf("%s%s", convertGroupEndpoint, officeEndpoint), body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusNotFound, srv, req) + // finally... + os.Setenv(conf.DisableGoogleChromeEnvVar, "0") + os.Setenv(conf.DisableUnoconvEnvVar, "0") +} diff --git a/internal/pkg/chrome/chrome.go b/internal/pkg/chrome/chrome.go new file mode 100644 index 00000000..f70b2591 --- /dev/null +++ b/internal/pkg/chrome/chrome.go @@ -0,0 +1,170 @@ +package chrome + +import ( + "context" + "os" + "os/exec" + "strings" + "syscall" + "time" + + "github.com/mafredri/cdp/devtool" + "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/xtime" +) + +// Start starts Google Chrome headless in background. +func Start(logger xlog.Logger) error { + const op string = "chrome.Start" + logger.DebugOp(op, "starting new Google Chrome headless process on port 9222...") + resolver := func() error { + cmd, err := cmd(logger) + if err != nil { + return err + } + // we try to start the process. + xexec.LogBeforeExecute(logger, cmd) + if err := cmd.Start(); err != nil { + return err + } + // if the process failed to start correctly, + // we have to restart it. + if !isViable(logger) { + return restart(logger, cmd.Process) + } + return nil + } + if err := resolver(); err != nil { + return xerror.New(op, err) + } + return nil +} + +func cmd(logger xlog.Logger) (*exec.Cmd, error) { + const op string = "chrome.cmd" + binary := "google-chrome-stable" + args := []string{ + "--no-sandbox", + "--headless", + // see https://github.com/GoogleChrome/puppeteer/issues/2410. + "--font-render-hinting=medium", + "--remote-debugging-port=9222", + "--disable-gpu", + "--disable-translate", + "--disable-extensions", + "--disable-background-networking", + "--safebrowsing-disable-auto-update", + "--disable-sync", + "--disable-default-apps", + "--hide-scrollbars", + "--metrics-recording-only", + "--mute-audio", + "--no-first-run", + } + cmd, err := xexec.Command(logger, binary, args...) + if err != nil { + return nil, xerror.New(op, err) + } + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + return cmd, nil +} + +func kill(logger xlog.Logger, proc *os.Process) error { + const op string = "chrome.kill" + logger.DebugOp(op, "killing Google Chrome headless process using port 9222...") + resolver := func() error { + err := syscall.Kill(-proc.Pid, syscall.SIGKILL) + if err == nil { + return nil + } + if strings.Contains(err.Error(), "no such process") { + return nil + } + return err + } + if err := resolver(); err != nil { + return xerror.New(op, err) + } + return nil +} + +func restart(logger xlog.Logger, proc *os.Process) error { + const op string = "chrome.restart" + logger.DebugOp(op, "restarting Google Chrome headless process using port 9222...") + resolver := func() error { + // kill the existing process first. + if err := kill(logger, proc); err != nil { + return err + } + cmd, err := cmd(logger) + if err != nil { + return err + } + // we try to restart the process. + xexec.LogBeforeExecute(logger, cmd) + if err := cmd.Start(); err != nil { + return err + } + // if the process failed to restart correctly, + // we have to restart it again. + if !isViable(logger) { + return restart(logger, cmd.Process) + } + return nil + } + if err := resolver(); err != nil { + return xerror.New(op, err) + } + return nil +} + +func isViable(logger xlog.Logger) bool { + const ( + op string = "chrome.isViable" + maxViabilityTests int = 20 + ) + viable := func() bool { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + endpoint := "http://localhost:9222" + logger.DebugfOp( + op, + "checking Google Chrome headless process viability via endpoint '%s/json/version'", + endpoint, + ) + v, err := devtool.New(endpoint).Version(ctx) + if err != nil { + logger.DebugfOp( + op, + "Google Chrome headless is not viable as endpoint returned '%v'", + err.Error(), + ) + return false + } + logger.DebugfOp( + op, + "Google Chrome headless is viable as endpoint returned '%v'", + v, + ) + return true + } + result := false + for i := 0; i < maxViabilityTests && !result; i++ { + warmup(logger) + result = viable() + } + return result +} + +func warmup(logger xlog.Logger) { + const op string = "chrome.warmup" + warmupTime := xtime.Duration(0.5) + logger.DebugfOp( + op, + "waiting '%v' for allowing Google Chrome to warmup", + warmupTime, + ) + time.Sleep(warmupTime) +} diff --git a/internal/pkg/chrome/doc.go b/internal/pkg/chrome/doc.go new file mode 100644 index 00000000..c5431149 --- /dev/null +++ b/internal/pkg/chrome/doc.go @@ -0,0 +1,3 @@ +// Package chrome helps starting +// Google Chrome headless in background. +package chrome diff --git a/internal/pkg/conf/conf.go b/internal/pkg/conf/conf.go new file mode 100644 index 00000000..d0aca24a --- /dev/null +++ b/internal/pkg/conf/conf.go @@ -0,0 +1,226 @@ +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 contains the name + // of the environment variable "MAXIMUM_WAIT_TIMEOUT". + MaximumWaitTimeoutEnvVar string = "MAXIMUM_WAIT_TIMEOUT" + // MaximumWaitDelayEnvVar contains the name + // of the environment variable "MAXIMUM_WAIT_DELAY". + MaximumWaitDelayEnvVar string = "MAXIMUM_WAIT_DELAY" + // MaximumWebhookURLTimeoutEnvVar contains the name + // of the environment variable "MAXIMUM_WEBHOOK_URL_TIMEOUT". + MaximumWebhookURLTimeoutEnvVar string = "MAXIMUM_WEBHOOK_URL_TIMEOUT" + // DefaultWaitTimeoutEnvVar contains the name + // of the environment variable "DEFAULT_WAIT_TIMEOUT". + DefaultWaitTimeoutEnvVar string = "DEFAULT_WAIT_TIMEOUT" + // DefaultWebhookURLTimeoutEnvVar contains the name + // of the environment variable "DEFAULT_WEBHOOK_URL_TIMEOUT". + DefaultWebhookURLTimeoutEnvVar string = "DEFAULT_WEBHOOK_URL_TIMEOUT" + // DefaultListenPortEnvVar contains the name + // of the environment variable "DEFAULT_LISTEN_PORT". + DefaultListenPortEnvVar string = "DEFAULT_LISTEN_PORT" + // DisableGoogleChromeEnvVar contains the name + // of the environment variable "DISABLE_GOOGLE_CHROME". + DisableGoogleChromeEnvVar string = "DISABLE_GOOGLE_CHROME" + // DisableUnoconvEnvVar contains the name + // of the environment variable "DISABLE_UNOCONV". + DisableUnoconvEnvVar string = "DISABLE_UNOCONV" + // LogLevelEnvVar contains the name + // of the environment variable "LOG_LEVEL". + 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 +} + +// DefaultConfig returns the default +// configuration. +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..262aa079 --- /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" +) + +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() + test.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() + test.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() + test.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() + test.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() + test.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() + test.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() + test.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() + test.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() + test.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() + test.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() + test.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() + test.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() + test.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() + test.AssertError(t, err) + assert.Equal(t, expected, result) + os.Unsetenv(DefaultListenPortEnvVar) + // DEFAULT_LISTEN_PORT > 65535. + os.Setenv(DefaultListenPortEnvVar, "65536") + expected = DefaultConfig() + result, err = FromEnv() + test.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() + test.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() + test.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() + test.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/normalize/doc.go b/internal/pkg/normalize/doc.go new file mode 100644 index 00000000..f5e6f4ab --- /dev/null +++ b/internal/pkg/normalize/doc.go @@ -0,0 +1,9 @@ +/* +Package normalize helps removing special +characters from a string. + +Fixes: https://github.com/thecodingmachine/gotenberg/issues/104 + +Credits: https://medium.com/@swdream/golang-remove-all-accents-in-string-319abf6a7f5b +*/ +package normalize diff --git a/internal/pkg/normalize/normalize.go b/internal/pkg/normalize/normalize.go new file mode 100644 index 00000000..5b58c55b --- /dev/null +++ b/internal/pkg/normalize/normalize.go @@ -0,0 +1,21 @@ +package normalize + +import ( + "unicode" + + "github.com/thecodingmachine/gotenberg/internal/pkg/xerror" + "golang.org/x/text/runes" + "golang.org/x/text/transform" + "golang.org/x/text/unicode/norm" +) + +// String normalizes given string. +func String(str string) (string, error) { + const op string = "normalize.String" + t := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC) + result, _, err := transform.String(t, str) + if err != nil { + return "", xerror.New(op, err) + } + return result, nil +} diff --git a/internal/pkg/normalize/normalize_test.go b/internal/pkg/normalize/normalize_test.go new file mode 100644 index 00000000..087ad509 --- /dev/null +++ b/internal/pkg/normalize/normalize_test.go @@ -0,0 +1,17 @@ +package normalize + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestString(t *testing.T) { + const ( + toNormalize string = "analise da aplicação" + expected string = "analise da aplicacao" + ) + v, err := String(toNormalize) + assert.Nil(t, err) + assert.Equal(t, expected, v) +} diff --git a/internal/pkg/notify/doc.go b/internal/pkg/notify/doc.go deleted file mode 100644 index d60f45c6..00000000 --- a/internal/pkg/notify/doc.go +++ /dev/null @@ -1,5 +0,0 @@ -/* -Package notify helps displaying nice outputs -to the user. -*/ -package notify diff --git a/internal/pkg/notify/notify.go b/internal/pkg/notify/notify.go deleted file mode 100644 index 6690e2cc..00000000 --- a/internal/pkg/notify/notify.go +++ /dev/null @@ -1,35 +0,0 @@ -package notify - -import ( - "fmt" - "os" - - "github.com/labstack/gommon/color" -) - -// Print prints a message to stdout. -func Print(message string) { - stdout := color.New() - stdout.SetOutput(os.Stdout) - stdout.Printf("⇨ %s\n", message) -} - -// Printf prints a formatted message to stdout. -func Printf(format string, a ...interface{}) { - message := fmt.Sprintf(format, a...) - Print(message) -} - -// WarnPrint prints a warning to stderr. -func WarnPrint(err error) { - stderr := color.New() - stderr.SetOutput(os.Stderr) - stderr.Printf("%s\n", color.Yellow(fmt.Sprintf("⇨ warn: %v", err))) -} - -// ErrPrint prints an error to stderr. -func ErrPrint(err error) { - stderr := color.New() - stderr.SetOutput(os.Stderr) - stderr.Printf("%s\n", color.Red(fmt.Sprintf("⇨ error: %v", err))) -} diff --git a/internal/pkg/pm2/chrome.go b/internal/pkg/pm2/chrome.go deleted file mode 100644 index dfcdc1f9..00000000 --- a/internal/pkg/pm2/chrome.go +++ /dev/null @@ -1,72 +0,0 @@ -package pm2 - -import ( - "context" - "time" - - "github.com/mafredri/cdp/devtool" -) - -type chrome struct { - manager *processManager -} - -// NewChrome retruns a Google Chrome -// headless process. -func NewChrome() Process { - return &chrome{ - manager: &processManager{}, - } -} - -func (p *chrome) Fullname() string { - return "Google Chrome headless" -} - -func (p *chrome) Start() error { - return p.manager.start(p) -} - -func (p *chrome) Shutdown() error { - return p.manager.shutdown(p) -} - -func (p *chrome) args() []string { - return []string{ - "--no-sandbox", - "--headless", - "--remote-debugging-port=9222", - "--disable-gpu", - "--disable-translate", - "--disable-extensions", - "--disable-background-networking", - "--safebrowsing-disable-auto-update", - "--disable-sync", - "--disable-default-apps", - "--hide-scrollbars", - "--metrics-recording-only", - "--mute-audio", - "--no-first-run", - } -} - -func (p *chrome) name() string { - return "google-chrome-stable" -} - -func (p *chrome) viable() bool { - // check if Google Chrome is correctly running. - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - _, err := devtool.New("http://localhost:9222").Version(ctx) - return err == nil -} - -func (p *chrome) warmup() { - time.Sleep(5 * time.Second) -} - -// Compile-time checks to ensure type implements desired interfaces. -var ( - _ = Process(new(chrome)) -) diff --git a/internal/pkg/pm2/chrome_test.go b/internal/pkg/pm2/chrome_test.go deleted file mode 100644 index fa30c443..00000000 --- a/internal/pkg/pm2/chrome_test.go +++ /dev/null @@ -1,19 +0,0 @@ -package pm2 - -import ( - "testing" - - "github.com/stretchr/testify/require" -) - -func TestChromeStart(t *testing.T) { - p := NewChrome() - err := p.Start() - require.Nil(t, err) -} - -func TestChromeShutdown(t *testing.T) { - p := NewChrome() - err := p.Shutdown() - require.Nil(t, err) -} diff --git a/internal/pkg/pm2/doc.go b/internal/pkg/pm2/doc.go deleted file mode 100644 index 879a203c..00000000 --- a/internal/pkg/pm2/doc.go +++ /dev/null @@ -1,12 +0,0 @@ -/* -Package pm2 facilitates starting external -processes on which our API depends. - -For instance, it may start Google Chrome headless and -unoconv listener with PM2. - -The PM2 process manager launch those processes and keep -them running in the background. If for some reason they -crash, it will also restart them. -*/ -package pm2 diff --git a/internal/pkg/pm2/pm2.go b/internal/pkg/pm2/pm2.go deleted file mode 100644 index c4cf937a..00000000 --- a/internal/pkg/pm2/pm2.go +++ /dev/null @@ -1,83 +0,0 @@ -package pm2 - -import ( - "fmt" - "os/exec" -) - -const ( - stoppedState = iota - runningState - errorState -) - -// Process is a type that can start or -// shutdown a process with PM2. -type Process interface { - Fullname() string - Start() error - Shutdown() error - args() []string - name() string - viable() bool - warmup() -} - -type processManager struct { - heuristicState int32 -} - -func (m *processManager) start(p Process) error { - if err := m.pm2(p, "start"); err != nil { - return 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 err - } - p.warmup() - attempts++ - } - if !p.viable() { - m.heuristicState = errorState - return fmt.Errorf("failed to launch %s", p.Fullname()) - } - } - m.heuristicState = runningState - return nil -} - -func (m *processManager) shutdown(p Process) error { - if m.heuristicState != runningState { - return nil - } - if err := m.pm2(p, "stop"); err != nil { - m.heuristicState = errorState - return err - } - m.heuristicState = stoppedState - return nil -} - -func (m *processManager) pm2(p Process, cmdName string) error { - cmdArgs := []string{ - cmdName, - p.name(), - } - if cmdName == "start" { - cmdArgs = append(cmdArgs, "--interpreter=none", "--") - cmdArgs = append(cmdArgs, p.args()...) - } - cmd := exec.Command( - "pm2", - cmdArgs..., - ) - if err := cmd.Start(); err != nil { - return fmt.Errorf("%s %s with PM2: %v", cmdName, p.Fullname(), err) - } - return nil -} diff --git a/internal/pkg/pm2/unoconv.go b/internal/pkg/pm2/unoconv.go deleted file mode 100644 index f2881010..00000000 --- a/internal/pkg/pm2/unoconv.go +++ /dev/null @@ -1,52 +0,0 @@ -package pm2 - -type unoconv struct { - manager *processManager -} - -// NewUnoconv retruns a unoconv listener -// process. -func NewUnoconv() Process { - return &unoconv{ - manager: &processManager{}, - } -} - -func (p *unoconv) Fullname() string { - return "unoconv listener" -} - -func (p *unoconv) Start() error { - return p.manager.start(p) -} - -func (p *unoconv) Shutdown() error { - return p.manager.shutdown(p) -} - -func (p *unoconv) args() []string { - return []string{ - "--listener", - "--verbose", - } -} - -func (p *unoconv) name() string { - return "unoconv" -} - -func (p *unoconv) viable() bool { - // TODO find a way to check if - // the unoconv listener - // is correctly started? - return true -} - -func (p *unoconv) warmup() { - // let's do nothing. -} - -// Compile-time checks to ensure type implements desired interfaces. -var ( - _ = Process(new(unoconv)) -) diff --git a/internal/pkg/pm2/unoconv_test.go b/internal/pkg/pm2/unoconv_test.go deleted file mode 100644 index 8c74b244..00000000 --- a/internal/pkg/pm2/unoconv_test.go +++ /dev/null @@ -1,19 +0,0 @@ -package pm2 - -import ( - "testing" - - "github.com/stretchr/testify/require" -) - -func TestUnoconvStart(t *testing.T) { - p := NewUnoconv() - err := p.Start() - require.Nil(t, err) -} - -func TestUnoconvShutdown(t *testing.T) { - p := NewUnoconv() - err := p.Shutdown() - require.Nil(t, err) -} diff --git a/internal/pkg/printer/chrome.go b/internal/pkg/printer/chrome.go index 82f05569..aff21e75 100644 --- a/internal/pkg/printer/chrome.go +++ b/internal/pkg/printer/chrome.go @@ -12,17 +12,23 @@ import ( "github.com/mafredri/cdp/protocol/page" "github.com/mafredri/cdp/protocol/target" "github.com/mafredri/cdp/rpcc" + "github.com/thecodingmachine/gotenberg/internal/pkg/conf" + "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 -// Google Chrome printer behaviour. -type ChromeOptions struct { +// ChromePrinterOptions helps customizing the +// Google Chrome Printer behaviour. +type ChromePrinterOptions struct { WaitTimeout float64 WaitDelay float64 HeaderHTML string @@ -36,120 +42,267 @@ type ChromeOptions struct { Landscape bool } -func (p *chrome) Print(destination string) error { - duration := time.Duration(p.opts.WaitTimeout+p.opts.WaitDelay) * time.Second - ctx, cancel := context.WithTimeout(context.Background(), duration) +// DefaultChromePrinterOptions returns the default +// Google Chrome Printer options. +func DefaultChromePrinterOptions(config conf.Config) ChromePrinterOptions { + const defaultHeaderFooterHTML string = "" + return ChromePrinterOptions{ + WaitTimeout: config.DefaultWaitTimeout(), + WaitDelay: 0.0, + HeaderHTML: defaultHeaderFooterHTML, + FooterHTML: defaultHeaderFooterHTML, + PaperWidth: 8.27, + PaperHeight: 11.7, + MarginTop: 1.0, + MarginBottom: 1.0, + MarginLeft: 1.0, + MarginRight: 1.0, + Landscape: false, + } +} + +// nolint: gochecknoglobals +var lockChrome = make(chan struct{}, 1) + +const maxDevtConnections int = 5 + +// nolint: gochecknoglobals +var devtConnections int + +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() - devt, err := devtool.New("http://localhost:9222").Version(ctx) - if err != nil { - return err + resolver := func() error { + devt, err := devtool.New("http://localhost:9222").Version(ctx) + if err != nil { + return err + } + // connect to WebSocket URL (page) that speaks the Chrome DevTools Protocol. + devtConn, err := rpcc.DialContext(ctx, devt.WebSocketDebuggerURL) + if err != nil { + 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 err + } + /* + close the browser context when done. + we're not using the "default" context + as it may timeout before actually closing + the browser context. + see: https://github.com/mafredri/cdp/issues/101#issuecomment-524533670 + */ + disposeBrowserContextArgs := target.NewDisposeBrowserContextArgs(newContextTarget.BrowserContextID) + defer devtClient.Target.DisposeBrowserContext(context.Background(), disposeBrowserContextArgs) // nolint: errcheck + // create a new blank target with the new browser context. + createTargetArgs := target. + NewCreateTargetArgs("about:blank"). + SetBrowserContextID(newContextTarget.BrowserContextID) + newTarget, err := devtClient.Target.CreateTarget(ctx, createTargetArgs) + if err != nil { + return 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 err + } + defer newContextConn.Close() // nolint: errcheck + // create a new CDP Client that uses newContextConn. + targetClient := cdp.NewClient(newContextConn) + /* + close the target when done. + we're not using the "default" context + as it may timeout before actually closing + the target. + see: https://github.com/mafredri/cdp/issues/101#issuecomment-524533670 + */ + closeTargetArgs := target.NewCloseTargetArgs(newTarget.TargetID) + defer targetClient.Target.CloseTarget(context.Background(), closeTargetArgs) // nolint: errcheck + // enable all events. + if err := p.enableEvents(ctx, targetClient); err != nil { + return err + } + // listen for all events. + if err := p.listenEvents(ctx, targetClient); err != nil { + return err + } + // apply a wait delay (if any). + if p.opts.WaitDelay > 0.0 { + // wait for a given amount of time (useful for javascript delay). + p.logger.DebugfOp(op, "applying a wait delay of '%.2fs'...", p.opts.WaitDelay) + time.Sleep(xtime.Duration(p.opts.WaitDelay)) + } else { + p.logger.DebugOp(op, "no wait delay to apply, moving on...") + } + // print the page to PDF. + print, err := targetClient.Page.PrintToPDF( + ctx, + page.NewPrintToPDFArgs(). + SetPaperWidth(p.opts.PaperWidth). + SetPaperHeight(p.opts.PaperHeight). + SetMarginTop(p.opts.MarginTop). + SetMarginBottom(p.opts.MarginBottom). + SetMarginLeft(p.opts.MarginLeft). + SetMarginRight(p.opts.MarginRight). + SetLandscape(p.opts.Landscape). + SetDisplayHeaderFooter(true). + SetHeaderTemplate(p.opts.HeaderHTML). + SetFooterTemplate(p.opts.FooterHTML). + SetPrintBackground(true), + ) + if err != nil { + return err + } + if err := ioutil.WriteFile(destination, print.Data, 0644); err != nil { + return err + } + return nil } - // connect to WebSocket URL (page) that speaks the Chrome DevTools Protocol. - devtConn, err := rpcc.DialContext(ctx, devt.WebSocketDebuggerURL) - if err != nil { - return err + if devtConnections < maxDevtConnections { + p.logger.DebugOp(op, "skipping lock acquisition...") + devtConnections++ + err := resolver() + devtConnections-- + if err != nil { + return xcontext.MustHandleError( + ctx, + xerror.New(op, err), + ) + } + return nil } - defer devtConn.Close() // nolint: errcheck - // create a new CDP Client that uses conn. - devtClient := cdp.NewClient(devtConn) - newContextTarget, err := devtClient.Target.CreateBrowserContext(ctx) - if err != nil { - return fmt.Errorf("creating new browser context: %v", err) + p.logger.DebugOp(op, "waiting lock to be acquired...") + select { + case lockChrome <- struct{}{}: + // lock acquired. + p.logger.DebugOp(op, "lock acquired") + devtConnections++ + err := resolver() + devtConnections-- + <-lockChrome // we release the lock. + if err != nil { + return xcontext.MustHandleError( + ctx, + xerror.New(op, err), + ) + } + return nil + case <-ctx.Done(): + // failed to acquire lock before + // deadline. + p.logger.DebugOp(op, "failed to acquire lock before context.Context deadline") + return xcontext.MustHandleError( + ctx, + ctx.Err(), + ) } - // create a new blank target with the new browser context. - createTargetArgs := target. - NewCreateTargetArgs("about:blank"). - SetBrowserContextID(newContextTarget.BrowserContextID) - newTarget, err := devtClient.Target.CreateTarget(ctx, createTargetArgs) - if err != nil { - return fmt.Errorf("creating new blank target: %v", err) - } - // connect the client to the new target. - newTargetWsURL := fmt.Sprintf("ws://127.0.0.1:9222/devtools/page/%s", newTarget.TargetID) - newContextConn, err := rpcc.DialContext(ctx, newTargetWsURL) - if err != nil { - return fmt.Errorf("connecting client to blank target: %v", err) - } - defer newContextConn.Close() // nolint: errcheck - // create a new CDP Client that uses newContextConn. - targetClient := cdp.NewClient(newContextConn) - closeTargetArgs := target.NewCloseTargetArgs(newTarget.TargetID) - // close the target when done. - defer targetClient.Target.CloseTarget(ctx, closeTargetArgs) // nolint: errcheck +} + +func (p chromePrinter) enableEvents(ctx context.Context, client *cdp.Client) error { + const op string = "printer.chromePrinter.enableEvents" + // enable all the domain events that we're interested in. if err := runBatch( - // enable all the domain events that we're interested in. - func() error { return targetClient.DOM.Enable(ctx) }, - func() error { return targetClient.Network.Enable(ctx, network.NewEnableArgs()) }, - func() error { return targetClient.Page.Enable(ctx) }, - func() error { return targetClient.Runtime.Enable(ctx) }, + func() error { return client.DOM.Enable(ctx) }, + func() error { return client.Network.Enable(ctx, network.NewEnableArgs()) }, + func() error { return client.Page.Enable(ctx) }, + func() error { + return client.Page.SetLifecycleEventsEnabled(ctx, page.NewSetLifecycleEventsEnabledArgs(true)) + }, + func() error { return client.Runtime.Enable(ctx) }, ); err != nil { - return err - } - if err := p.navigate(ctx, targetClient); err != nil { - return err - } - print, err := targetClient.Page.PrintToPDF( - ctx, - page.NewPrintToPDFArgs(). - SetPaperWidth(p.opts.PaperWidth). - SetPaperHeight(p.opts.PaperHeight). - SetMarginTop(p.opts.MarginTop). - SetMarginBottom(p.opts.MarginBottom). - SetMarginLeft(p.opts.MarginLeft). - SetMarginRight(p.opts.MarginRight). - SetLandscape(p.opts.Landscape). - SetDisplayHeaderFooter(true). - SetHeaderTemplate(p.opts.HeaderHTML). - SetFooterTemplate(p.opts.FooterHTML). - SetPrintBackground(true), - ) - if err != nil { - return fmt.Errorf("printing page to PDF: %v", err) - } - if err := ioutil.WriteFile(destination, print.Data, 0644); err != nil { - return fmt.Errorf("%s: writing file: %v", destination, err) + return xerror.New(op, err) } return nil } -func (p *chrome) navigate(ctx context.Context, client *cdp.Client) 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( +func (p chromePrinter) listenEvents(ctx context.Context, client *cdp.Client) error { + const op string = "printer.chromePrinter.listenEvents" + 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 + lifecycleEvent, err := client.Page.LifecycleEvent(ctx) + if err != nil { + return err + } + defer lifecycleEvent.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 + } // 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 + return runBatch( + func() error { + _, err := domContentEventFired.Recv() + if err != nil { + return err + } + p.logger.DebugOp(op, "event 'domContentEventFired' received") + return nil + }, + func() error { + _, err := loadEventFired.Recv() + if err != nil { + return err + } + p.logger.DebugOp(op, "event 'loadEventFired' received") + return nil + }, + func() error { + const networkIdleEventName string = "networkIdle" + for { + ev, err := lifecycleEvent.Recv() + if err != nil { + return err + } + p.logger.DebugfOp(op, "event '%s' received", ev.Name) + if ev.Name == networkIdleEventName { + break + } + } + return nil + }, + func() error { + _, err := loadingFinished.Recv() + if err != nil { + return err + } + p.logger.DebugOp(op, "event 'loadingFinished' received") + return nil + }, + ) + } + if err := resolver(); err != nil { + return xerror.New(op, err) } - // wait for a given amount of time (useful for javascript delay). - time.Sleep(time.Duration(p.opts.WaitDelay) * time.Second) return nil } @@ -165,5 +318,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/html_test.go b/internal/pkg/printer/html_test.go new file mode 100644 index 00000000..4a233163 --- /dev/null +++ b/internal/pkg/printer/html_test.go @@ -0,0 +1,52 @@ +package printer + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/thecodingmachine/gotenberg/internal/pkg/conf" + "github.com/thecodingmachine/gotenberg/internal/pkg/xerror" + "github.com/thecodingmachine/gotenberg/internal/pkg/xlog" + "github.com/thecodingmachine/gotenberg/test" +) + +func TestHTMLPrinter(t *testing.T) { + var ( + logger xlog.Logger = test.DebugLogger() + config conf.Config = conf.DefaultConfig() + fpath string = test.HTMLFpaths(t)[0] + opts ChromePrinterOptions + dest string + p Printer + err error + ) + // default options. + opts = DefaultChromePrinterOptions(config) + p = NewHTMLPrinter(logger, fpath, opts) + dest = test.GenerateDestination() + err = p.Print(dest) + assert.Nil(t, err) + err = os.RemoveAll(dest) + assert.Nil(t, err) + // options with a wait delay. + opts = DefaultChromePrinterOptions(config) + opts.WaitDelay = 0.5 + p = NewHTMLPrinter(logger, fpath, opts) + dest = test.GenerateDestination() + err = p.Print(dest) + assert.Nil(t, err) + err = os.RemoveAll(dest) + assert.Nil(t, err) + // should not be OK as context.Context + // should timeout. + opts = DefaultChromePrinterOptions(config) + opts.WaitTimeout = 0.0 + p = NewHTMLPrinter(logger, fpath, opts) + dest = test.GenerateDestination() + err = p.Print(dest) + test.AssertError(t, err) + assert.Equal(t, xerror.TimeoutCode, xerror.Code(err)) + err = os.RemoveAll(dest) + assert.Nil(t, err) +} diff --git a/internal/pkg/printer/markdown.go b/internal/pkg/printer/markdown.go index 0bbaa518..3ac9956c 100644 --- a/internal/pkg/printer/markdown.go +++ b/internal/pkg/printer/markdown.go @@ -9,36 +9,46 @@ import ( "github.com/microcosm-cc/bluemonday" "github.com/russross/blackfriday/v2" - "github.com/thecodingmachine/gotenberg/internal/pkg/rand" + "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) { - 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} + logger.DebugOp(op, "converting Markdown files to HTML...") + var buffer bytes.Buffer + if err := tmpl.Execute(&buffer, data); err != nil { + return "", err + } + baseFilename := xrand.Get() + dst := fmt.Sprintf("%s/%s.html", dirPath, baseFilename) + logger.DebugOp(op, "writing the HTML from previous conversion(s) into new file...") + 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, fmt.Errorf("%s: parsing template: %v", fpath, 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, fmt.Errorf("%s: executing template: %v", fpath, err) - } - baseFilename, err := rand.Get() - if err != nil { - return nil, err - } - dst := fmt.Sprintf("%s/%s.html", dirPath, baseFilename) - if err := ioutil.WriteFile(dst, buffer.Bytes(), 0644); err != nil { - return nil, fmt.Errorf("%s: writing file: %v", dst, err) - } - URL := fmt.Sprintf("file://%s", dst) - return &chrome{ - url: URL, - opts: opts, + return chromePrinter{ + logger: logger, + url: URL, + opts: opts, }, nil } @@ -47,10 +57,11 @@ type templateData struct { } func markdownToHTML(dirPath, filename string) (template.HTML, error) { + const op string = "printer.markdownToHTML" fpath := fmt.Sprintf("%s/%s", dirPath, filename) b, err := ioutil.ReadFile(fpath) if err != nil { - return "", fmt.Errorf("%s: reading file: %v", fpath, err) + return "", xerror.New(op, err) } unsafe := blackfriday.Run(b) content := bluemonday.UGCPolicy().SanitizeBytes(unsafe) diff --git a/internal/pkg/printer/markdown_test.go b/internal/pkg/printer/markdown_test.go new file mode 100644 index 00000000..c5115a3a --- /dev/null +++ b/internal/pkg/printer/markdown_test.go @@ -0,0 +1,55 @@ +package printer + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/thecodingmachine/gotenberg/internal/pkg/conf" + "github.com/thecodingmachine/gotenberg/internal/pkg/xerror" + "github.com/thecodingmachine/gotenberg/internal/pkg/xlog" + "github.com/thecodingmachine/gotenberg/test" +) + +func TestMarkdownPrinter(t *testing.T) { + var ( + logger xlog.Logger = test.DebugLogger() + config conf.Config = conf.DefaultConfig() + fpath string = test.MarkdownFpaths(t)[0] + opts ChromePrinterOptions + dest string + p Printer + err error + ) + // default options. + opts = DefaultChromePrinterOptions(config) + p, err = NewMarkdownPrinter(logger, fpath, opts) + assert.Nil(t, err) + dest = test.GenerateDestination() + err = p.Print(dest) + assert.Nil(t, err) + err = os.RemoveAll(dest) + assert.Nil(t, err) + // options with a wait delay. + opts = DefaultChromePrinterOptions(config) + opts.WaitDelay = 0.5 + p, err = NewMarkdownPrinter(logger, fpath, opts) + assert.Nil(t, err) + dest = test.GenerateDestination() + err = p.Print(dest) + assert.Nil(t, err) + err = os.RemoveAll(dest) + assert.Nil(t, err) + // should not be OK as context.Context + // should timeout. + opts = DefaultChromePrinterOptions(config) + opts.WaitTimeout = 0.0 + p, err = NewMarkdownPrinter(logger, fpath, opts) + assert.Nil(t, err) + dest = test.GenerateDestination() + err = p.Print(dest) + test.AssertError(t, err) + assert.Equal(t, xerror.TimeoutCode, xerror.Code(err)) + err = os.RemoveAll(dest) + assert.Nil(t, err) +} diff --git a/internal/pkg/printer/merge.go b/internal/pkg/printer/merge.go index d21c2420..b8d4fa5b 100644 --- a/internal/pkg/printer/merge.go +++ b/internal/pkg/printer/merge.go @@ -2,49 +2,75 @@ package printer import ( "context" - "fmt" - "os/exec" - "time" + + "github.com/thecodingmachine/gotenberg/internal/pkg/conf" + "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{ +// DefaultMergePrinterOptions returns the default +// merge Printer options. +func DefaultMergePrinterOptions(config conf.Config) MergePrinterOptions { + return MergePrinterOptions{ + WaitTimeout: config.DefaultWaitTimeout(), + } +} + +// 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 { +func (p mergePrinter) Print(destination string) error { + const op string = "printer.mergePrinter.Print" + /* + context.Context may be providen from + an officePrinter which needs to merge + its result files. + */ if p.ctx == nil { - ctx, cancel := context.WithTimeout(context.Background(), time.Duration(p.opts.WaitTimeout)*time.Second) + logOptions(p.logger, p.opts) + ctx, cancel := xcontext.WithTimeout(p.logger, p.opts.WaitTimeout) defer cancel() p.ctx = ctx } - var cmdArgs []string - cmdArgs = append(cmdArgs, p.fpaths...) - cmdArgs = append(cmdArgs, "cat", "output", destination) - cmd := exec.CommandContext(p.ctx, "pdftk", cmdArgs...) - _, err := cmd.Output() - if err != nil { - return fmt.Errorf("pdtk: %v", err) + p.logger.DebugfOp(op, "merging '%v'...", p.fpaths) + resolver := func() error { + var args []string + args = append(args, p.fpaths...) + args = append(args, "cat", "output", destination) + return xexec.Run(p.ctx, p.logger, "pdftk", args...) + } + if err := resolver(); err != nil { + 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/merge_test.go b/internal/pkg/printer/merge_test.go new file mode 100644 index 00000000..71399a32 --- /dev/null +++ b/internal/pkg/printer/merge_test.go @@ -0,0 +1,43 @@ +package printer + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/thecodingmachine/gotenberg/internal/pkg/conf" + "github.com/thecodingmachine/gotenberg/internal/pkg/xerror" + "github.com/thecodingmachine/gotenberg/internal/pkg/xlog" + "github.com/thecodingmachine/gotenberg/test" +) + +func TestMergePrinter(t *testing.T) { + var ( + logger xlog.Logger = test.DebugLogger() + config conf.Config = conf.DefaultConfig() + fpaths []string = test.MergeFpaths(t) + opts MergePrinterOptions + dest string + p Printer + err error + ) + // default options. + opts = DefaultMergePrinterOptions(config) + p = NewMergePrinter(logger, fpaths, opts) + dest = test.GenerateDestination() + err = p.Print(dest) + assert.Nil(t, err) + err = os.RemoveAll(dest) + assert.Nil(t, err) + // should not be OK as context.Context + // should timeout. + opts = DefaultMergePrinterOptions(config) + opts.WaitTimeout = 0.0 + p = NewMergePrinter(logger, fpaths, opts) + dest = test.GenerateDestination() + err = p.Print(dest) + test.AssertError(t, err) + assert.Equal(t, xerror.TimeoutCode, xerror.Code(err)) + err = os.RemoveAll(dest) + assert.Nil(t, err) +} diff --git a/internal/pkg/printer/office.go b/internal/pkg/printer/office.go index 6a104ccf..8a21ff2e 100644 --- a/internal/pkg/printer/office.go +++ b/internal/pkg/printer/office.go @@ -4,87 +4,115 @@ import ( "context" "fmt" "os" - "os/exec" "path/filepath" - "sync" - "time" - "github.com/thecodingmachine/gotenberg/internal/pkg/rand" + "github.com/phayes/freeport" + "github.com/thecodingmachine/gotenberg/internal/pkg/conf" + "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{ +// DefaultOfficePrinterOptions returns the default +// Office Printer options. +func DefaultOfficePrinterOptions(config conf.Config) OfficePrinterOptions { + return OfficePrinterOptions{ + WaitTimeout: config.DefaultWaitTimeout(), + Landscape: false, + } +} + +// 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 { - ctx, cancel := context.WithTimeout(context.Background(), time.Duration(p.opts.WaitTimeout)*time.Second) +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)) - dirPath := filepath.Dir(destination) - for i, fpath := range p.fpaths { - baseFilename, err := rand.Get() + resolver := func() error { + fpaths := make([]string, len(p.fpaths)) + dirPath := filepath.Dir(destination) + for i, fpath := range p.fpaths { + baseFilename := xrand.Get() + tmpDest := fmt.Sprintf("%s/%d%s.pdf", dirPath, i, baseFilename) + 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 + } + if len(fpaths) == 1 { + p.logger.DebugOp(op, "only one PDF created, nothing to merge") + return os.Rename(fpaths[0], destination) + } + m := mergePrinter{ + logger: p.logger, + ctx: ctx, + fpaths: fpaths, + } + return m.Print(destination) + } + if err := resolver(); err != nil { + return xcontext.MustHandleError( + ctx, + xerror.New(op, err), + ) + } + return nil +} + +func unoconv(ctx context.Context, logger xlog.Logger, fpath, destination string, opts OfficePrinterOptions) error { + const op string = "printer.unoconv" + resolver := func() error { + port, err := freeport.GetFreePort() if err != nil { return err } - tmpDest := fmt.Sprintf("%s/%d%s.pdf", dirPath, i, baseFilename) - if err := unoconv(ctx, fpath, tmpDest, p.opts); err != nil { - return err + args := []string{ + "--user-profile", + fmt.Sprintf("///tmp/%d", port), + "--port", + fmt.Sprintf("%d", port), + "--format", + "pdf", } - fpaths[i] = tmpDest + if opts.Landscape { + args = append(args, "--printer", "PaperOrientation=landscape") + } + args = append(args, "--output", destination, fpath) + return xexec.Run(ctx, logger, "unoconv", args...) } - if len(fpaths) == 1 { - return os.Rename(fpaths[0], destination) - } - m := &merge{ - ctx: ctx, - fpaths: fpaths, - } - return m.Print(destination) -} - -// nolint: gochecknoglobals -var mu sync.Mutex - -func unoconv(ctx context.Context, fpath, destination string, opts *OfficeOptions) error { - mu.Lock() - defer mu.Unlock() - cmdArgs := []string{ - "--format", - "pdf", - } - 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 fmt.Errorf("unoconv: %v", 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/office_test.go b/internal/pkg/printer/office_test.go new file mode 100644 index 00000000..b6a0235b --- /dev/null +++ b/internal/pkg/printer/office_test.go @@ -0,0 +1,60 @@ +package printer + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/thecodingmachine/gotenberg/internal/pkg/conf" + "github.com/thecodingmachine/gotenberg/internal/pkg/xerror" + "github.com/thecodingmachine/gotenberg/internal/pkg/xlog" + "github.com/thecodingmachine/gotenberg/test" +) + +func TestOfficePrinter(t *testing.T) { + var ( + logger xlog.Logger = test.DebugLogger() + config conf.Config = conf.DefaultConfig() + fpaths []string = test.OfficeFpaths(t) + opts OfficePrinterOptions + dest string + p Printer + err error + ) + // default options. + opts = DefaultOfficePrinterOptions(config) + p = NewOfficePrinter(logger, fpaths, opts) + dest = test.GenerateDestination() + err = p.Print(dest) + assert.Nil(t, err) + err = os.RemoveAll(dest) + assert.Nil(t, err) + // using one file. + opts = DefaultOfficePrinterOptions(config) + p = NewOfficePrinter(logger, []string{fpaths[0]}, opts) + dest = test.GenerateDestination() + err = p.Print(dest) + assert.Nil(t, err) + err = os.RemoveAll(dest) + assert.Nil(t, err) + // options with landscape. + opts = DefaultOfficePrinterOptions(config) + opts.Landscape = true + p = NewOfficePrinter(logger, fpaths, opts) + dest = test.GenerateDestination() + err = p.Print(dest) + assert.Nil(t, err) + err = os.RemoveAll(dest) + assert.Nil(t, err) + // should not be OK as context.Context + // should timeout. + opts = DefaultOfficePrinterOptions(config) + opts.WaitTimeout = 0.0 + p = NewOfficePrinter(logger, fpaths, opts) + dest = test.GenerateDestination() + err = p.Print(dest) + test.AssertError(t, err) + assert.Equal(t, xerror.TimeoutCode, xerror.Code(err)) + err = os.RemoveAll(dest) + assert.Nil(t, err) +} 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/printer/url_test.go b/internal/pkg/printer/url_test.go new file mode 100644 index 00000000..2c0b99f7 --- /dev/null +++ b/internal/pkg/printer/url_test.go @@ -0,0 +1,52 @@ +package printer + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/thecodingmachine/gotenberg/internal/pkg/conf" + "github.com/thecodingmachine/gotenberg/internal/pkg/xerror" + "github.com/thecodingmachine/gotenberg/internal/pkg/xlog" + "github.com/thecodingmachine/gotenberg/test" +) + +func TestURLPrinter(t *testing.T) { + var ( + logger xlog.Logger = test.DebugLogger() + config conf.Config = conf.DefaultConfig() + URL = "https://google.com" + opts ChromePrinterOptions + dest string + p Printer + err error + ) + // default options. + opts = DefaultChromePrinterOptions(config) + p = NewURLPrinter(logger, URL, opts) + dest = test.GenerateDestination() + err = p.Print(dest) + assert.Nil(t, err) + err = os.RemoveAll(dest) + assert.Nil(t, err) + // options with a wait delay. + opts = DefaultChromePrinterOptions(config) + opts.WaitDelay = 0.5 + p = NewURLPrinter(logger, URL, opts) + dest = test.GenerateDestination() + err = p.Print(dest) + assert.Nil(t, err) + err = os.RemoveAll(dest) + assert.Nil(t, err) + // should not be OK as context.Context + // should timeout. + opts = DefaultChromePrinterOptions(config) + opts.WaitTimeout = 0.0 + p = NewURLPrinter(logger, URL, opts) + dest = test.GenerateDestination() + err = p.Print(dest) + test.AssertError(t, err) + assert.Equal(t, xerror.TimeoutCode, xerror.Code(err)) + err = os.RemoveAll(dest) + assert.Nil(t, err) +} diff --git a/internal/pkg/rand/doc.go b/internal/pkg/rand/doc.go deleted file mode 100644 index 35fa53e7..00000000 --- a/internal/pkg/rand/doc.go +++ /dev/null @@ -1,7 +0,0 @@ -/* -Package rand helps generating a random string. - -It should be used for creating directory and -file names in order to avoid collision. -*/ -package rand diff --git a/internal/pkg/rand/rand.go b/internal/pkg/rand/rand.go deleted file mode 100644 index 6291b515..00000000 --- a/internal/pkg/rand/rand.go +++ /dev/null @@ -1,17 +0,0 @@ -package rand - -import ( - "crypto/rand" - "encoding/hex" - "fmt" -) - -// Get returns a random string. -func Get() (string, error) { - randBytes := make([]byte, 16) - _, err := rand.Read(randBytes) - if err != nil { - return "", fmt.Errorf("creating random string: %v", err) - } - return hex.EncodeToString(randBytes), nil -} diff --git a/internal/pkg/rand/rand_test.go b/internal/pkg/rand/rand_test.go deleted file mode 100644 index c171539c..00000000 --- a/internal/pkg/rand/rand_test.go +++ /dev/null @@ -1,16 +0,0 @@ -package rand - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestGet(t *testing.T) { - rand1, err := Get() - require.Nil(t, err) - rand2, err := Get() - require.Nil(t, err) - assert.NotEqual(t, rand1, rand2) -} 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..78aba945 --- /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" +) + +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() + test.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() + test.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..2b5244dd --- /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" +) + +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() + test.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() + test.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..87ad8065 --- /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" +) + +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() + test.AssertError(t, err) +} diff --git a/internal/pkg/xassert/xassert.go b/internal/pkg/xassert/xassert.go new file mode 100644 index 00000000..8ad5a55a --- /dev/null +++ b/internal/pkg/xassert/xassert.go @@ -0,0 +1,234 @@ +package xassert + +import ( + "fmt" + "os" + "strconv" + + "github.com/dustin/go-humanize" + "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 +} + +/* +Bytes tries to convert a string to a int64. + +If string is empty or conversion fails, returns the +default value. + +The key is used to identify the value. +*/ +func Bytes(key, value string, defaultValue int64, rules ...RuleInt64) (int64, error) { + const op string = "xassert.Bytes" + result := defaultValue + if value != "" { + parsedValue, err := humanize.ParseBigBytes(value) + if err != nil { + return defaultValue, xerror.Invalid( + op, + fmt.Sprintf("'%s' is not a correct bytes representation, got '%s'", key, value), + err, + ) + } + result = parsedValue.Int64() + } + for _, rule := range rules { + rule.with(key, result) + if err := rule.validate(); err != nil { + return defaultValue, xerror.New(op, err) + } + } + return result, nil +} + +/* +BytesFromEnv returns the int64 representation of the +value of given environment variable. + +If not found, empty or conversion fails, returns the +default value. +*/ +func BytesFromEnv(envVar string, defaultValue int64, rules ...RuleInt64) (int64, error) { + const op string = "xassert.BytesFromEnv" + value := os.Getenv(envVar) + result, err := Bytes(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..32a5e2ac --- /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" +) + +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) + test.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) + test.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) + test.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) + test.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) + test.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) + test.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) + test.AssertError(t, err) + // should not be OK as given value does not + // validate the rule x >= 6. + v, err = Float64("foo", "5.0", defaultValue, rule) + expected = defaultValue + assert.Equal(t, expected, v) + test.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) + test.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.0") + v, err = Float64FromEnv(envVar, defaultValue, rule) + expected = defaultValue + assert.Equal(t, expected, v) + test.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) + test.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) + test.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..36a7e9d9 --- /dev/null +++ b/internal/pkg/xcontext/xcontext_test.go @@ -0,0 +1,42 @@ +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" +) + +func TestMustHandleError(t *testing.T) { + previousErr := errors.New("previous error") + logger := test.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 := test.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 = test.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..9db49c6f --- /dev/null +++ b/internal/pkg/xexec/doc.go @@ -0,0 +1,9 @@ +/* +Package xexec helps creating exec.Cmd +with logging and executing those commands +without leaking orphan processes. + +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..127f65c6 --- /dev/null +++ b/internal/pkg/xexec/xexec.go @@ -0,0 +1,155 @@ +package xexec + +import ( + "bufio" + "bytes" + "context" + "fmt" + "io" + "os/exec" + "strings" + "syscall" + + "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 +} + +/* +Run runs a command. + +If command finishes or fails to finish +before context.Context deadline, kill the +corresponding process in a way which does +not leak orphan processes. +*/ +func Run(ctx context.Context, logger xlog.Logger, binary string, args ...string) error { + const op string = "xexec.Run" + resolver := func() error { + cmd, err := Command( + logger, + binary, + args..., + ) + if err != nil { + return err + } + LogBeforeExecute(logger, cmd) + // see https://medium.com/@felixge/killing-a-child-process-and-all-of-its-children-in-go-54079af94773. + kill := func() { + err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + if err == nil { + return + } + if !strings.Contains(err.Error(), "no such process") { + logger.ErrorOp(op, err) + } + } + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + if err := cmd.Start(); err != nil { + return err + } + result := make(chan error, 1) + go func() { + result <- cmd.Wait() + }() + select { + case err := <-result: + logger.DebugfOp(op, "command '%s' finished", strings.Join(cmd.Args, " ")) + kill() + return err + case <-ctx.Done(): + logger.DebugfOp(op, "command '%s' failed to finish before context.Context deadline", strings.Join(cmd.Args, " ")) + kill() + return ctx.Err() + } + } + if err := resolver(); err != nil { + return xerror.New(op, err) + } + return 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 buf bytes.Buffer + buf.WriteString(outputType) + for _, arg := range cmd.Args { + buf.WriteString(fmt.Sprintf(".%s", arg)) + } + op := buf.String() + r := bufio.NewReader(reader) + defer reader.Close() // nolint: errcheck + for { + line, _, err := r.ReadLine() + if err != nil { + if err != io.EOF && !strings.Contains(err.Error(), "file already closed") { + 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..ceffc5c5 --- /dev/null +++ b/internal/pkg/xexec/xexec_test.go @@ -0,0 +1,53 @@ +package xexec + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/thecodingmachine/gotenberg/internal/pkg/xcontext" + "github.com/thecodingmachine/gotenberg/test" +) + +func TestCommand(t *testing.T) { + logger := test.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 = test.InfoLogger() + cmd, err = Command(logger, "echo", "Hello", "World") + LogBeforeExecute(logger, cmd) + assert.Nil(t, err) +} + +func TestCommandContext(t *testing.T) { + logger := test.DebugLogger() + // should pipe command output as + // xlog.Logger has a xlog.DebugLevel. + cmd, err := CommandContext(context.Background(), logger, "echo", "Hello", "World") + assert.Nil(t, err) + LogBeforeExecute(logger, cmd) + // should not pipe command output as + // xlog.Logger has a xlog.InfoLevel. + logger = test.InfoLogger() + cmd, err = CommandContext(context.Background(), logger, "echo", "Hello", "World") + LogBeforeExecute(logger, cmd) + assert.Nil(t, err) +} + +func TestRun(t *testing.T) { + logger := test.DebugLogger() + // should run without issue. + err := Run(context.Background(), logger, "echo", "Hello", "World") + assert.Nil(t, err) + // should not be OK as context.Context + // should timeout. + ctx, cancel := xcontext.WithTimeout(logger, 0) + defer cancel() + err = Run(ctx, logger, "echo", "Hello", "World") + assert.NotNil(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/xrand/xrand.go b/internal/pkg/xrand/xrand.go new file mode 100644 index 00000000..bdac8fab --- /dev/null +++ b/internal/pkg/xrand/xrand.go @@ -0,0 +1,10 @@ +package xrand + +import ( + "github.com/labstack/gommon/random" +) + +// Get returns a random string. +func Get() string { + return random.String(32) +} diff --git a/internal/pkg/xrand/xrand_test.go b/internal/pkg/xrand/xrand_test.go new file mode 100644 index 00000000..e5880f99 --- /dev/null +++ b/internal/pkg/xrand/xrand_test.go @@ -0,0 +1,28 @@ +package xrand + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGet(t *testing.T) { + var rands []string + // use case: for 1 000 concurrent + // requests (which is a big Gotenberg instance), + // none should have the same identifier. + for i := 0; i < 1000; i++ { + rands = append(rands, Get()) + } + unique := func() bool { + for i, rand := range rands { + for j, current := range rands { + if i != j && rand == current { + return false + } + } + } + return true + } + assert.Equal(t, true, unique()) +} 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/loadtesting/README.md b/loadtesting/README.md new file mode 100644 index 00000000..eb673c9f --- /dev/null +++ b/loadtesting/README.md @@ -0,0 +1,160 @@ +# Load testing + +You may wonder how Gotenberg behaves under load. + +In order to help you having an idea, we created a bunch of scenarios for the +[k6](https://docs.k6.io/docs) load testing tool. + +The Gotenberg container (version `6.0.0` and default options) was hosted on a AWS EC2 `t2.micro` instance (1 vCPU and 1 Go of RAM, low to average network performances). + +The k6 scenarios have been performed on a MacBook Pro 2016 (2 GHz Intel Core i5 and 16 Go 1867 MHz LPDDR3). + +## HTML + +The HTML scenario is quite simple: + +* Ramp up from 0 to 100 virtual users, each one uploading as many time as possible the [HTML test data](../test/testdata/html). +* Stop when at least one response is not an HTTP 200 code. + +```bash +$ k6 run --env MAX_VUS=100 --env BASE_URL=http://ec2-foo.eu-west-1.compute.amazonaws.com html.js + + /\ |‾‾| /‾‾/ /‾/ + /\ / \ | |_/ / / / + / \/ \ | | / ‾‾\ + / \ | |‾\ \ | (_) | + / __________ \ |__| \__\ \___/ .io + + execution: local + output: - + script: html.js + + duration: -, iterations: - + vus: 1, max: 100 + + done [==========================================================] 1m17.9s / 10m0s + + ✗ is status 200 + ↳ 99% — ✓ 201 / ✗ 2 + ✗ is not status 504 + ↳ 99% — ✓ 201 / ✗ 2 + ✓ is not status 500 + + checks.....................: 99.34% ✓ 605 ✗ 4 + data_received..............: 46 MB 594 kB/s + data_sent..................: 9.6 MB 123 kB/s + ✗ failed requests............: 2 0.025646/s + http_req_blocked...........: avg=1.63ms min=2µs med=4µs max=74.77ms p(90)=9.6µs p(95)=19.66ms + http_req_connecting........: avg=1.61ms min=0s med=0s max=74.59ms p(90)=0s p(95)=19.57ms + http_req_duration..........: avg=2.43s min=1.31s med=1.67s max=10.27s p(90)=5.44s p(95)=8.75s + http_req_receiving.........: avg=97.95ms min=103µs med=93.74ms max=202.48ms p(90)=114.51ms p(95)=132.99ms + http_req_sending...........: avg=231.53µs min=92µs med=201µs max=2.33ms p(90)=310.4µs p(95)=333.59µs + http_req_tls_handshaking...: avg=0s min=0s med=0s max=0s p(90)=0s p(95)=0s + http_req_waiting...........: avg=2.33s min=1.22s med=1.58s max=10.12s p(90)=5.33s p(95)=8.62s + http_reqs..................: 203 2.603019/s + iteration_duration.........: avg=2.43s min=1.31s med=1.68s max=10.27s p(90)=5.45s p(95)=8.75s + iterations.................: 203 2.603019/s + vus........................: 13 min=1 max=13 + vus_max....................: 100 min=100 max=100 +``` + +In our use case, when reaching 13 virtual users (~2,6 requests per second), some incoming requests cannot be fulfilled before 10 seconds (`DEFAULT_WAIT_TIMEOUT` value). +During this test, CPU usage was high and memory usage went from 339 MiB to a peak of 421 MiB before going back to 364 MiB. + +## Office + +The Office scenario is the same as the HTML scenario, but with a [document.docx](../test/testdata/office/document.docx). + +```bash +$ k6 run --env MAX_VUS=100 --env BASE_URL=http://ec2-foo.eu-west-1.compute.amazonaws.com office.js + + /\ |‾‾| /‾‾/ /‾/ + /\ / \ | |_/ / / / + / \/ \ | | / ‾‾\ + / \ | |‾\ \ | (_) | + / __________ \ |__| \__\ \___/ .io + + execution: local + output: - + script: office.js + + duration: -, iterations: - + vus: 1, max: 100 + + done [==========================================================] 47.9s / 10m0s + + ✓ is not status 500 + ✗ is status 200 + ↳ 83% — ✓ 31 / ✗ 6 + ✗ is not status 504 + ↳ 83% — ✓ 31 / ✗ 6 + + checks.....................: 89.18% ✓ 99 ✗ 12 + data_received..............: 2.6 MB 54 kB/s + data_sent..................: 3.4 MB 71 kB/s + ✗ failed requests............: 6 0.125047/s + http_req_blocked...........: avg=4.39ms min=3µs med=4µs max=24.52ms p(90)=23.27ms p(95)=23.62ms + http_req_connecting........: avg=4.34ms min=0s med=0s max=24.42ms p(90)=23.15ms p(95)=23.52ms + http_req_duration..........: avg=5.34s min=1.74s med=4.33s max=10.83s p(90)=10.24s p(95)=10.25s + http_req_receiving.........: avg=42.49ms min=67µs med=49.1ms max=68.87ms p(90)=57.34ms p(95)=62.06ms + http_req_sending...........: avg=341.91µs min=215µs med=341µs max=724µs p(90)=443.99µs p(95)=514.79µs + http_req_tls_handshaking...: avg=0s min=0s med=0s max=0s p(90)=0s p(95)=0s + http_req_waiting...........: avg=5.3s min=1.7s med=4.27s max=10.83s p(90)=10.24s p(95)=10.25s + http_reqs..................: 37 0.771121/s + iteration_duration.........: avg=5.34s min=1.74s med=4.35s max=10.86s p(90)=10.24s p(95)=10.25s + iterations.................: 37 0.771121/s + vus........................: 8 min=1 max=8 + vus_max....................: 100 min=100 max=100 +``` + +In our use case, when reaching 8 virtual users (~0.8 requests per second), some incoming requests cannot be fulfilled before 10 seconds (`DEFAULT_WAIT_TIMEOUT` value). +During this test, CPU usage was high and memory usage went from 315 MiB to a peak of 788 MiB before going back to 307 MiB. + +## Merge + +The Merge scenario is the same as the previous scenarios, but with a [gotenberg.pdf](../test/testdata/pdf/gotenberg.pdf) and a [gotenberg_bis.pdf](../test/testdata/pdf/gotenberg_bis.pdf). + +```bash +$ k6 run --env MAX_VUS=100 --env BASE_URL=http://ec2-foo.eu-west-1.compute.amazonaws.com merge.js + + /\ |‾‾| /‾‾/ /‾/ + /\ / \ | |_/ / / / + / \/ \ | | / ‾‾\ + / \ | |‾\ \ | (_) | + / __________ \ |__| \__\ \___/ .io + + execution: local + output: - + script: merge.js + + duration: -, iterations: - + vus: 1, max: 100 + + done [==========================================================] 1m45.9s / 10m0s + + ✗ is status 200 + ↳ 98% — ✓ 165 / ✗ 3 + ✗ is not status 504 + ↳ 98% — ✓ 165 / ✗ 3 + ✓ is not status 500 + + checks.....................: 98.80% ✓ 498 ✗ 6 + data_received..............: 69 MB 649 kB/s + data_sent..................: 70 MB 661 kB/s + ✗ failed requests............: 3 0.028302/s + http_req_blocked...........: avg=2.28ms min=2µs med=5µs max=33.24ms p(90)=23.6µs p(95)=23.38ms + http_req_connecting........: avg=2.24ms min=0s med=0s max=33.1ms p(90)=0s p(95)=23.04ms + http_req_duration..........: avg=5.32s min=632.86ms med=5.24s max=10.17s p(90)=9.42s p(95)=9.91s + http_req_receiving.........: avg=122.07ms min=82µs med=114.72ms max=230.35ms p(90)=162.27ms p(95)=188.95ms + http_req_sending...........: avg=16.56ms min=349µs med=1.37ms max=223.25ms p(90)=12ms p(95)=149.76ms + http_req_tls_handshaking...: avg=0s min=0s med=0s max=0s p(90)=0s p(95)=0s + http_req_waiting...........: avg=5.18s min=573.03ms med=5.09s max=10.13s p(90)=9.29s p(95)=9.78s + http_reqs..................: 168 1.58493/s + iteration_duration.........: avg=5.33s min=633.87ms med=5.24s max=10.17s p(90)=9.43s p(95)=9.91s + iterations.................: 168 1.58493/s + vus........................: 18 min=1 max=18 + vus_max....................: 100 min=100 max=100 +``` + +In our use case, when reaching 18 virtual users (~1.6 requests per second), some incoming requests cannot be fulfilled before 10 seconds (`DEFAULT_WAIT_TIMEOUT` value). +During this test, CPU usage was high and memory usage went from 310 MiB to a peak of 604 MiB before going back to 331 MiB. diff --git a/loadtesting/html.js b/loadtesting/html.js new file mode 100644 index 00000000..b6965a35 --- /dev/null +++ b/loadtesting/html.js @@ -0,0 +1,44 @@ +import http from "k6/http"; +import { Counter } from "k6/metrics"; +import { check } from "k6"; + +let indexFile = open("../test/testdata/html/index.html", "b"), + styleFile = open("../test/testdata/html/style.css", "b"), + headerFile = open("../test/testdata/html/header.html", "b"), + footerFile = open("../test/testdata/html/footer.html", "b"), + fontFile = open("../test/testdata/html/font.woff", "b"), + imgFile = open("../test/testdata/html/img.gif", "b"); + +let failCounter = new Counter("failed requests"); + +export let options = { + stages: [ + { duration: "10m", target: __ENV.MAX_VUS } + ], + thresholds: { + "failed requests": [{ + threshold: "count<1", + abortOnFail: true, + }] + } +} + +export default function() { + let data = { + "index.html": http.file(indexFile, "index.html"), + "style.css": http.file(styleFile, "style.css"), + "header.html": http.file(headerFile, "header.html"), + "footer.html": http.file(footerFile, "footer.html"), + "font.woff": http.file(fontFile, "font.woff"), + "img.gif": http.file(imgFile, "img.gif") + } + let res = http.post(__ENV.BASE_URL + '/convert/html', data); + check(res, { + "is status 200": (r) => r.status === 200, + "is not status 504": (r) => r.status !== 504, + "is not status 500": (r) => r.status !== 500 + }); + if (res.status !== 200) { + failCounter.add(1); + } +} \ No newline at end of file diff --git a/loadtesting/merge.js b/loadtesting/merge.js new file mode 100644 index 00000000..29e1db0d --- /dev/null +++ b/loadtesting/merge.js @@ -0,0 +1,36 @@ +import http from "k6/http"; +import { Counter } from "k6/metrics"; +import { check } from "k6"; + +let pdf1File = open("../test/testdata/pdf/gotenberg.pdf", "b"), + pdf2File = open("../test/testdata/pdf/gotenberg_bis.pdf", "b"); + +let failCounter = new Counter("failed requests"); + +export let options = { + stages: [ + { duration: "10m", target: __ENV.MAX_VUS } + ], + thresholds: { + "failed requests": [{ + threshold: "count<1", + abortOnFail: true, + }] + } +} + +export default function() { + var data = { + "gotenberg.pdf": http.file(pdf1File, "gotenberg.pdf"), + "gotenberg_bis.pdf": http.file(pdf2File, "gotenberg_bis.pdf") + } + var res = http.post(__ENV.BASE_URL + '/merge', data); + check(res, { + "is status 200": (r) => r.status === 200, + "is not status 504": (r) => r.status !== 504, + "is not status 500": (r) => r.status !== 500, + }); + if (res.status !== 200) { + failCounter.add(1); + } +} \ No newline at end of file diff --git a/loadtesting/office.js b/loadtesting/office.js new file mode 100644 index 00000000..6df88069 --- /dev/null +++ b/loadtesting/office.js @@ -0,0 +1,34 @@ +import http from "k6/http"; +import { Counter } from "k6/metrics"; +import { check } from "k6"; + +let documentFile = open("../test/testdata/office/document.docx", "b"); + +let failCounter = new Counter("failed requests"); + +export let options = { + stages: [ + { duration: "10m", target: __ENV.MAX_VUS } + ], + thresholds: { + "failed requests": [{ + threshold: "count<1", + abortOnFail: true, + }] + } +} + +export default function() { + let data = { + "document.docx": http.file(documentFile, "document.docx") + } + let res = http.post(__ENV.BASE_URL + '/convert/office', data); + check(res, { + "is status 200": (r) => r.status === 200, + "is not status 504": (r) => r.status !== 504, + "is not status 500": (r) => r.status !== 500, + }); + if (res.status !== 200) { + failCounter.add(1); + } +} \ No newline at end of file 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/scripts/tests.sh b/scripts/tests.sh new file mode 100755 index 00000000..3824cc6d --- /dev/null +++ b/scripts/tests.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +set -e + +DOCKER_REPOSITORY="$1" +CODE_COVERAGE="$2" + +touch "$PWD/coverage.txt" +chmod 777 "$PWD/coverage.txt" +docker build -t "$DOCKER_REPOSITORY/gotenberg:tests" -f build/tests/Dockerfile . + +if [ "$CODE_COVERAGE" = "1" ]; then + docker run --rm -e "CODE_COVERAGE=$CODE_COVERAGE" -v "$PWD/coverage.txt:/gotenberg/tests/coverage.txt" "$DOCKER_REPOSITORY/gotenberg:tests" +else + docker run --rm -e "CODE_COVERAGE=$CODE_COVERAGE" "$DOCKER_REPOSITORY/gotenberg:tests" +fi \ No newline at end of file diff --git a/test/cmd/chrome.go b/test/cmd/chrome.go new file mode 100644 index 00000000..73d96474 --- /dev/null +++ b/test/cmd/chrome.go @@ -0,0 +1,20 @@ +package main + +import ( + "github.com/thecodingmachine/gotenberg/internal/pkg/chrome" + "github.com/thecodingmachine/gotenberg/internal/pkg/conf" + "github.com/thecodingmachine/gotenberg/internal/pkg/xlog" +) + +func main() { + const op string = "main" + config, err := conf.FromEnv() + systemLogger := xlog.New(config.LogLevel(), "system") + if err != nil { + systemLogger.FatalOp(op, err) + } + // start Google Chrome headless. + if err := chrome.Start(systemLogger); err != nil { + systemLogger.FatalOp(op, err) + } +} diff --git a/test/context.go b/test/context.go new file mode 100644 index 00000000..537ae5f2 --- /dev/null +++ b/test/context.go @@ -0,0 +1,29 @@ +package test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/labstack/echo/v4" +) + +// DummyEchoContext creates a +// echo.Context without anything. +func DummyEchoContext() echo.Context { + e := echo.New() + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + return e.NewContext(req, rec) +} + +// EchoContextMultipart creates a +// echo.Context with form files. +func EchoContextMultipart(t *testing.T) echo.Context { + e := echo.New() + body, contentType := MergeMultipartForm(t, nil) + req := httptest.NewRequest(http.MethodPost, "/", body) + req.Header.Set(echo.HeaderContentType, contentType) + rec := httptest.NewRecorder() + return e.NewContext(req, rec) +} 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/http.go b/test/http.go new file mode 100644 index 00000000..187ba58c --- /dev/null +++ b/test/http.go @@ -0,0 +1,17 @@ +package test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +// AssertStatusCode checks if the given request +// returns the expected status code. +func AssertStatusCode(t *testing.T, expectedStatusCode int, srv http.Handler, req *http.Request) { + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + assert.Equal(t, expectedStatusCode, rec.Code) +} diff --git a/test/multipartform.go b/test/multipartform.go new file mode 100644 index 00000000..0bc73508 --- /dev/null +++ b/test/multipartform.go @@ -0,0 +1,90 @@ +package test + +import ( + "bytes" + "io" + "mime/multipart" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +/* +MergeMultipartForm returns the body +for a multipart/form-data request with all +files under "testdata/pdf" folder. +*/ +func MergeMultipartForm(t *testing.T, formValues map[string]string) (*bytes.Buffer, string) { + fpaths := MergeFpaths(t) + return multipartForm(t, "pdf", formValues, fpaths) +} + +/* +HTMLMultipartForm returns the body +for a multipart/form-data request with all +files under "testdata/html" folder. +*/ +func HTMLMultipartForm(t *testing.T, formValues map[string]string) (*bytes.Buffer, string) { + fpaths := HTMLFpaths(t) + return multipartForm(t, "html", formValues, fpaths) +} + +/* +URLMultipartForm returns the body +for a multipart/form-data request with all +files under "testdata/url" folder. +*/ +func URLMultipartForm(t *testing.T, formValues map[string]string) (*bytes.Buffer, string) { + fpaths := URLFpaths(t) + return multipartForm(t, "url", formValues, fpaths) +} + +/* +MarkdownMultipartForm returns the body +for a multipart/form-data request with all +files under "testdata/markdown" folder. +*/ +func MarkdownMultipartForm(t *testing.T, formValues map[string]string) (*bytes.Buffer, string) { + fpaths := MarkdownFpaths(t) + return multipartForm(t, "markdown", formValues, fpaths) +} + +/* +OfficeMultipartForm returns the body +for a multipart/form-data request with all +files under "testdata/office" folder. +*/ +func OfficeMultipartForm(t *testing.T, formValues map[string]string) (*bytes.Buffer, string) { + fpaths := OfficeFpaths(t) + return multipartForm(t, "office", formValues, fpaths) +} + +func multipartForm( + t *testing.T, + kind string, + formValues map[string]string, + formFilePaths []string, +) (*bytes.Buffer, string) { + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + defer writer.Close() + for _, fpath := range formFilePaths { + file, err := os.Open(fpath) + require.Nil(t, err) + part, err := writer.CreateFormFile("foo", filepath.Base(fpath)) + require.Nil(t, err) + _, err = io.Copy(part, file) + require.Nil(t, err) + } + if kind == "url" { + err := writer.WriteField("remoteURL", "http://google.com") + require.Nil(t, err) + } + for k, v := range formValues { + err := writer.WriteField(k, v) + require.Nil(t, err) + } + return body, writer.FormDataContentType() +} diff --git a/test/testdata.go b/test/testdata.go new file mode 100644 index 00000000..e40ab818 --- /dev/null +++ b/test/testdata.go @@ -0,0 +1,87 @@ +package test + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" + "github.com/thecodingmachine/gotenberg/internal/pkg/xrand" +) + +/* +testdataDirectoryPath should be +the absolute of the testdata INSIDE +the Docker image. +*/ +const testdataDirectoryPath string = "/gotenberg/tests/test/testdata" + +// GenerateDestination simply generates +// a path for a resulting PDF file. +func GenerateDestination() string { + return fmt.Sprintf("/tmp/%s.pdf", xrand.Get()) +} + +// MergeFpaths return the paths of all +// files under "testdata/pdf" folder. +func MergeFpaths(t *testing.T) []string { + return []string{ + fpath(t, "pdf", "gotenberg.pdf"), + fpath(t, "pdf", "gotenberg_bis.pdf"), + } +} + +// HTMLFpaths return the paths of all +// files under "testdata/html" folder. +func HTMLFpaths(t *testing.T) []string { + return []string{ + fpath(t, "html", "index.html"), + fpath(t, "html", "header.html"), + fpath(t, "html", "footer.html"), + fpath(t, "html", "style.css"), + fpath(t, "html", "img.gif"), + fpath(t, "html", "font.woff"), + } +} + +// URLFpaths return the paths of all +// files under "testdata/url" folder. +func URLFpaths(t *testing.T) []string { + return []string{ + fpath(t, "url", "header.html"), + fpath(t, "url", "footer.html"), + } +} + +// MarkdownFpaths return the paths of all +// files under "testdata/markdown" folder. +func MarkdownFpaths(t *testing.T) []string { + return []string{ + fpath(t, "markdown", "index.html"), + fpath(t, "markdown", "header.html"), + fpath(t, "markdown", "footer.html"), + fpath(t, "markdown", "style.css"), + fpath(t, "markdown", "img.gif"), + fpath(t, "markdown", "font.woff"), + fpath(t, "markdown", "paragraph1.md"), + fpath(t, "markdown", "paragraph2.md"), + fpath(t, "markdown", "paragraph3.md"), + } +} + +// OfficeFpaths return the paths of all +// files under "testdata/office" folder. +func OfficeFpaths(t *testing.T) []string { + return []string{ + fpath(t, "office", "document.docx"), + fpath(t, "office", "document.rtf"), + fpath(t, "office", "document.txt"), + } +} + +func fpath(t *testing.T, kind, filename string) string { + require.NotEmpty(t, kind) + require.NotEmpty(t, filename) + fpath := fmt.Sprintf("%s/%s/%s", testdataDirectoryPath, kind, filename) + require.FileExists(t, fpath) + return fpath +} diff --git a/test/testfunc.go b/test/testfunc.go deleted file mode 100644 index acdbafa0..00000000 --- a/test/testfunc.go +++ /dev/null @@ -1,121 +0,0 @@ -// Package test contains useful functions used across tests. -package test - -import ( - "bytes" - "fmt" - "io" - "mime/multipart" - "net/http" - "net/http/httptest" - "os" - "path" - "path/filepath" - "runtime" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "golang.org/x/sync/errgroup" -) - -// AssertStatusCode checks if the given request -// returns the expected status code. -func AssertStatusCode(t *testing.T, expectedStatusCode int, srv http.Handler, req *http.Request) { - rec := httptest.NewRecorder() - srv.ServeHTTP(rec, req) - assert.Equal(t, expectedStatusCode, rec.Code) -} - -// AssertConcurrent runs all functions simultaneously -// and wait until execution has completed -// or an error is encountered. -func AssertConcurrent(t *testing.T, fn func() error, amount int) { - eg := errgroup.Group{} - for i := 0; i < amount; i++ { - eg.Go(fn) - } - err := eg.Wait() - assert.NoError(t, err) -} - -// HTMLTestMultipartForm returns the body -// for a multipate/form-data request with all -// files under "html" folder. -func HTMLTestMultipartForm(t *testing.T, formValues map[string]string) (*bytes.Buffer, string) { - return multipartForm(t, "html", formValues) -} - -// URLTestMultipartForm returns the body -// for a multipate/form-data request with all -// files under "url" folder. -func URLTestMultipartForm(t *testing.T, formValues map[string]string) (*bytes.Buffer, string) { - return multipartForm(t, "url", formValues) -} - -// MarkdownTestMultipartForm returns the body -// for a multipate/form-data request with all -// files under "markdown" folder. -func MarkdownTestMultipartForm(t *testing.T, formValues map[string]string) (*bytes.Buffer, string) { - return multipartForm(t, "markdown", formValues) -} - -// OfficeTestMultipartForm returns the body -// for a multipate/form-data request with all -// files under "office" folder. -func OfficeTestMultipartForm(t *testing.T, formValues map[string]string) (*bytes.Buffer, string) { - return multipartForm(t, "office", formValues) -} - -// PDFTestMultipartForm returns the body -// for a multipate/form-data request with all -// files under "pdf" folder. -func PDFTestMultipartForm(t *testing.T, formValues map[string]string) (*bytes.Buffer, string) { - return multipartForm(t, "pdf", formValues) -} - -func multipartForm(t *testing.T, kind string, formValues map[string]string) (*bytes.Buffer, string) { - body := &bytes.Buffer{} - writer := multipart.NewWriter(body) - defer writer.Close() - dirPath := abs(t, kind, "") - fpaths := make(map[string]string) - err := filepath.Walk(dirPath, func(path string, info os.FileInfo, _ error) error { - if info.IsDir() { - return nil - } - fpaths[info.Name()] = abs(t, kind, info.Name()) - return nil - }) - require.Nil(t, err) - for filename, fpath := range fpaths { - file, err := os.Open(fpath) - require.Nil(t, err) - part, err := writer.CreateFormFile("foo", filename) - require.Nil(t, err) - _, err = io.Copy(part, file) - require.Nil(t, err) - } - if kind == "url" { - err := writer.WriteField("remoteURL", "http://google.com") - require.Nil(t, err) - } - for k, v := range formValues { - err := writer.WriteField(k, v) - require.Nil(t, err) - } - return body, writer.FormDataContentType() -} - -func abs(t *testing.T, kind, filename string) string { - _, gofilename, _, ok := runtime.Caller(0) - require.Equal(t, ok, true, "got no caller information") - if filename == "" { - path, err := filepath.Abs(fmt.Sprintf("%s/testdata/%s", path.Dir(gofilename), kind)) - require.Nil(t, err, `getting the absolute path of "%s"`, kind) - return path - } - path, err := filepath.Abs(fmt.Sprintf("%s/testdata/%s/%s", path.Dir(gofilename), kind, filename)) - require.Nil(t, err, `getting the absolute path of "%s"`, filename) - return path -} diff --git a/test/xerror.go b/test/xerror.go new file mode 100644 index 00000000..6bc04278 --- /dev/null +++ b/test/xerror.go @@ -0,0 +1,18 @@ +package test + +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/xlog.go b/test/xlog.go new file mode 100644 index 00000000..b8c93611 --- /dev/null +++ b/test/xlog.go @@ -0,0 +1,23 @@ +package test + +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") +}