mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-08-08 08:32:16 +01:00
Compare commits
191 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b49b35365 | ||
|
|
333ab7066c | ||
|
|
66dbdc09b5 | ||
|
|
79f396f6ff | ||
|
|
62ba4877ac | ||
|
|
e65a5e6d22 | ||
|
|
711b43070d | ||
|
|
b2f957d1d8 | ||
|
|
249745f256 | ||
|
|
8ff9d3bcf1 | ||
|
|
99c328c302 | ||
|
|
8a9f0a245e | ||
|
|
9daecc127a | ||
|
|
119bba4f04 | ||
|
|
bf205c579d | ||
|
|
aa57b17254 | ||
|
|
7df786c5c6 | ||
|
|
b315464e98 | ||
|
|
1252ea076b | ||
|
|
d970e446a8 | ||
|
|
fe40f3727b | ||
|
|
10a290b065 | ||
|
|
0abb11b42a | ||
|
|
1eb6eb729a | ||
|
|
e371a4a3c3 | ||
|
|
f2b6bd3d4b | ||
|
|
d21f87b543 | ||
|
|
b8cdee4f45 | ||
|
|
b06d7445d9 | ||
|
|
2aa9cceb32 | ||
|
|
ae2a2a8a07 | ||
|
|
286555411b | ||
|
|
3c42aec180 | ||
|
|
a203ae00c6 | ||
|
|
68784616a4 | ||
|
|
5cd8c6a5da | ||
|
|
a3647fea8a | ||
|
|
300e73f07d | ||
|
|
03ced23f05 | ||
|
|
da8eeb5d8f | ||
|
|
16cf02896f | ||
|
|
c9f10d248e | ||
|
|
3297e41289 | ||
|
|
4e1b347797 | ||
|
|
4602332dbb | ||
|
|
e1f69ee533 | ||
|
|
d90e431b5b | ||
|
|
45a9eca0ce | ||
|
|
af202f4871 | ||
|
|
7b32fa35f3 | ||
|
|
0c40b230dc | ||
|
|
2baba67bfc | ||
|
|
b1e69a9059 | ||
|
|
f87362ec1b | ||
|
|
84dc57373a | ||
|
|
b3f4a02658 | ||
|
|
50304dbefe | ||
|
|
bf01af0bf3 | ||
|
|
0e3fe3463b | ||
|
|
3956763825 | ||
|
|
d6fe895f6e | ||
|
|
697fa08d8b | ||
|
|
669a35afef | ||
|
|
b1d94a3845 | ||
|
|
06869cf491 | ||
|
|
ed67d8514c | ||
|
|
07ec2f3d7b | ||
|
|
73d02e85c4 | ||
|
|
c1cf1eb37a | ||
|
|
7aecc94049 | ||
|
|
b55fbfeb8e | ||
|
|
20b8991fa4 | ||
|
|
e50de4201d | ||
|
|
ba2b876ee4 | ||
|
|
9b1399c9d5 | ||
|
|
020a2f00c2 | ||
|
|
8713946f35 | ||
|
|
bed8c25e40 | ||
|
|
7c51a09068 | ||
|
|
0f0a6ce042 | ||
|
|
a890968ee4 | ||
|
|
2267c5be00 | ||
|
|
e853cfb98a | ||
|
|
f89a0db471 | ||
|
|
1d66b43da1 | ||
|
|
75c4a4f253 | ||
|
|
6a9c95b3b1 | ||
|
|
c11a2d0081 | ||
|
|
43a13ff2c5 | ||
|
|
8454d163eb | ||
|
|
4a11f6a39f | ||
|
|
c63235cd3d | ||
|
|
f4ce196a34 | ||
|
|
e644b27201 | ||
|
|
d2b14582ee | ||
|
|
ca9194fd96 | ||
|
|
773d3ab13c | ||
|
|
a02a4a07a5 | ||
|
|
0a8625227f | ||
|
|
dc613aa2bf | ||
|
|
2008ac5663 | ||
|
|
da326f1925 | ||
|
|
71911cb1a7 | ||
|
|
31e7582216 | ||
|
|
91f63047e0 | ||
|
|
c9aa4938f4 | ||
|
|
b9e6a38037 | ||
|
|
eb054146d2 | ||
|
|
a780373cfc | ||
|
|
5ad90ccdb8 | ||
|
|
ef97d81ca8 | ||
|
|
c9f7b642c5 | ||
|
|
770962be3f | ||
|
|
d6c857f4a8 | ||
|
|
7b7ffb427d | ||
|
|
b80318d1b6 | ||
|
|
3997d69e49 | ||
|
|
4af41b33c0 | ||
|
|
0f8bbead66 | ||
|
|
804fcff5b6 | ||
|
|
d18e6920fc | ||
|
|
c95174ff11 | ||
|
|
c70329c22b | ||
|
|
34f69263a2 | ||
|
|
bf5056f8fe | ||
|
|
c47f5b06ba | ||
|
|
63055e3df2 | ||
|
|
8ee555140b | ||
|
|
859f78e986 | ||
|
|
366eebb68c | ||
|
|
25ce0b9aee | ||
|
|
376a7db545 | ||
|
|
f906ed741d | ||
|
|
ad152e62e5 | ||
|
|
7f3a61ef43 | ||
|
|
a7ba8c8213 | ||
|
|
b1f3d6d4c0 | ||
|
|
5707ae027f | ||
|
|
770208024d | ||
|
|
975a9f5344 | ||
|
|
a45008411e | ||
|
|
9091ffd1b3 | ||
|
|
089f161d1e | ||
|
|
e6d7131701 | ||
|
|
ed82170c2c | ||
|
|
3472d42cdd | ||
|
|
b8926350b9 | ||
|
|
e5194b313d | ||
|
|
94228375ad | ||
|
|
0e8385a5a7 | ||
|
|
afd3763d96 | ||
|
|
1122f09093 | ||
|
|
1123af15c9 | ||
|
|
b2f6e865a7 | ||
|
|
93b9ac2d13 | ||
|
|
64af65b0e7 | ||
|
|
390c8437c5 | ||
|
|
cc65ca9b6a | ||
|
|
4bd7cba247 | ||
|
|
92e2f7f8b7 | ||
|
|
98054ddff6 | ||
|
|
4e5c1aba2e | ||
|
|
5eb44320ae | ||
|
|
f751269289 | ||
|
|
0e836622ec | ||
|
|
a6ac0a813d | ||
|
|
21c47f60f2 | ||
|
|
66142b1dd8 | ||
|
|
3a28f4a0cb | ||
|
|
71eeca96ee | ||
|
|
b56cde47ca | ||
|
|
793e65bac0 | ||
|
|
4d1a569269 | ||
|
|
5c56317d50 | ||
|
|
32491ae2ad | ||
|
|
28aeb11565 | ||
|
|
61eb3ff908 | ||
|
|
ff5455881e | ||
|
|
143b7ce678 | ||
|
|
9c3dfc78df | ||
|
|
017872c5ef | ||
|
|
b9ad67d147 | ||
|
|
62ea3b1a1a | ||
|
|
325b668d00 | ||
|
|
9ce6bf8e14 | ||
|
|
052448c59a | ||
|
|
c8b318b315 | ||
|
|
54daac329e | ||
|
|
b5a59e4de0 | ||
|
|
258876d13f | ||
|
|
9cc8e16d64 |
1
.github/FUNDING.yml
vendored
Normal file
1
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
github: [gulien]
|
||||
4
.github/stale.yml
vendored
4
.github/stale.yml
vendored
@@ -1,7 +1,7 @@
|
||||
# Number of days of inactivity before an issue becomes stale
|
||||
daysUntilStale: 15
|
||||
daysUntilStale: 7
|
||||
# Number of days of inactivity before a stale issue is closed
|
||||
daysUntilClose: 7
|
||||
daysUntilClose: 3
|
||||
# Issues with these labels will never be considered stale
|
||||
exemptLabels:
|
||||
- bug
|
||||
|
||||
5
.github/workflows/continuous_delivery.yml
vendored
5
.github/workflows/continuous_delivery.yml
vendored
@@ -5,7 +5,6 @@ on:
|
||||
types: [ published ]
|
||||
|
||||
jobs:
|
||||
|
||||
release:
|
||||
name: Release Docker image
|
||||
runs-on: ubuntu-latest
|
||||
@@ -21,7 +20,7 @@ jobs:
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Build and push Docker image
|
||||
- name: Build and push Docker image for release
|
||||
run: |
|
||||
make release GOTENBERG_VERSION=${{ github.event.release.tag_name }}
|
||||
make release GOTENBERG_VERSION=${{ github.event.release.tag_name }} DOCKER_REPOSITORY=thecodingmachine
|
||||
make release GOTENBERG_VERSION=${{ github.event.release.tag_name }} DOCKER_REGISTRY=thecodingmachine
|
||||
|
||||
61
.github/workflows/continuous_integration.yml
vendored
61
.github/workflows/continuous_integration.yml
vendored
@@ -15,20 +15,20 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v4
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.21'
|
||||
go-version: '1.23'
|
||||
cache: false
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
- name: Run linters
|
||||
uses: golangci/golangci-lint-action@v3
|
||||
uses: golangci/golangci-lint-action@v6
|
||||
with:
|
||||
version: v1.54.2
|
||||
version: v1.61.0
|
||||
|
||||
tests:
|
||||
needs:
|
||||
- Lint
|
||||
- lint
|
||||
name: Tests
|
||||
# TODO: once arm64 actions are available, also run the tests on this architecture.
|
||||
# See: https://github.com/actions/virtual-environments/issues/2552#issuecomment-771478000.
|
||||
@@ -43,6 +43,53 @@ jobs:
|
||||
- name: Build testing environment
|
||||
run: make build build-tests
|
||||
- name: Run tests
|
||||
run: make tests-once
|
||||
- name: Upload to Codecov
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
verbose: true
|
||||
|
||||
snapshot_release:
|
||||
if: github.event_name == 'pull_request'
|
||||
needs:
|
||||
- tests
|
||||
name: Snapshot release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
- name: Log in to Docker Hub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Build and push snapshot Docker image (linux/amd64)
|
||||
run: make release GOTENBERG_VERSION=${{ github.head_ref }} DOCKER_REPOSITORY=snapshot LINUX_AMD64_RELEASE=true
|
||||
|
||||
edge_release:
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
needs:
|
||||
- tests
|
||||
name: Edge release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
- name: Log in to Docker Hub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Build and push Docker image for main branch
|
||||
run: |
|
||||
make tests-once
|
||||
bash <(curl -s https://codecov.io/bash)
|
||||
make release GOTENBERG_VERSION=edge
|
||||
make release GOTENBERG_VERSION=edge DOCKER_REGISTRY=thecodingmachine
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,2 +1,5 @@
|
||||
/coverage.html
|
||||
/coverage.txt
|
||||
/coverage.txt
|
||||
.idea
|
||||
.vscode
|
||||
.DS_Store
|
||||
|
||||
@@ -1,27 +1,60 @@
|
||||
linters-settings:
|
||||
gci:
|
||||
sections:
|
||||
- standard
|
||||
- default
|
||||
- prefix(github.com/gotenberg/gotenberg/v8)
|
||||
skip-generated: true
|
||||
custom-order: true
|
||||
# Until https://github.com/securego/gosec/issues/1187 is resolved.
|
||||
gosec:
|
||||
excludes:
|
||||
- G115
|
||||
|
||||
linters:
|
||||
disable-all: true
|
||||
enable:
|
||||
- asasalint
|
||||
- asciicheck
|
||||
- bidichk
|
||||
- bodyclose
|
||||
- decorder
|
||||
- dogsled
|
||||
- dupl
|
||||
- dupword
|
||||
- durationcheck
|
||||
- copyloopvar
|
||||
- errcheck
|
||||
- errname
|
||||
- exhaustive
|
||||
- gci
|
||||
- gofmt
|
||||
- goimports
|
||||
- gofumpt
|
||||
- gosec
|
||||
- gosimple
|
||||
- govet
|
||||
- ineffassign
|
||||
- importas
|
||||
- misspell
|
||||
- prealloc
|
||||
- promlinter
|
||||
#- sloglint
|
||||
- staticcheck
|
||||
- tenv
|
||||
- testableexamples
|
||||
- tparallel
|
||||
- typecheck
|
||||
- unconvert
|
||||
- unused
|
||||
- wastedassign
|
||||
- whitespace
|
||||
|
||||
run:
|
||||
deadline: 5m
|
||||
timeout: 5m
|
||||
issues-exit-code: 1
|
||||
tests: false
|
||||
|
||||
output:
|
||||
format: 'colored-line-number'
|
||||
print-issued-lines: true
|
||||
print-linter-name: true
|
||||
print-linter-name: true
|
||||
|
||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023 Julien Neuhart
|
||||
Copyright (c) 2024 Julien Neuhart
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
108
Makefile
108
Makefile
@@ -5,14 +5,16 @@ help: ## Show the help
|
||||
.PHONY: it
|
||||
it: build build-tests ## Initialize the development environment
|
||||
|
||||
GOLANG_VERSION=1.21
|
||||
GOLANG_VERSION=1.23
|
||||
DOCKER_REGISTRY=gotenberg
|
||||
DOCKER_REPOSITORY=gotenberg
|
||||
GOTENBERG_VERSION=snapshot
|
||||
GOTENBERG_USER_GID=1001
|
||||
GOTENBERG_USER_UID=1001
|
||||
NOTO_COLOR_EMOJI_VERSION=v2.038 # See https://github.com/googlefonts/noto-emoji/releases.
|
||||
NOTO_COLOR_EMOJI_VERSION=v2.047 # See https://github.com/googlefonts/noto-emoji/releases.
|
||||
PDFTK_VERSION=v3.3.3 # See https://gitlab.com/pdftk-java/pdftk/-/releases - Binary package.
|
||||
GOLANGCI_LINT_VERSION=v1.54.2 # See https://github.com/golangci/golangci-lint/releases.
|
||||
PDFCPU_VERSION=v0.8.1 # See https://github.com/pdfcpu/pdfcpu/releases.
|
||||
GOLANGCI_LINT_VERSION=v1.61.0 # See https://github.com/golangci/golangci-lint/releases.
|
||||
|
||||
.PHONY: build
|
||||
build: ## Build the Gotenberg's Docker image
|
||||
@@ -23,17 +25,31 @@ build: ## Build the Gotenberg's Docker image
|
||||
--build-arg GOTENBERG_USER_UID=$(GOTENBERG_USER_UID) \
|
||||
--build-arg NOTO_COLOR_EMOJI_VERSION=$(NOTO_COLOR_EMOJI_VERSION) \
|
||||
--build-arg PDFTK_VERSION=$(PDFTK_VERSION) \
|
||||
-t $(DOCKER_REPOSITORY)/gotenberg:$(GOTENBERG_VERSION) \
|
||||
--build-arg PDFCPU_VERSION=$(PDFCPU_VERSION) \
|
||||
-t $(DOCKER_REGISTRY)/$(DOCKER_REPOSITORY):$(GOTENBERG_VERSION) \
|
||||
-f build/Dockerfile .
|
||||
|
||||
GOTENBERG_GRACEFUL_SHUTDOWN_DURATION=30s
|
||||
API_PORT=3000
|
||||
API_PORT_FROM_ENV=
|
||||
API_BIND_IP=
|
||||
API_START_TIMEOUT=30s
|
||||
API_TIMEOUT=30s
|
||||
API_BODY_LIMIT=
|
||||
API_ROOT_PATH=/
|
||||
API_TRACE_HEADER=Gotenberg-Trace
|
||||
API_ENABLE_BASIC_AUTH=false
|
||||
GOTENBERG_API_BASIC_AUTH_USERNAME=
|
||||
GOTENBERG_API_BASIC_AUTH_PASSWORD=
|
||||
API-DOWNLOAD-FROM-ALLOW-LIST=
|
||||
API-DOWNLOAD-FROM-DENY-LIST=
|
||||
API-DOWNLOAD-FROM-FROM-MAX-RETRY=4
|
||||
API-DISABLE-DOWNLOAD-FROM=false
|
||||
API_DISABLE_HEALTH_CHECK_LOGGING=false
|
||||
CHROMIUM_FAILED_STARTS_THRESHOLD=5
|
||||
CHROMIUM_RESTART_AFTER=0
|
||||
CHROMIUM_MAX_QUEUE_SIZE=0
|
||||
CHROMIUM_AUTO_START=false
|
||||
CHROMIUM_START_TIMEOUT=20s
|
||||
CHROMIUM_INCOGNITO=false
|
||||
CHROMIUM_ALLOW_INSECURE_LOCALHOST=false
|
||||
CHROMIUM_IGNORE_CERTIFICATE_ERRORS=false
|
||||
@@ -42,21 +58,29 @@ CHROMIUM_ALLOW_FILE_ACCESS_FROM_FILES=false
|
||||
CHROMIUM_HOST_RESOLVER_RULES=
|
||||
CHROMIUM_PROXY_SERVER=
|
||||
CHROMIUM_ALLOW_LIST=
|
||||
CHROMIUM_DENY_LIST="^file:///[^tmp].*"
|
||||
CHROMIUM_DENY_LIST=^file:(?!//\/tmp/).*
|
||||
CHROMIUM_CLEAR_CACHE=false
|
||||
CHROMIUM_CLEAR_COOKIES=false
|
||||
CHROMIUM_DISABLE_JAVASCRIPT=false
|
||||
CHROMIUM_DISABLE_ROUTES=false
|
||||
LIBREOFFICE_DISABLES_ROUTES=false
|
||||
LIBREOFFICE_RESTART_AFTER=10
|
||||
LIBREOFFICE_MAX_QUEUE_SIZE=0
|
||||
LIBREOFFICE_AUTO_START=false
|
||||
LIBREOFFICE_START_TIMEOUT=20s
|
||||
LIBREOFFICE_DISABLE_ROUTES=false
|
||||
LOG_LEVEL=info
|
||||
LOG_FORMAT=auto
|
||||
LOG_FIELDS_PREFIX=
|
||||
PDFENGINES_ENGINES=
|
||||
PDFENGINES_MERGE_ENGINES=qpdf,pdfcpu,pdftk
|
||||
PDFENGINES_CONVERT_ENGINES=libreoffice-pdfengine
|
||||
PDFENGINES_READ_METADATA_ENGINES=exiftool
|
||||
PDFENGINES_WRITE_METADATA_ENGINES=exiftool
|
||||
PDFENGINES_DISABLE_ROUTES=false
|
||||
PROMETHEUS_NAMESPACE=gotenberg
|
||||
PROMETHEUS_COLLECT_INTERVAL=1s
|
||||
PROMETHEUS_DISABLE_ROUTE_LOGGING=false
|
||||
PROMETHEUS_DISABLE_COLLECT=false
|
||||
UNO_LISTENER_START_TIMEOUT=10s
|
||||
UNO_LISTENER_RESTART_THRESHOLD=10
|
||||
WEBHOOK_ALLOW_LIST=
|
||||
WEBHOOK_DENY_LIST=
|
||||
WEBHOOK_ERROR_ALLOW_LIST=
|
||||
@@ -71,16 +95,29 @@ WEBHOOK_DISABLE=false
|
||||
run: ## Start a Gotenberg container
|
||||
docker run --rm -it \
|
||||
-p $(API_PORT):$(API_PORT) \
|
||||
$(DOCKER_REPOSITORY)/gotenberg:$(GOTENBERG_VERSION) \
|
||||
-e GOTENBERG_API_BASIC_AUTH_USERNAME=$(GOTENBERG_API_BASIC_AUTH_USERNAME) \
|
||||
-e GOTENBERG_API_BASIC_AUTH_PASSWORD=$(GOTENBERG_API_BASIC_AUTH_PASSWORD) \
|
||||
$(DOCKER_REGISTRY)/$(DOCKER_REPOSITORY):$(GOTENBERG_VERSION) \
|
||||
gotenberg \
|
||||
--gotenberg-graceful-shutdown-duration=$(GOTENBERG_GRACEFUL_SHUTDOWN_DURATION) \
|
||||
--api-port=$(API_PORT) \
|
||||
--api-port-from-env=$(API_PORT_FROM_ENV) \
|
||||
--api-bind-ip=$(API_BIND_IP) \
|
||||
--api-start-timeout=$(API_START_TIMEOUT) \
|
||||
--api-timeout=$(API_TIMEOUT) \
|
||||
--api-body-limit="$(API_BODY_LIMIT)" \
|
||||
--api-root-path=$(API_ROOT_PATH) \
|
||||
--api-trace-header=$(API_TRACE_HEADER) \
|
||||
--api-enable-basic-auth=$(API_ENABLE_BASIC_AUTH) \
|
||||
--api-download-from-allow-list=$(API-DOWNLOAD-FROM-ALLOW-LIST) \
|
||||
--api-download-from-deny-list=$(API-DOWNLOAD-FROM-DENY-LIST) \
|
||||
--api-download-from-max-retry=$(API-DOWNLOAD-FROM-FROM-MAX-RETRY) \
|
||||
--api-disable-download-from=$(API-DISABLE-DOWNLOAD-FROM) \
|
||||
--api-disable-health-check-logging=$(API_DISABLE_HEALTH_CHECK_LOGGING) \
|
||||
--chromium-failed-starts-threshold=$(CHROMIUM_FAILED_STARTS_THRESHOLD) \
|
||||
--chromium-restart-after=$(CHROMIUM_RESTART_AFTER) \
|
||||
--chromium-auto-start=$(CHROMIUM_AUTO_START) \
|
||||
--chromium-max-queue-size=$(CHROMIUM_MAX_QUEUE_SIZE) \
|
||||
--chromium-start-timeout=$(CHROMIUM_START_TIMEOUT) \
|
||||
--chromium-incognito=$(CHROMIUM_INCOGNITO) \
|
||||
--chromium-allow-insecure-localhost=$(CHROMIUM_ALLOW_INSECURE_LOCALHOST) \
|
||||
--chromium-ignore-certificate-errors=$(CHROMIUM_IGNORE_CERTIFICATE_ERRORS) \
|
||||
@@ -88,24 +125,32 @@ run: ## Start a Gotenberg container
|
||||
--chromium-allow-file-access-from-files=$(CHROMIUM_ALLOW_FILE_ACCESS_FROM_FILES) \
|
||||
--chromium-host-resolver-rules=$(CHROMIUM_HOST_RESOLVER_RULES) \
|
||||
--chromium-proxy-server=$(CHROMIUM_PROXY_SERVER) \
|
||||
--chromium-allow-list=$(CHROMIUM_ALLOW_LIST) \
|
||||
--chromium-deny-list=$(CHROMIUM_DENY_LIST) \
|
||||
--chromium-allow-list="$(CHROMIUM_ALLOW_LIST)" \
|
||||
--chromium-deny-list="$(CHROMIUM_DENY_LIST)" \
|
||||
--chromium-clear-cache=$(CHROMIUM_CLEAR_CACHE) \
|
||||
--chromium-clear-cookies=$(CHROMIUM_CLEAR_COOKIES) \
|
||||
--chromium-disable-javascript=$(CHROMIUM_DISABLE_JAVASCRIPT) \
|
||||
--chromium-disable-routes=$(CHROMIUM_DISABLE_ROUTES) \
|
||||
--libreoffice-disable-routes=$(LIBREOFFICE_DISABLES_ROUTES) \
|
||||
--libreoffice-restart-after=$(LIBREOFFICE_RESTART_AFTER) \
|
||||
--libreoffice-max-queue-size=$(LIBREOFFICE_MAX_QUEUE_SIZE) \
|
||||
--libreoffice-auto-start=$(LIBREOFFICE_AUTO_START) \
|
||||
--libreoffice-start-timeout=$(LIBREOFFICE_START_TIMEOUT) \
|
||||
--libreoffice-disable-routes=$(LIBREOFFICE_DISABLE_ROUTES) \
|
||||
--log-level=$(LOG_LEVEL) \
|
||||
--log-format=$(LOG_FORMAT) \
|
||||
--log-fields-prefix=$(LOG_FIELDS_PREFIX) \
|
||||
--pdfengines-engines=$(PDFENGINES_ENGINES) \
|
||||
--pdfengines-merge-engines=$(PDFENGINES_MERGE_ENGINES) \
|
||||
--pdfengines-convert-engines=$(PDFENGINES_CONVERT_ENGINES) \
|
||||
--pdfengines-read-metadata-engines=$(PDFENGINES_READ_METADATA_ENGINES) \
|
||||
--pdfengines-write-metadata-engines=$(PDFENGINES_WRITE_METADATA_ENGINES) \
|
||||
--pdfengines-disable-routes=$(PDFENGINES_DISABLE_ROUTES) \
|
||||
--prometheus-namespace=$(PROMETHEUS_NAMESPACE) \
|
||||
--prometheus-collect-interval=$(PROMETHEUS_COLLECT_INTERVAL) \
|
||||
--prometheus-disable-route-logging=$(PROMETHEUS_DISABLE_ROUTE_LOGGING) \
|
||||
--prometheus-disable-collect=$(PROMETHEUS_DISABLE_COLLECT) \
|
||||
--uno-listener-start-timeout=$(UNO_LISTENER_START_TIMEOUT) \
|
||||
--uno-listener-restart-threshold=$(UNO_LISTENER_RESTART_THRESHOLD) \
|
||||
--webhook-allow-list=$(WEBHOOK_ALLOW_LIST) \
|
||||
--webhook-deny-list=$(WEBHOOK_DENY_LIST) \
|
||||
--webhook-allow-list="$(WEBHOOK_ALLOW_LIST)" \
|
||||
--webhook-deny-list="$(WEBHOOK_DENY_LIST)" \
|
||||
--webhook-error-allow-list=$(WEBHOOK_ERROR_ALLOW_LIST) \
|
||||
--webhook-error-deny-list=$(WEBHOOK_ERROR_DENY_LIST) \
|
||||
--webhook-max-retry=$(WEBHOOK_MAX_RETRY) \
|
||||
@@ -118,38 +163,45 @@ run: ## Start a Gotenberg container
|
||||
build-tests: ## Build the tests' Docker image
|
||||
docker build \
|
||||
--build-arg GOLANG_VERSION=$(GOLANG_VERSION) \
|
||||
--build-arg DOCKER_REGISTRY=$(DOCKER_REGISTRY) \
|
||||
--build-arg DOCKER_REPOSITORY=$(DOCKER_REPOSITORY) \
|
||||
--build-arg GOTENBERG_VERSION=$(GOTENBERG_VERSION) \
|
||||
--build-arg GOLANGCI_LINT_VERSION=$(GOLANGCI_LINT_VERSION) \
|
||||
-t $(DOCKER_REPOSITORY)/gotenberg:$(GOTENBERG_VERSION)-tests \
|
||||
-t $(DOCKER_REGISTRY)/$(DOCKER_REPOSITORY):$(GOTENBERG_VERSION)-tests \
|
||||
-f test/Dockerfile .
|
||||
|
||||
.PHONY: tests
|
||||
tests: ## Start the testing environment
|
||||
docker run --rm -it \
|
||||
-v $(PWD):/tests \
|
||||
$(DOCKER_REPOSITORY)/gotenberg:$(GOTENBERG_VERSION)-tests \
|
||||
$(DOCKER_REGISTRY)/$(DOCKER_REPOSITORY):$(GOTENBERG_VERSION)-tests \
|
||||
bash
|
||||
|
||||
.PHONY: tests-once
|
||||
tests-once: ## Run the tests once (prefer the "tests" command while developing)
|
||||
docker run --rm \
|
||||
-v $(PWD):/tests \
|
||||
$(DOCKER_REPOSITORY)/gotenberg:$(GOTENBERG_VERSION)-tests \
|
||||
$(DOCKER_REGISTRY)/$(DOCKER_REPOSITORY):$(GOTENBERG_VERSION)-tests \
|
||||
gotest
|
||||
|
||||
# go install mvdan.cc/gofumpt@latest
|
||||
# go install github.com/daixiang0/gci@latest
|
||||
.PHONY: fmt
|
||||
fmt: ## Format the code and "optimize" the dependencies
|
||||
go fmt ./...
|
||||
gofumpt -l -w .
|
||||
gci write -s standard -s default -s "prefix(github.com/gotenberg/gotenberg/v8)" --skip-generated --skip-vendor --custom-order .
|
||||
go mod tidy
|
||||
|
||||
# go install golang.org/x/tools/cmd/godoc@latest
|
||||
.PHONY: godoc
|
||||
godoc: ## Run a webserver with Gotenberg godoc (go get golang.org/x/tools/cmd/godoc)
|
||||
$(info http://localhost:6060/pkg/github.com/gotenberg/gotenberg/v7)
|
||||
godoc: ## Run a webserver with Gotenberg godoc
|
||||
$(info http://localhost:6060/pkg/github.com/gotenberg/gotenberg/v8)
|
||||
godoc -http=:6060
|
||||
|
||||
LINUX_AMD64_RELEASE=false
|
||||
|
||||
.PHONY: release
|
||||
release: ## Build the Gotenberg's Docker image for many platforms, then push it to a Docker repository
|
||||
release: ## Build the Gotenberg's Docker image and push it to a Docker repository
|
||||
./scripts/release.sh \
|
||||
$(GOLANG_VERSION) \
|
||||
$(GOTENBERG_VERSION) \
|
||||
@@ -157,4 +209,8 @@ release: ## Build the Gotenberg's Docker image for many platforms, then push it
|
||||
$(GOTENBERG_USER_UID) \
|
||||
$(NOTO_COLOR_EMOJI_VERSION) \
|
||||
$(PDFTK_VERSION) \
|
||||
$(DOCKER_REPOSITORY)
|
||||
$(PDFCPU_VERSION) \
|
||||
$(DOCKER_REGISTRY) \
|
||||
$(DOCKER_REPOSITORY) \
|
||||
$(LINUX_AMD64_RELEASE)
|
||||
|
||||
|
||||
27
README.md
27
README.md
@@ -2,12 +2,20 @@
|
||||
<img src="https://user-images.githubusercontent.com/8983173/130322857-185831e2-f041-46eb-a17f-0a69d066c4e5.png" alt="Gotenberg Logo" width="150" height="150" />
|
||||
<h3 align="center">Gotenberg</h3>
|
||||
<p align="center">A Docker-powered stateless API for PDF files</p>
|
||||
<p align="center">
|
||||
<a href="https://hub.docker.com/r/gotenberg/gotenberg"><img alt="Total downloads (gotenberg/gotenberg)" src="https://img.shields.io/docker/pulls/gotenberg/gotenberg"></a>
|
||||
<a href="https://hub.docker.com/r/thecodingmachine/gotenberg"><img alt="Total downloads (thecodingmachine/gotenberg)" src="https://img.shields.io/docker/pulls/thecodingmachine/gotenberg"></a>
|
||||
<br>
|
||||
<a href="https://github.com/gotenberg/gotenberg/actions/workflows/continuous_integration.yml"><img alt="Continuous Integration" src="https://github.com/gotenberg/gotenberg/actions/workflows/continuous_integration.yml/badge.svg"></a>
|
||||
<a href="https://pkg.go.dev/github.com/gotenberg/gotenberg/v8"><img alt="Go Reference" src="https://pkg.go.dev/badge/github.com/gotenberg/gotenberg.svg"></a>
|
||||
<a href="https://codecov.io/gh/gotenberg/gotenberg/branch/main/graph/badge.svg"><img alt="Code coverage" src="https://codecov.io/gh/gotenberg/gotenberg/branch/main/graph/badge.svg"></a>
|
||||
</p>
|
||||
<p align="center"><a href="https://gotenberg.dev/docs/getting-started/introduction">Documentation</a> · <a href="https://gotenberg.dev/docs/getting-started/installation#live-demo-">Live Demo</a> 🔥</p>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
Gotenberg provides a developer-friendly API to interact with powerful tools like Chromium and LibreOffice for converting
|
||||
**Gotenberg** provides a developer-friendly API to interact with powerful tools like Chromium and LibreOffice for converting
|
||||
numerous document formats (HTML, Markdown, Word, Excel, etc.) into PDF files, and more!
|
||||
|
||||
## Quick Start
|
||||
@@ -15,13 +23,13 @@ numerous document formats (HTML, Markdown, Word, Excel, etc.) into PDF files, an
|
||||
Open a terminal and run the following command:
|
||||
|
||||
```
|
||||
docker run --rm -p 3000:3000 gotenberg/gotenberg:7
|
||||
docker run --rm -p 3000:3000 gotenberg/gotenberg:8
|
||||
```
|
||||
|
||||
Alternatively, using the historic Docker repository from our sponsor [TheCodingMachine](https://www.thecodingmachine.com):
|
||||
|
||||
```
|
||||
docker run --rm -p 3000:3000 thecodingmachine/gotenberg:7
|
||||
docker run --rm -p 3000:3000 thecodingmachine/gotenberg:8
|
||||
```
|
||||
|
||||
The API is now available on your host at http://localhost:3000.
|
||||
@@ -32,14 +40,11 @@ Head to the [documentation](https://gotenberg.dev/docs/getting-started/introduct
|
||||
|
||||
<p align="center">
|
||||
<a href="https://thecodingmachine.com">
|
||||
<img src="https://user-images.githubusercontent.com/8983173/130324668-9d6e7b35-53a3-49c7-a574-38190d2bd6b0.png" alt="TheCodingMachine Logo" width="429" height="210" />
|
||||
<img src="https://user-images.githubusercontent.com/8983173/130324668-9d6e7b35-53a3-49c7-a574-38190d2bd6b0.png" alt="TheCodingMachine Logo" width="333" height="163" />
|
||||
</a>
|
||||
<a href="https://zolsec.com?utm_source=gotenberg_github&utm_medium=website" target="_blank">
|
||||
<img src="https://github.com/gotenberg/gotenberg/assets/8983173/707ccc97-a79b-4dcb-8fc8-6827366e5be3" alt="Zolsec Logo" width="333" height="163" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
## Badges
|
||||
|
||||
[](https://hub.docker.com/r/gotenberg/gotenberg)
|
||||
[](https://hub.docker.com/r/thecodingmachine/gotenberg)
|
||||
[](https://github.com/gotenberg/gotenberg/actions/workflows/continuous_integration.yml)
|
||||
[](https://pkg.go.dev/github.com/gotenberg/gotenberg/v7)
|
||||
[](https://codecov.io/gh/gotenberg/gotenberg)
|
||||
Sponsorships help maintaining and improving Gotenberg - [become a sponsor](https://github.com/sponsors/gulien) ❤️
|
||||
|
||||
104
build/Dockerfile
104
build/Dockerfile
@@ -2,15 +2,39 @@
|
||||
# concatenate them. Also, we have to repeat ARG instructions in each build
|
||||
# stage that uses them.
|
||||
ARG GOLANG_VERSION
|
||||
ARG GOTENBERG_VERSION
|
||||
|
||||
# ----------------------------------------------
|
||||
# pdfcpu binary build stage
|
||||
# ----------------------------------------------
|
||||
# Note: this stage is required as pdfcpu does not release an armhf variant by
|
||||
# default.
|
||||
|
||||
FROM golang:$GOLANG_VERSION AS pdfcpu-binary-stage
|
||||
|
||||
ARG PDFCPU_VERSION
|
||||
ENV CGO_ENABLED=0
|
||||
|
||||
# Define the working directory outside of $GOPATH (we're using go modules).
|
||||
WORKDIR /home
|
||||
|
||||
RUN curl -Ls "https://github.com/pdfcpu/pdfcpu/archive/refs/tags/$PDFCPU_VERSION.tar.gz" -o pdfcpu.tar.gz &&\
|
||||
tar --strip-components=1 -xvzf pdfcpu.tar.gz
|
||||
|
||||
# Install module dependencies.
|
||||
RUN go mod download &&\
|
||||
go mod verify
|
||||
|
||||
RUN go build -o pdfcpu -ldflags "-s -w -X 'main.version=$PDFCPU_VERSION' -X 'github.com/pdfcpu/pdfcpu/pkg/pdfcpu.VersionStr=$PDFCPU_VERSION' -X main.builtBy=gotenberg" ./cmd/pdfcpu &&\
|
||||
# Verify installation.
|
||||
./pdfcpu version
|
||||
|
||||
# ----------------------------------------------
|
||||
# Gotenberg binary build stage
|
||||
# ----------------------------------------------
|
||||
FROM golang:$GOLANG_VERSION AS binary-stage
|
||||
FROM golang:$GOLANG_VERSION AS gotenberg-binary-stage
|
||||
|
||||
ARG GOTENBERG_VERSION
|
||||
ENV CGO_ENABLED 0
|
||||
ENV CGO_ENABLED=0
|
||||
|
||||
# Define the working directory outside of $GOPATH (we're using go modules).
|
||||
WORKDIR /home
|
||||
@@ -25,24 +49,26 @@ RUN go mod download &&\
|
||||
COPY cmd ./cmd
|
||||
COPY pkg ./pkg
|
||||
|
||||
RUN go build -o gotenberg -ldflags "-X 'github.com/gotenberg/gotenberg/v7/cmd.Version=$GOTENBERG_VERSION'" cmd/gotenberg/main.go
|
||||
RUN go build -o gotenberg -ldflags "-X 'github.com/gotenberg/gotenberg/v8/cmd.Version=$GOTENBERG_VERSION'" cmd/gotenberg/main.go
|
||||
|
||||
# ----------------------------------------------
|
||||
# Final stage
|
||||
# ----------------------------------------------
|
||||
FROM debian:11-slim
|
||||
FROM debian:12-slim
|
||||
|
||||
ARG GOTENBERG_VERSION
|
||||
ARG GOTENBERG_USER_GID
|
||||
ARG GOTENBERG_USER_UID
|
||||
ARG NOTO_COLOR_EMOJI_VERSION
|
||||
ARG PDFTK_VERSION
|
||||
ARG TMP_CHOMIUM_VERSION_ARMHF="116.0.5845.180-1~deb12u1"
|
||||
|
||||
LABEL author="Julien Neuhart" \
|
||||
description="A Docker-powered stateless API for PDF files." \
|
||||
github="https://github.com/gotenberg/gotenberg" \
|
||||
version="$GOTENBERG_VERSION" \
|
||||
website="https://gotenberg.dev"
|
||||
LABEL org.opencontainers.image.title="Gotenberg" \
|
||||
org.opencontainers.image.description="A Docker-powered stateless API for PDF files." \
|
||||
org.opencontainers.image.version="$GOTENBERG_VERSION" \
|
||||
org.opencontainers.image.authors="Julien Neuhart <neuhart.julien@gmail.com>" \
|
||||
org.opencontainers.image.documentation="https://gotenberg.dev" \
|
||||
org.opencontainers.image.source="https://github.com/gotenberg/gotenberg"
|
||||
|
||||
RUN \
|
||||
# Create a non-root user.
|
||||
@@ -56,8 +82,7 @@ RUN \
|
||||
# Install system dependencies required for the next instructions or debugging.
|
||||
# Note: tini is a helper for reaping zombie processes.
|
||||
apt-get update -qq &&\
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends curl gnupg htop tini python3 default-jre-headless &&\
|
||||
ln -s /usr/bin/htop /usr/bin/top &&\
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends curl gnupg tini python3 default-jre-headless &&\
|
||||
# Cleanup.
|
||||
# Note: the Debian image does automatically a clean after each install thanks to a hook.
|
||||
# Therefore, there is no need for apt-get clean.
|
||||
@@ -69,10 +94,10 @@ RUN \
|
||||
# Credits:
|
||||
# https://github.com/arachnys/athenapdf/blob/master/cli/Dockerfile.
|
||||
# https://help.accusoft.com/PrizmDoc/v12.1/HTML/Installing_Asian_Fonts_on_Ubuntu_and_Debian.html.
|
||||
curl -o ./ttf-mscorefonts-installer_3.8_all.deb http://httpredir.debian.org/debian/pool/contrib/m/msttcorefonts/ttf-mscorefonts-installer_3.8_all.deb &&\
|
||||
curl -o ./ttf-mscorefonts-installer_3.8.1_all.deb http://httpredir.debian.org/debian/pool/contrib/m/msttcorefonts/ttf-mscorefonts-installer_3.8.1_all.deb &&\
|
||||
apt-get update -qq &&\
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \
|
||||
./ttf-mscorefonts-installer_3.8_all.deb \
|
||||
./ttf-mscorefonts-installer_3.8.1_all.deb \
|
||||
culmus \
|
||||
fonts-beng \
|
||||
fonts-hosny-amiri \
|
||||
@@ -107,7 +132,7 @@ RUN \
|
||||
fonts-noto-ui-core \
|
||||
fonts-sil-gentium \
|
||||
fonts-sil-gentium-basic &&\
|
||||
rm -f ./ttf-mscorefonts-installer_3.8_all.deb &&\
|
||||
rm -f ./ttf-mscorefonts-installer_3.8.1_all.deb &&\
|
||||
# Add Color and Black-and-White Noto emoji font.
|
||||
# Credits:
|
||||
# https://github.com/gotenberg/gotenberg/pull/325.
|
||||
@@ -120,6 +145,9 @@ RUN \
|
||||
# Install either Google Chrome stable on amd64 architecture or
|
||||
# Chromium on other architectures.
|
||||
# See https://github.com/gotenberg/gotenberg/issues/328.
|
||||
# FIXME:
|
||||
# armhf is currently not working with the latest version of Chromium.
|
||||
# See: https://github.com/gotenberg/gotenberg/issues/709.
|
||||
/bin/bash -c \
|
||||
'set -e &&\
|
||||
if [[ "$(dpkg --print-architecture)" == "amd64" ]]; then \
|
||||
@@ -128,6 +156,14 @@ RUN \
|
||||
apt-get update -qq &&\
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends --allow-unauthenticated google-chrome-stable &&\
|
||||
mv /usr/bin/google-chrome-stable /usr/bin/chromium; \
|
||||
elif [[ "$(dpkg --print-architecture)" == "armhf" ]]; then \
|
||||
apt-get update -qq &&\
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends devscripts &&\
|
||||
debsnap chromium-common "$TMP_CHOMIUM_VERSION_ARMHF" -v --force --binary --architecture armhf &&\
|
||||
debsnap chromium "$TMP_CHOMIUM_VERSION_ARMHF" -v --force --binary --architecture armhf &&\
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install --fix-broken -y -qq --no-install-recommends "./binary-chromium-common/chromium-common_${TMP_CHOMIUM_VERSION_ARMHF}_armhf.deb" "./binary-chromium/chromium_${TMP_CHOMIUM_VERSION_ARMHF}_armhf.deb" &&\
|
||||
DEBIAN_FRONTEND=noninteractive apt-get purge -y -qq devscripts &&\
|
||||
rm -rf ./binary-chromium-common/* ./binary-chromium/*; \
|
||||
else \
|
||||
apt-get update -qq &&\
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends chromium; \
|
||||
@@ -138,33 +174,35 @@ RUN \
|
||||
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
|
||||
RUN \
|
||||
# Install LibreOffice & unoconv.
|
||||
# Install LibreOffice & unoconverter.
|
||||
echo "deb http://deb.debian.org/debian bookworm-backports main" >> /etc/apt/sources.list &&\
|
||||
apt-get update -qq &&\
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends libreoffice &&\
|
||||
curl -Ls https://raw.githubusercontent.com/dagwieers/unoconv/master/unoconv -o /usr/bin/unoconv &&\
|
||||
chmod +x /usr/bin/unoconv &&\
|
||||
# unoconv will look for the Python binary, which has to be at version 3.
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends -t bookworm-backports libreoffice &&\
|
||||
curl -Ls https://raw.githubusercontent.com/gotenberg/unoconverter/v0.0.1/unoconv -o /usr/bin/unoconverter &&\
|
||||
chmod +x /usr/bin/unoconverter &&\
|
||||
# unoconverter will look for the Python binary, which has to be at version 3.
|
||||
ln -s /usr/bin/python3 /usr/bin/python &&\
|
||||
# Verify installations.
|
||||
libreoffice --version &&\
|
||||
unoconv --version &&\
|
||||
unoconverter --version &&\
|
||||
# Cleanup.
|
||||
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
|
||||
RUN \
|
||||
# Install PDFtk & QPDF (PDF engines).
|
||||
# Install PDFtk, QPDF & ExifTool (PDF engines).
|
||||
# See https://github.com/gotenberg/gotenberg/pull/273.
|
||||
curl -o /usr/bin/pdftk-all.jar "https://gitlab.com/api/v4/projects/5024297/packages/generic/pdftk-java/$PDFTK_VERSION/pdftk-all.jar" &&\
|
||||
chmod a+x /usr/bin/pdftk-all.jar &&\
|
||||
echo '#!/bin/bash\n\nexec java -jar /usr/bin/pdftk-all.jar "$@"' > /usr/bin/pdftk && \
|
||||
chmod +x /usr/bin/pdftk &&\
|
||||
apt-get update -qq &&\
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends qpdf &&\
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends qpdf exiftool &&\
|
||||
# See https://github.com/nextcloud/docker/issues/380.
|
||||
mkdir -p /usr/share/man/man1 &&\
|
||||
# Verify installations.
|
||||
pdftk --version &&\
|
||||
qpdf --version &&\
|
||||
exiftool --version &&\
|
||||
# Cleanup.
|
||||
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
|
||||
@@ -174,16 +212,20 @@ RUN \
|
||||
# https://github.com/arachnys/athenapdf/commit/ba25a8d80a25d08d58865519c4cd8756dc9a336d.
|
||||
COPY build/fonts.conf /etc/fonts/conf.d/100-gotenberg.conf
|
||||
|
||||
# Copy the Gotenberg binary from the binary stage.
|
||||
COPY --from=binary-stage /home/gotenberg /usr/bin/
|
||||
# Copy the pdfcpu binary from the pdfcpu-binary-stage.
|
||||
COPY --from=pdfcpu-binary-stage /home/pdfcpu /usr/bin/
|
||||
|
||||
# Copy the Gotenberg binary from the gotenberg-binary-stage.
|
||||
COPY --from=gotenberg-binary-stage /home/gotenberg /usr/bin/
|
||||
|
||||
# Environment variables required by modules or else.
|
||||
ENV GC_EXCLUDE_SUBSTR "hsperfdata_root,hsperfdata_gotenberg"
|
||||
ENV CHROMIUM_BIN_PATH /usr/bin/chromium
|
||||
ENV UNOCONV_BIN_PATH /usr/bin/unoconv
|
||||
ENV LIBREOFFICE_BIN_PATH /usr/lib/libreoffice/program/soffice.bin
|
||||
ENV PDFTK_BIN_PATH /usr/bin/pdftk
|
||||
ENV QPDF_BIN_PATH /usr/bin/qpdf
|
||||
ENV CHROMIUM_BIN_PATH=/usr/bin/chromium
|
||||
ENV LIBREOFFICE_BIN_PATH=/usr/lib/libreoffice/program/soffice.bin
|
||||
ENV UNOCONVERTER_BIN_PATH=/usr/bin/unoconverter
|
||||
ENV PDFTK_BIN_PATH=/usr/bin/pdftk
|
||||
ENV QPDF_BIN_PATH=/usr/bin/qpdf
|
||||
ENV EXIFTOOL_BIN_PATH=/usr/bin/exiftool
|
||||
ENV PDFCPU_BIN_PATH=/usr/bin/pdfcpu
|
||||
|
||||
USER gotenberg
|
||||
WORKDIR /home/gotenberg
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
ARG DOCKER_REGISTRY
|
||||
ARG DOCKER_REPOSITORY
|
||||
ARG GOTENBERG_VERSION
|
||||
|
||||
FROM $DOCKER_REPOSITORY/gotenberg:$GOTENBERG_VERSION
|
||||
FROM $DOCKER_REGISTRY/$DOCKER_REPOSITORY:$GOTENBERG_VERSION
|
||||
|
||||
USER root
|
||||
|
||||
|
||||
@@ -8,9 +8,10 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
||||
flag "github.com/spf13/pflag"
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
|
||||
// See https://patorjk.com/software/taag/#p=display&f=Small%20Slant&t=Gotenberg.
|
||||
@@ -34,6 +35,7 @@ var Version = "snapshot"
|
||||
// Run starts the Gotenberg application. Call this in the main of your program.
|
||||
func Run() {
|
||||
fmt.Printf(banner, Version)
|
||||
gotenberg.Version = Version
|
||||
|
||||
// Create the root FlagSet and adds the modules flags to it.
|
||||
fs := flag.NewFlagSet("gotenberg", flag.ExitOnError)
|
||||
@@ -74,7 +76,6 @@ func Run() {
|
||||
go func(app gotenberg.App) {
|
||||
id := app.(gotenberg.Module).Descriptor().ID
|
||||
err = app.Start()
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf("[FATAL] starting %s: %s\n", id, err)
|
||||
os.Exit(1)
|
||||
@@ -83,7 +84,6 @@ func Run() {
|
||||
startupMessage := app.StartupMessage()
|
||||
if startupMessage == "" {
|
||||
fmt.Printf("[SYSTEM] %s: application started\n", id)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -143,7 +143,6 @@ func Run() {
|
||||
}
|
||||
|
||||
fmt.Printf("[SYSTEM] %s: application stopped\n", id)
|
||||
|
||||
return nil
|
||||
}
|
||||
}(a.(gotenberg.App)))
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
gotenbergcmd "github.com/gotenberg/gotenberg/v7/cmd"
|
||||
|
||||
gotenbergcmd "github.com/gotenberg/gotenberg/v8/cmd"
|
||||
// Gotenberg modules.
|
||||
_ "github.com/gotenberg/gotenberg/v7/pkg/standard"
|
||||
_ "github.com/gotenberg/gotenberg/v8/pkg/standard"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
||||
@@ -1,744 +0,0 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Gotenberg
|
||||
version: 7.x
|
||||
license:
|
||||
name: MIT
|
||||
url: 'https://github.com/gotenberg/gotenberg/blob/main/LICENSE'
|
||||
contact:
|
||||
url: 'https://github.com/gotenberg/gotenberg'
|
||||
description: >-
|
||||
A Docker-powered stateless API for PDF files.
|
||||
externalDocs:
|
||||
url: https://gotenberg.dev
|
||||
servers:
|
||||
- url: 'http://localhost:3000'
|
||||
description: Local server with the default Docker image and port
|
||||
tags:
|
||||
- name: chromium
|
||||
description: Operations of the Chromium module
|
||||
externalDocs:
|
||||
url: https://gotenberg.dev/docs/modules/chromium
|
||||
- name: libreoffice
|
||||
description: Operations of the Libreoffice module
|
||||
externalDocs:
|
||||
url: https://gotenberg.dev/docs/modules/libreoffice
|
||||
- name: pdfengines
|
||||
description: Operations of the PDF Engines module
|
||||
externalDocs:
|
||||
url: https://gotenberg.dev/docs/modules/pdf-engines
|
||||
paths:
|
||||
/forms/chromium/convert/url:
|
||||
post:
|
||||
tags:
|
||||
- chromium
|
||||
summary: Convert the contents of a given URL to PDF
|
||||
externalDocs:
|
||||
url: https://gotenberg.dev/docs/modules/chromium
|
||||
description: >-
|
||||
Send a URL in your API request via the `url` form field
|
||||
Send a remote URL in your API request via the `remoteURL` parameter, and
|
||||
get the resulting PDF file. The API will fetch the given URL and render
|
||||
the page to PDF using the underlying headless Chrome instance.
|
||||
You can optionally include `header.html` and `footer.html` files as part of the request as well.
|
||||
See externalDocs for more details.
|
||||
parameters:
|
||||
- in: header
|
||||
name: Gotenberg-Output-Filename
|
||||
description: >-
|
||||
By default, the API generates a UUID filename.
|
||||
However, you may also specify the filename per request,
|
||||
thanks to the Gotenberg-Output-Filename header.
|
||||
Caution! The API adds the file extension automatically; you don't have to set it.
|
||||
schema:
|
||||
type: string
|
||||
required: false
|
||||
- in: header
|
||||
name: Gotenberg-Trace
|
||||
description: >-
|
||||
The trace, or request ID, identifies a request in the logs.
|
||||
|
||||
By default, the API generates a UUID trace for each request.
|
||||
However, you may also specify the trace per request, thanks to the Gotenberg-Trace header.
|
||||
schema:
|
||||
type: string
|
||||
required: false
|
||||
requestBody:
|
||||
required: true
|
||||
description: >-
|
||||
The request must be `multipart/form-data` that includes a `url` form field.
|
||||
The API uses a headless Chrome instance to do the conversion, therefore print
|
||||
parameter such as margins and paper size are also accepted as optional parameters.
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
$ref: '#/components/schemas/URLConvertRequestBody'
|
||||
examples: { }
|
||||
responses:
|
||||
'200':
|
||||
$ref: '#/components/responses/SuccessfulPDF'
|
||||
'400':
|
||||
description: Bad Request
|
||||
'503':
|
||||
$ref: '#/components/responses/Timeout'
|
||||
|
||||
/forms/chromium/convert/html:
|
||||
post:
|
||||
tags:
|
||||
- chromium
|
||||
summary: Convert a given HTML file to PDF
|
||||
externalDocs:
|
||||
url: https://gotenberg.dev/docs/modules/chromium
|
||||
description: >-
|
||||
Send an HTML file called `index.html` as a multipart form request, and
|
||||
get the resulting PDF file. You can optionally include `header.html` and
|
||||
`footer.html` files as part of the request as well.
|
||||
See externalDocs for more details.
|
||||
parameters:
|
||||
- in: header
|
||||
name: Gotenberg-Output-Filename
|
||||
description: >-
|
||||
By default, the API generates a UUID filename.
|
||||
However, you may also specify the filename per request,
|
||||
thanks to the Gotenberg-Output-Filename header.
|
||||
Caution! The API adds the file extension automatically; you don't have to set it.
|
||||
schema:
|
||||
type: string
|
||||
required: false
|
||||
- in: header
|
||||
name: Gotenberg-Trace
|
||||
description: >-
|
||||
The trace, or request ID, identifies a request in the logs.
|
||||
|
||||
By default, the API generates a UUID trace for each request.
|
||||
However, you may also specify the trace per request, thanks to the Gotenberg-Trace header.
|
||||
schema:
|
||||
type: string
|
||||
required: false
|
||||
requestBody:
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HTMLConvertRequestBody'
|
||||
description: >-
|
||||
The request body must have an `index.html` file in the `files` array,
|
||||
as well as all the referred resources on the same level as the
|
||||
`index.html` file. The request can also include `header.html` and
|
||||
`footer.html`, given the limitations in the API description above.
|
||||
responses:
|
||||
'200':
|
||||
$ref: '#/components/responses/SuccessfulPDF'
|
||||
'400':
|
||||
description: Bad Request
|
||||
'503':
|
||||
$ref: '#/components/responses/Timeout'
|
||||
|
||||
/forms/chromium/convert/markdown:
|
||||
post:
|
||||
tags:
|
||||
- chromium
|
||||
summary: Convert a Markdown file to PDF
|
||||
externalDocs:
|
||||
url: https://gotenberg.dev/docs/modules/chromium
|
||||
description: >-
|
||||
Accepts an HTML file called `index.html` plus markdown files as a multipart
|
||||
form request and embeds the markdown files into the HTML file using the Golang template
|
||||
function `toHTML`.
|
||||
The API will convert the markdown to HTML and embed it into your `index.html` file,
|
||||
then render the resulting page. You can include your own styling and more in your HTML file.
|
||||
Refer to the HTML conversion page for all the options you can use when converting
|
||||
Markdown documents as well. You can optionally include `header.html` and
|
||||
`footer.html` files as part of the request as well.
|
||||
See externalDocs for more details.
|
||||
parameters:
|
||||
- in: header
|
||||
name: Gotenberg-Output-Filename
|
||||
description: >-
|
||||
By default, the API generates a UUID filename.
|
||||
However, you may also specify the filename per request,
|
||||
thanks to the Gotenberg-Output-Filename header.
|
||||
Caution! The API adds the file extension automatically; you don't have to set it.
|
||||
schema:
|
||||
type: string
|
||||
required: false
|
||||
- in: header
|
||||
name: Gotenberg-Trace
|
||||
description: >-
|
||||
The trace, or request ID, identifies a request in the logs.
|
||||
|
||||
By default, the API generates a UUID trace for each request.
|
||||
However, you may also specify the trace per request, thanks to the Gotenberg-Trace header.
|
||||
schema:
|
||||
type: string
|
||||
required: false
|
||||
requestBody:
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MarkdownConvertRequestBody'
|
||||
description: >-
|
||||
The request body must have an `index.html` file in the `files` array,
|
||||
as well as all the referred markdown resources on the same level as
|
||||
the `index.html` file.
|
||||
responses:
|
||||
'200':
|
||||
$ref: '#/components/responses/SuccessfulPDF'
|
||||
'400':
|
||||
description: Bad Request
|
||||
'503':
|
||||
$ref: '#/components/responses/Timeout'
|
||||
|
||||
/forms/libreoffice/convert:
|
||||
post:
|
||||
tags:
|
||||
- libreoffice
|
||||
summary: Convert an Office document to PDF
|
||||
externalDocs:
|
||||
url: https://gotenberg.dev/docs/modules/libreoffice
|
||||
description: >-
|
||||
This route accepts multipart/form-data requests and files with the following extensions:
|
||||
|
||||
.bib .doc .xml .docx .fodt .html .ltx .txt .odt .ott .pdb .pdf .psw .rtf
|
||||
.sdw .stw .sxw .uot .vor .wps .epub .png .bmp .emf .eps .fodg .gif .jpg
|
||||
.met .odd .otg .pbm .pct .pgm .ppm .ras .std .svg .svm .swf .sxd .sxw
|
||||
.tiff .xhtml .xpm .fodp .potm .pot .pptx .pps .ppt .pwp .sda .sdd .sti
|
||||
.sxi .uop .wmf .csv .dbf .dif .fods .ods .ots .pxl .sdc .slk .stc .sxc
|
||||
.uos .xls .xlt .xlsx .tif .jpeg .odp
|
||||
|
||||
By default, if you send more than one file to convert, the route returns a ZIP archive of the
|
||||
resulting PDF files. However, you may prefer to merge all the PDF files into an individual PDF file.
|
||||
|
||||
> **Attention:** The files will be merged alphabetically for the
|
||||
resulting PDF.
|
||||
|
||||
You may also specify the page ranges to convert from the incoming Office
|
||||
documents. The expected format is the same as the one from the print
|
||||
options of LibreOffice, e.g. `1-1` or `1-4`.
|
||||
|
||||
> **Attention:** if more than one document, the page ranges will be
|
||||
applied for each document.
|
||||
|
||||
See externalDocs for more details.
|
||||
parameters:
|
||||
- in: header
|
||||
name: Gotenberg-Output-Filename
|
||||
description: >-
|
||||
By default, the API generates a UUID filename.
|
||||
However, you may also specify the filename per request,
|
||||
thanks to the Gotenberg-Output-Filename header.
|
||||
Caution! The API adds the file extension automatically; you don't have to set it.
|
||||
schema:
|
||||
type: string
|
||||
required: false
|
||||
- in: header
|
||||
name: Gotenberg-Trace
|
||||
description: >-
|
||||
The trace, or request ID, identifies a request in the logs.
|
||||
|
||||
By default, the API generates a UUID trace for each request.
|
||||
However, you may also specify the trace per request, thanks to the Gotenberg-Trace header.
|
||||
schema:
|
||||
type: string
|
||||
required: false
|
||||
requestBody:
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
$ref: '#/components/schemas/OfficeConvertRequestBody'
|
||||
responses:
|
||||
'200':
|
||||
$ref: '#/components/responses/SuccessfulPDF'
|
||||
'400':
|
||||
description: Bad Request, e.g. Both 'pdfFormat' and 'nativePdfA1aFormat' form values are provided
|
||||
'503':
|
||||
$ref: '#/components/responses/Timeout'
|
||||
|
||||
/forms/pdfengines/merge:
|
||||
post:
|
||||
tags:
|
||||
- pdfengines
|
||||
summary: Merge multiple PDFs into a single PDF
|
||||
externalDocs:
|
||||
url: https://gotenberg.dev/docs/modules/pdf-engines
|
||||
description: >-
|
||||
You can send multiple PDF files to this endpoint, the API will merge
|
||||
them into a single PDF and return the resulting PDF file.
|
||||
|
||||
> **Attention:** The PDF files will be merged alphabetically.
|
||||
parameters:
|
||||
- in: header
|
||||
name: Gotenberg-Output-Filename
|
||||
description: >-
|
||||
By default, the API generates a UUID filename.
|
||||
However, you may also specify the filename per request,
|
||||
thanks to the Gotenberg-Output-Filename header.
|
||||
Caution! The API adds the file extension automatically; you don't have to set it.
|
||||
schema:
|
||||
type: string
|
||||
required: false
|
||||
- in: header
|
||||
name: Gotenberg-Trace
|
||||
description: >-
|
||||
The trace, or request ID, identifies a request in the logs.
|
||||
|
||||
By default, the API generates a UUID trace for each request.
|
||||
However, you may also specify the trace per request, thanks to the Gotenberg-Trace header.
|
||||
schema:
|
||||
type: string
|
||||
required: false
|
||||
requestBody:
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
files:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
format: binary
|
||||
pdfFormat:
|
||||
type: string
|
||||
description: The PDF format of the resulting PDF
|
||||
example: PDF/A-1a
|
||||
required:
|
||||
- files
|
||||
responses:
|
||||
'200':
|
||||
$ref: '#/components/responses/SuccessfulPDF'
|
||||
'400':
|
||||
description: Bad Request
|
||||
'503':
|
||||
$ref: '#/components/responses/Timeout'
|
||||
|
||||
/forms/pdfengines/convert:
|
||||
post:
|
||||
tags:
|
||||
- pdfengines
|
||||
summary: Convert PDFs into the given formats
|
||||
externalDocs:
|
||||
url: https://gotenberg.dev/docs/modules/pdf-engines
|
||||
description: >-
|
||||
This route accepts PDF files and a form field pdfFormat for converting them into the specified format.
|
||||
parameters:
|
||||
- in: header
|
||||
name: Gotenberg-Output-Filename
|
||||
description: >-
|
||||
By default, the API generates a UUID filename.
|
||||
However, you may also specify the filename per request,
|
||||
thanks to the Gotenberg-Output-Filename header.
|
||||
Caution! The API adds the file extension automatically; you don't have to set it.
|
||||
schema:
|
||||
type: string
|
||||
required: false
|
||||
- in: header
|
||||
name: Gotenberg-Trace
|
||||
description: >-
|
||||
The trace, or request ID, identifies a request in the logs.
|
||||
|
||||
By default, the API generates a UUID trace for each request.
|
||||
However, you may also specify the trace per request, thanks to the Gotenberg-Trace header.
|
||||
schema:
|
||||
type: string
|
||||
required: false
|
||||
requestBody:
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
files:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
format: binary
|
||||
pdfFormat:
|
||||
type: string
|
||||
description: The PDF format of the resulting PDF
|
||||
example: PDF/A-1a
|
||||
required:
|
||||
- files
|
||||
- pdfFormat
|
||||
responses:
|
||||
'200':
|
||||
$ref: '#/components/responses/SuccessfulPDF'
|
||||
'400':
|
||||
description: >-
|
||||
Bad Request, e.g. Invalid form data: no form file found for extensions: [.pdf]; form value 'pdfFormat' is required
|
||||
'503':
|
||||
$ref: '#/components/responses/Timeout'
|
||||
|
||||
components:
|
||||
schemas:
|
||||
HTMLConvertRequestBody:
|
||||
title: HTML Conversion Request Body
|
||||
type: object
|
||||
properties:
|
||||
files:
|
||||
type: array
|
||||
description: >-
|
||||
List of HTML files to be converted to PDF. An `index.html` file is
|
||||
required, and any other resources that are referenced through the
|
||||
HTML file must be included as well. All the referenced files must be
|
||||
on the same level as the `index.html` file.
|
||||
items:
|
||||
type: string
|
||||
format: binary
|
||||
marginTop:
|
||||
type: number
|
||||
example: 0
|
||||
default: 1
|
||||
description: Top margin for the page in inches.
|
||||
marginBottom:
|
||||
type: number
|
||||
example: 0
|
||||
default: 1
|
||||
description: Bottom margin for the page in inches.
|
||||
marginLeft:
|
||||
type: number
|
||||
example: 0
|
||||
default: 1
|
||||
description: Left margin for the page in inches.
|
||||
marginRight:
|
||||
type: number
|
||||
example: 0
|
||||
default: 1
|
||||
description: Right margin for the page in inches.
|
||||
paperWidth:
|
||||
type: number
|
||||
example: 8.27
|
||||
description: >-
|
||||
Paper width to be used while rendering the PDF. The default page
|
||||
size is A4.
|
||||
paperHeight:
|
||||
type: number
|
||||
example: 11.69
|
||||
description: >-
|
||||
Paper height to be used while rendering the PDF. The default page
|
||||
size is A4.
|
||||
preferCssPageSize:
|
||||
type: boolean
|
||||
description: >-
|
||||
Define whether to prefer page size as defined by CSS (default false)
|
||||
default: false
|
||||
printBackground:
|
||||
type: boolean
|
||||
description: >-
|
||||
Print the background graphics (default false)
|
||||
default: false
|
||||
landscape:
|
||||
type: boolean
|
||||
example: true
|
||||
default: false
|
||||
description: >-
|
||||
The default orientation for rendering the page is "portrait" mode.
|
||||
By sending "landscape" parameter, you can ask the output to be
|
||||
landscape.
|
||||
scale:
|
||||
type: number
|
||||
minimum: 0.1
|
||||
maximum: 2.0
|
||||
example: 1.5
|
||||
description: >-
|
||||
The scale of the page rendering
|
||||
default: 1.0
|
||||
waitDelay:
|
||||
type: string
|
||||
example: 5s
|
||||
description: >-
|
||||
When the page relies on JavaScript for rendering, and you don't have access to the page's code,
|
||||
you may want to wait a certain amount of time to make sure Chromium has fully rendered the page
|
||||
you're trying to generate.
|
||||
waitWindowStatus:
|
||||
type: string
|
||||
example: done
|
||||
description: >-
|
||||
If you have access to the page's code, you may set the window status and tell Gotenberg to wait for a specific value.
|
||||
For instance
|
||||
await promises()
|
||||
window.status = 'ready'
|
||||
Prefer this option over waitDelay.
|
||||
extraHttpHeaders:
|
||||
type: string
|
||||
description: HTTP headers to send by Chromium while loading the HTML document (JSON format)
|
||||
nativePageRanges:
|
||||
type: string
|
||||
example: 1-4
|
||||
description: >-
|
||||
The page ranges to be converted to PDF for the incoming Office
|
||||
documents.
|
||||
pdfFormat:
|
||||
type: string
|
||||
description: >-
|
||||
The PDF format of the resulting PDF.
|
||||
Caution! You cannot use both nativePdfA1aFormat and pdfFormat form fields.
|
||||
example: PDF/A-1a
|
||||
required:
|
||||
- files
|
||||
MarkdownConvertRequestBody:
|
||||
title: Markdown Conversion Request Body
|
||||
type: object
|
||||
properties:
|
||||
files:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
format: binary
|
||||
marginTop:
|
||||
type: number
|
||||
example: 0
|
||||
default: 1
|
||||
description: Top margin for the page in inches.
|
||||
marginBottom:
|
||||
type: number
|
||||
example: 0
|
||||
default: 1
|
||||
description: Bottom margin for the page in inches.
|
||||
marginLeft:
|
||||
type: number
|
||||
example: 0
|
||||
default: 1
|
||||
description: Left margin for the page in inches.
|
||||
marginRight:
|
||||
type: number
|
||||
example: 0
|
||||
default: 1
|
||||
description: Right margin for the page in inches.
|
||||
paperWidth:
|
||||
type: number
|
||||
example: 8.27
|
||||
description: >-
|
||||
Paper width to be used while rendering the PDF. The default page
|
||||
size is A4.
|
||||
paperHeight:
|
||||
type: number
|
||||
example: 11.69
|
||||
description: >-
|
||||
Paper height to be used while rendering the PDF. The default page
|
||||
size is A4.
|
||||
preferCssPageSize:
|
||||
type: boolean
|
||||
description: >-
|
||||
Define whether to prefer page size as defined by CSS (default false)
|
||||
default: false
|
||||
printBackground:
|
||||
type: boolean
|
||||
description: >-
|
||||
Print the background graphics (default false)
|
||||
default: false
|
||||
landscape:
|
||||
type: boolean
|
||||
example: true
|
||||
default: false
|
||||
description: >-
|
||||
The default orientation for rendering the page is "portrait" mode.
|
||||
By sending "landscape" parameter, you can ask the output to be
|
||||
landscape.
|
||||
scale:
|
||||
type: number
|
||||
minimum: 0.1
|
||||
maximum: 2.0
|
||||
example: 1.5
|
||||
description: >-
|
||||
The scale of the page rendering
|
||||
default: 1.0
|
||||
waitDelay:
|
||||
type: string
|
||||
example: 5s
|
||||
description: >-
|
||||
When the page relies on JavaScript for rendering, and you don't have access to the page's code,
|
||||
you may want to wait a certain amount of time to make sure Chromium has fully rendered the page
|
||||
you're trying to generate.
|
||||
waitWindowStatus:
|
||||
type: string
|
||||
example: done
|
||||
description: >-
|
||||
If you have access to the page's code, you may set the window status and tell Gotenberg to wait for a specific value.
|
||||
For instance
|
||||
await promises()
|
||||
window.status = 'ready'
|
||||
Prefer this option over waitDelay.
|
||||
extraHttpHeaders:
|
||||
type: string
|
||||
description: HTTP headers to send by Chromium while loading the HTML document (JSON format)
|
||||
nativePageRanges:
|
||||
type: string
|
||||
example: 1-4
|
||||
description: >-
|
||||
The page ranges to be converted to PDF for the incoming Office
|
||||
documents.
|
||||
pdfFormat:
|
||||
type: string
|
||||
description: >-
|
||||
The PDF format of the resulting PDF.
|
||||
Caution! You cannot use both nativePdfA1aFormat and pdfFormat form fields.
|
||||
example: PDF/A-1a
|
||||
required:
|
||||
- files
|
||||
URLConvertRequestBody:
|
||||
title: URL Conversion Request Body
|
||||
type: object
|
||||
properties:
|
||||
url:
|
||||
type: string
|
||||
example: 'https://google.com'
|
||||
files:
|
||||
description: Optional files named header.html and footer.html
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
format: binary
|
||||
marginTop:
|
||||
type: number
|
||||
example: 0
|
||||
default: 1
|
||||
description: Top margin for the page in inches.
|
||||
marginBottom:
|
||||
type: number
|
||||
example: 0
|
||||
default: 1
|
||||
description: Bottom margin for the page in inches.
|
||||
marginLeft:
|
||||
type: number
|
||||
example: 0
|
||||
default: 1
|
||||
description: Left margin for the page in inches.
|
||||
marginRight:
|
||||
type: number
|
||||
example: 0
|
||||
default: 1
|
||||
description: Right margin for the page in inches.
|
||||
paperWidth:
|
||||
type: number
|
||||
example: 8.27
|
||||
description: >-
|
||||
Paper width to be used while rendering the PDF. The default page
|
||||
size is A4.
|
||||
paperHeight:
|
||||
type: number
|
||||
example: 11.69
|
||||
description: >-
|
||||
Paper height to be used while rendering the PDF. The default page
|
||||
size is A4.
|
||||
preferCssPageSize:
|
||||
type: boolean
|
||||
description: >-
|
||||
Define whether to prefer page size as defined by CSS (default false)
|
||||
default: false
|
||||
printBackground:
|
||||
type: boolean
|
||||
description: >-
|
||||
Print the background graphics (default false)
|
||||
default: false
|
||||
landscape:
|
||||
type: boolean
|
||||
example: true
|
||||
default: false
|
||||
description: >-
|
||||
The default orientation for rendering the page is "portrait" mode.
|
||||
By sending "landscape" parameter, you can ask the output to be
|
||||
landscape.
|
||||
scale:
|
||||
type: number
|
||||
minimum: 0.1
|
||||
maximum: 2.0
|
||||
example: 1.5
|
||||
description: >-
|
||||
The scale of the page rendering
|
||||
default: 1.0
|
||||
waitDelay:
|
||||
type: string
|
||||
example: 5s
|
||||
description: >-
|
||||
When the page relies on JavaScript for rendering, and you don't have access to the page's code,
|
||||
you may want to wait a certain amount of time to make sure Chromium has fully rendered the page
|
||||
you're trying to generate.
|
||||
waitWindowStatus:
|
||||
type: string
|
||||
example: done
|
||||
description: >-
|
||||
If you have access to the page's code, you may set the window status and tell Gotenberg to wait for a specific value.
|
||||
For instance
|
||||
await promises()
|
||||
window.status = 'ready'
|
||||
Prefer this option over waitDelay.
|
||||
extraHttpHeaders:
|
||||
type: string
|
||||
description: HTTP headers to send by Chromium while loading the HTML document (JSON format)
|
||||
nativePageRanges:
|
||||
type: string
|
||||
example: 1-4
|
||||
description: >-
|
||||
The page ranges to be converted to PDF for the incoming Office
|
||||
documents.
|
||||
pdfFormat:
|
||||
type: string
|
||||
description: >-
|
||||
The PDF format of the resulting PDF.
|
||||
Caution! You cannot use both nativePdfA1aFormat and pdfFormat form fields.
|
||||
example: PDF/A-1a
|
||||
required:
|
||||
- url
|
||||
OfficeConvertRequestBody:
|
||||
title: Office Conversion Request Body
|
||||
type: object
|
||||
properties:
|
||||
files:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
format: binary
|
||||
nativePageRanges:
|
||||
type: string
|
||||
example: 1-4
|
||||
description: >-
|
||||
The page ranges to be converted to PDF for the incoming Office
|
||||
documents. **If there are multiple files sent to the API, this page
|
||||
range will apply to all of the documents**. Empty means all pages.
|
||||
nativePdfA1aFormat:
|
||||
type: boolean
|
||||
description: >-
|
||||
Use unoconv to convert the resulting PDF to the 'PDF/A-1a' format.
|
||||
Caution! You cannot use both nativePdfA1aFormat and pdfFormat form fields.
|
||||
pdfFormat:
|
||||
type: string
|
||||
description: >-
|
||||
The PDF format of the resulting PDF.
|
||||
Caution! You cannot use both nativePdfA1aFormat and pdfFormat form fields.
|
||||
example: PDF/A-1a
|
||||
landscape:
|
||||
type: boolean
|
||||
example: true
|
||||
default: false
|
||||
description: >-
|
||||
The default orientation for rendering the page is "portrait" mode.
|
||||
By sending "landscape" parameter, you can ask the output to be
|
||||
landscape.
|
||||
merge:
|
||||
type: boolean
|
||||
description: >-
|
||||
Merge all PDF files into an individual PDF file.
|
||||
required:
|
||||
- files
|
||||
MergeFilesRequestBody:
|
||||
title: Merge Files Request Body
|
||||
type: object
|
||||
properties:
|
||||
files:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
format: binary
|
||||
required:
|
||||
- files
|
||||
securitySchemes: { }
|
||||
responses:
|
||||
SuccessfulPDF:
|
||||
description: Resulting PDF file from the conversion.
|
||||
content:
|
||||
application/pdf:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
Timeout:
|
||||
description: Service Unavailable
|
||||
78
go.mod
78
go.mod
@@ -1,67 +1,63 @@
|
||||
module github.com/gotenberg/gotenberg/v7
|
||||
module github.com/gotenberg/gotenberg/v8
|
||||
|
||||
go 1.21
|
||||
go 1.23.0
|
||||
|
||||
require (
|
||||
github.com/alexliesenfeld/health v0.7.0
|
||||
github.com/andybalholm/brotli v1.0.5 // indirect
|
||||
github.com/chromedp/cdproto v0.0.0-20230914224007-a15a36ccbc2e
|
||||
github.com/chromedp/chromedp v0.9.2
|
||||
github.com/alexliesenfeld/health v0.8.0
|
||||
github.com/andybalholm/brotli v1.1.1 // indirect
|
||||
github.com/barasher/go-exiftool v1.10.0
|
||||
github.com/chromedp/cdproto v0.0.0-20241110205750-a72e6703cd9b
|
||||
github.com/chromedp/chromedp v0.11.2
|
||||
github.com/golang/snappy v0.0.4 // indirect
|
||||
github.com/google/uuid v1.3.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
||||
github.com/hashicorp/go-retryablehttp v0.7.4
|
||||
github.com/klauspost/compress v1.17.0 // indirect
|
||||
github.com/hashicorp/go-retryablehttp v0.7.7
|
||||
github.com/klauspost/compress v1.17.11 // indirect
|
||||
github.com/klauspost/pgzip v1.2.6 // indirect
|
||||
github.com/labstack/echo/v4 v4.11.1
|
||||
github.com/labstack/gommon v0.4.0
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
github.com/labstack/echo/v4 v4.12.0
|
||||
github.com/labstack/gommon v0.4.2
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mholt/archiver/v3 v3.5.1
|
||||
github.com/microcosm-cc/bluemonday v1.0.25
|
||||
github.com/microcosm-cc/bluemonday v1.0.27
|
||||
github.com/nwaples/rardecode v1.1.3 // indirect
|
||||
github.com/pdfcpu/pdfcpu v0.5.0
|
||||
github.com/pierrec/lz4/v4 v4.1.18 // indirect
|
||||
github.com/prometheus/client_golang v1.16.0
|
||||
github.com/pierrec/lz4/v4 v4.1.21 // indirect
|
||||
github.com/prometheus/client_golang v1.20.5
|
||||
github.com/russross/blackfriday/v2 v2.1.0
|
||||
github.com/spf13/pflag v1.0.5
|
||||
github.com/ulikunitz/xz v0.5.11 // indirect
|
||||
github.com/ulikunitz/xz v0.5.12 // indirect
|
||||
go.uber.org/multierr v1.11.0
|
||||
go.uber.org/zap v1.26.0
|
||||
golang.org/x/crypto v0.13.0 // indirect
|
||||
golang.org/x/image v0.12.0 // indirect
|
||||
golang.org/x/net v0.15.0
|
||||
golang.org/x/sync v0.3.0
|
||||
golang.org/x/sys v0.12.0 // indirect
|
||||
golang.org/x/term v0.12.0
|
||||
golang.org/x/text v0.13.0
|
||||
go.uber.org/zap v1.27.0
|
||||
golang.org/x/crypto v0.29.0 // indirect
|
||||
golang.org/x/net v0.31.0
|
||||
golang.org/x/sync v0.9.0
|
||||
golang.org/x/sys v0.27.0 // indirect
|
||||
golang.org/x/term v0.26.0
|
||||
golang.org/x/text v0.20.0
|
||||
)
|
||||
|
||||
require github.com/dlclark/regexp2 v1.11.4
|
||||
|
||||
require (
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||
github.com/chromedp/sysutil v1.0.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/chromedp/sysutil v1.1.0 // indirect
|
||||
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 // indirect
|
||||
github.com/gobwas/httphead v0.1.0 // indirect
|
||||
github.com/gobwas/pool v0.2.1 // indirect
|
||||
github.com/gobwas/ws v1.3.0 // indirect
|
||||
github.com/golang/protobuf v1.5.3 // indirect
|
||||
github.com/gorilla/css v1.0.0 // indirect
|
||||
github.com/hhrutter/lzw v1.0.0 // indirect
|
||||
github.com/hhrutter/tiff v1.0.1 // indirect
|
||||
github.com/gobwas/ws v1.4.0 // indirect
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.15 // indirect
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/prometheus/client_model v0.4.0 // indirect
|
||||
github.com/prometheus/common v0.44.0 // indirect
|
||||
github.com/prometheus/procfs v0.11.1 // indirect
|
||||
github.com/rivo/uniseg v0.4.4 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/prometheus/client_model v0.6.1 // indirect
|
||||
github.com/prometheus/common v0.60.1 // indirect
|
||||
github.com/prometheus/procfs v0.15.1 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
|
||||
google.golang.org/protobuf v1.31.0 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
golang.org/x/time v0.8.0 // indirect
|
||||
google.golang.org/protobuf v1.35.2 // indirect
|
||||
)
|
||||
|
||||
229
go.sum
229
go.sum
@@ -1,214 +1,151 @@
|
||||
github.com/alexliesenfeld/health v0.7.0 h1:U3mSZ3ussRbGx+/rXBjNVxjLX5cKaNh8Ly9Hx3q+yfY=
|
||||
github.com/alexliesenfeld/health v0.7.0/go.mod h1:6Nnjbu7vBYHoZqIuZeOnTpnW7OH14ulR+wIBE2QuJ8I=
|
||||
github.com/alexliesenfeld/health v0.8.0 h1:lCV0i+ZJPTbqP7LfKG7p3qZBl5VhelwUFCIVWl77fgk=
|
||||
github.com/alexliesenfeld/health v0.8.0/go.mod h1:TfNP0f+9WQVWMQRzvMUjlws4ceXKEL3WR+6Hp95HUFc=
|
||||
github.com/andybalholm/brotli v1.0.1/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y=
|
||||
github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs=
|
||||
github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
||||
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
|
||||
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/barasher/go-exiftool v1.10.0 h1:f5JY5jc42M7tzR6tbL9508S2IXdIcG9QyieEXNMpIhs=
|
||||
github.com/barasher/go-exiftool v1.10.0/go.mod h1:F9s/a3uHSM8YniVfwF+sbQUtP8Gmh9nyzigNF+8vsWo=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/chromedp/cdproto v0.0.0-20230802225258-3cf4e6d46a89/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
|
||||
github.com/chromedp/cdproto v0.0.0-20230914224007-a15a36ccbc2e h1:BfDqq+EHA0HP037qWakDtYxIg9erpn2aZfZlrtnB35E=
|
||||
github.com/chromedp/cdproto v0.0.0-20230914224007-a15a36ccbc2e/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
|
||||
github.com/chromedp/chromedp v0.9.2 h1:dKtNz4kApb06KuSXoTQIyUC2TrA0fhGDwNZf3bcgfKw=
|
||||
github.com/chromedp/chromedp v0.9.2/go.mod h1:LkSXJKONWTCHAfQasKFUZI+mxqS4tZqhmtGzzhLsnLs=
|
||||
github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic=
|
||||
github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/chromedp/cdproto v0.0.0-20241110205750-a72e6703cd9b h1:md1Gk5jkNE91SZxFDCMHmKqX0/GsEr1/VTejht0sCbY=
|
||||
github.com/chromedp/cdproto v0.0.0-20241110205750-a72e6703cd9b/go.mod h1:4XqMl3iIW08jtieURWL6Tt5924w21pxirC6th662XUM=
|
||||
github.com/chromedp/chromedp v0.11.2 h1:ZRHTh7DjbNTlfIv3NFTbB7eVeu5XCNkgrpcGSpn2oX0=
|
||||
github.com/chromedp/chromedp v0.11.2/go.mod h1:lr8dFRLKsdTTWb75C/Ttol2vnBKOSnt0BW8R9Xaupi8=
|
||||
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
|
||||
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
|
||||
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 h1:iFaUwBSo5Svw6L7HYpRu/0lE3e0BaElwnNO1qkNQxBY=
|
||||
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s=
|
||||
github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY=
|
||||
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
|
||||
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
|
||||
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
|
||||
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
|
||||
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
|
||||
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||
github.com/gobwas/ws v1.2.1/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
|
||||
github.com/gobwas/ws v1.3.0 h1:sbeU3Y4Qzlb+MOzIe6mQGf7QR4Hkv6ZD0qhGkBFL2O0=
|
||||
github.com/gobwas/ws v1.3.0/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
|
||||
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
|
||||
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
|
||||
github.com/golang/snappy v0.0.2/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
|
||||
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4=
|
||||
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
|
||||
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
|
||||
github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI=
|
||||
github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ=
|
||||
github.com/hashicorp/go-retryablehttp v0.7.4 h1:ZQgVdpTdAL7WpMIwLzCfbalOcSUdkDZnpUv3/+BxzFA=
|
||||
github.com/hashicorp/go-retryablehttp v0.7.4/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8=
|
||||
github.com/hhrutter/lzw v1.0.0 h1:laL89Llp86W3rRs83LvKbwYRx6INE8gDn0XNb1oXtm0=
|
||||
github.com/hhrutter/lzw v1.0.0/go.mod h1:2HC6DJSn/n6iAZfgM3Pg+cP1KxeWc3ezG8bBqW5+WEo=
|
||||
github.com/hhrutter/tiff v1.0.1 h1:MIus8caHU5U6823gx7C6jrfoEvfSTGtEFRiM8/LOzC0=
|
||||
github.com/hhrutter/tiff v1.0.1/go.mod h1:zU/dNgDm0cMIa8y8YwcYBeuEEveI4B0owqHyiPpJPHc=
|
||||
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
|
||||
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
|
||||
github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU=
|
||||
github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
|
||||
github.com/klauspost/compress v1.11.4/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
|
||||
github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM=
|
||||
github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
|
||||
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
|
||||
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
|
||||
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
|
||||
github.com/klauspost/pgzip v1.2.5/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
|
||||
github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
|
||||
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/labstack/echo/v4 v4.11.1 h1:dEpLU2FLg4UVmvCGPuk/APjlH6GDpbEPti61srUUUs4=
|
||||
github.com/labstack/echo/v4 v4.11.1/go.mod h1:YuYRTSM3CHs2ybfrL8Px48bO6BAnYIN4l8wSTMP6BDQ=
|
||||
github.com/labstack/gommon v0.4.0 h1:y7cvthEAEbU0yHOf4axH8ZG2NH8knB9iNSoTO8dyIk8=
|
||||
github.com/labstack/gommon v0.4.0/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/labstack/echo/v4 v4.12.0 h1:IKpw49IMryVB2p1a4dzwlhP1O2Tf2E0Ir/450lH+kI0=
|
||||
github.com/labstack/echo/v4 v4.12.0/go.mod h1:UP9Cr2DJXbOK3Kr9ONYzNowSh7HP0aG0ShAyycHSJvM=
|
||||
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
||||
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
|
||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
|
||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
|
||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
||||
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mholt/archiver/v3 v3.5.1 h1:rDjOBX9JSF5BvoJGvjqK479aL70qh9DIpZCl+k7Clwo=
|
||||
github.com/mholt/archiver/v3 v3.5.1/go.mod h1:e3dqJ7H78uzsRSEACH1joayhuSyhnonssnDhppzS1L4=
|
||||
github.com/microcosm-cc/bluemonday v1.0.25 h1:4NEwSfiJ+Wva0VxN5B8OwMicaJvD8r9tlJWm9rtloEg=
|
||||
github.com/microcosm-cc/bluemonday v1.0.25/go.mod h1:ZIOjCQp1OrzBBPIJmfX4qDYFuhU02nx4bn030ixfHLE=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/nwaples/rardecode v1.1.0/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0=
|
||||
github.com/nwaples/rardecode v1.1.3 h1:cWCaZwfM5H7nAD6PyEdcVnczzV8i/JtotnyW/dD9lEc=
|
||||
github.com/nwaples/rardecode v1.1.3/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0=
|
||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
|
||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
|
||||
github.com/pdfcpu/pdfcpu v0.5.0 h1:F3wC4bwPbaJM+RPgm1D0Q4SAUwxElw7BhwNvL3iPgDo=
|
||||
github.com/pdfcpu/pdfcpu v0.5.0/go.mod h1:UPcHdWcMw1V6Bo5tcWHd3jZfkG8cwUwrJkQOlB6o+7g=
|
||||
github.com/pierrec/lz4/v4 v4.1.2/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pierrec/lz4/v4 v4.1.18 h1:xaKrnTkyoqfh1YItXl56+6KJNVYWlEEPuAQW9xsplYQ=
|
||||
github.com/pierrec/lz4/v4 v4.1.18/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
|
||||
github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
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/prometheus/client_golang v1.16.0 h1:yk/hx9hDbrGHovbci4BY+pRMfSuuat626eFsHb7tmT8=
|
||||
github.com/prometheus/client_golang v1.16.0/go.mod h1:Zsulrv/L9oM40tJ7T815tM89lFEugiJ9HzIqaAx4LKc=
|
||||
github.com/prometheus/client_model v0.4.0 h1:5lQXD3cAg1OXBf4Wq03gTrXHeaV0TQvGfUooCfx1yqY=
|
||||
github.com/prometheus/client_model v0.4.0/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU=
|
||||
github.com/prometheus/common v0.44.0 h1:+5BrQJwiBB9xsMygAB3TNvpQKOwlkc25LbISbrdOOfY=
|
||||
github.com/prometheus/common v0.44.0/go.mod h1:ofAIvZbQ1e/nugmZGz4/qCb9Ap1VoSTIO7x0VV9VvuY=
|
||||
github.com/prometheus/procfs v0.11.1 h1:xRC8Iq1yyca5ypa9n1EZnWZkt7dwcoRPQwX/5gwaUuI=
|
||||
github.com/prometheus/procfs v0.11.1/go.mod h1:eesXgaPo1q7lBpVMoMy0ZOFTth9hBn4W/y0/p/ScXhY=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
|
||||
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
|
||||
github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
|
||||
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
|
||||
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
|
||||
github.com/prometheus/common v0.60.1 h1:FUas6GcOw66yB/73KC+BOZoFJmbo/1pojoILArPAaSc=
|
||||
github.com/prometheus/common v0.60.1/go.mod h1:h0LYf1R1deLSKtD4Vdg8gy4RuOvENW2J/h19V5NADQw=
|
||||
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
|
||||
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
|
||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
|
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||
github.com/ulikunitz/xz v0.5.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||
github.com/ulikunitz/xz v0.5.11 h1:kpFauv27b6ynzBNT/Xy+1k+fK4WswhN/6PN5WhFAGw8=
|
||||
github.com/ulikunitz/xz v0.5.11/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||
github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc=
|
||||
github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||
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 v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo=
|
||||
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk=
|
||||
go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
|
||||
go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/image v0.12.0 h1:w13vZbU4o5rKOFFR8y7M+c4A5jXDC0uXTdHYRP8X2DQ=
|
||||
golang.org/x/image v0.12.0/go.mod h1:Lu90jvHG7GfemOIcldsh9A2hS01ocl6oNO7ype5mEnk=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.15.0 h1:ugBLEUaxABaB5AJqW9enI0ACdci2RUd4eP51NTBvuJ8=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ=
|
||||
golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg=
|
||||
golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo=
|
||||
golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM=
|
||||
golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ=
|
||||
golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.12.0 h1:/ZfYdc3zq+q02Rv9vGqTeSItdzZTSNDmfTi0mBAuidU=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
|
||||
golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.26.0 h1:WEQa6V3Gja/BhNxg540hBip/kkaYtRg3cxg4oXSw4AU=
|
||||
golang.org/x/term v0.26.0/go.mod h1:Si5m1o57C5nBNQo5z1iq+XDijt21BDBDp2bK0QI8e3E=
|
||||
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
|
||||
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
|
||||
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
|
||||
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
|
||||
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io=
|
||||
google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -13,43 +13,43 @@ import (
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Cmd wraps an exec.Cmd.
|
||||
// Cmd wraps an [exec.Cmd].
|
||||
type Cmd struct {
|
||||
ctx context.Context
|
||||
logger *zap.Logger
|
||||
process *exec.Cmd
|
||||
}
|
||||
|
||||
// Command creates a Cmd without a context. It configures the internal
|
||||
// exec.Cmd of Cmd so that we may kill its unix process and all its children
|
||||
// without creating orphans.
|
||||
// Command creates a [Cmd] without a context. It configures the internal
|
||||
// [exec.Cmd] of [Cmd] so that we may kill its unix process and all its
|
||||
// children without creating orphans.
|
||||
//
|
||||
// See https://medium.com/@felixge/killing-a-child-process-and-all-of-its-children-in-go-54079af94773.
|
||||
func Command(logger *zap.Logger, binPath string, args ...string) Cmd {
|
||||
func Command(logger *zap.Logger, binPath string, args ...string) *Cmd {
|
||||
cmd := exec.Command(binPath, args...)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
|
||||
return Cmd{
|
||||
return &Cmd{
|
||||
ctx: nil,
|
||||
logger: logger.Named(strings.ReplaceAll(binPath, "/", "")),
|
||||
process: cmd,
|
||||
}
|
||||
}
|
||||
|
||||
// CommandContext creates a Cmd with a context. It configures the internal
|
||||
// exec.Cmd of Cmd so that we may kill its unix process and all its children
|
||||
// without creating orphans.
|
||||
// CommandContext creates a [Cmd] with a context. It configures the internal
|
||||
// [exec.Cmd] of [Cmd] so that we may kill its unix process and all its
|
||||
// children without creating orphans.
|
||||
//
|
||||
// See https://medium.com/@felixge/killing-a-child-process-and-all-of-its-children-in-go-54079af94773.
|
||||
func CommandContext(ctx context.Context, logger *zap.Logger, binPath string, args ...string) (Cmd, error) {
|
||||
func CommandContext(ctx context.Context, logger *zap.Logger, binPath string, args ...string) (*Cmd, error) {
|
||||
if ctx == nil {
|
||||
return Cmd{}, errors.New("nil context")
|
||||
return nil, errors.New("nil context")
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, binPath, args...)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
|
||||
return Cmd{
|
||||
return &Cmd{
|
||||
ctx: ctx,
|
||||
logger: logger.Named(strings.ReplaceAll(binPath, "/", "")),
|
||||
process: cmd,
|
||||
@@ -57,7 +57,7 @@ func CommandContext(ctx context.Context, logger *zap.Logger, binPath string, arg
|
||||
}
|
||||
|
||||
// Start starts the command but does not wait for its completion.
|
||||
func (cmd Cmd) Start() error {
|
||||
func (cmd *Cmd) Start() error {
|
||||
err := cmd.pipeOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("pipe unix process output: %w", err)
|
||||
@@ -75,7 +75,7 @@ func (cmd Cmd) Start() error {
|
||||
|
||||
// Wait waits for the command to complete. It should be called when using the
|
||||
// Start method, so that the command does not leak zombies.
|
||||
func (cmd Cmd) Wait() error {
|
||||
func (cmd *Cmd) Wait() error {
|
||||
err := cmd.process.Wait()
|
||||
if err != nil {
|
||||
return fmt.Errorf("wait for unix process: %w", err)
|
||||
@@ -86,7 +86,7 @@ func (cmd Cmd) Wait() error {
|
||||
|
||||
// Exec executes the command and wait for its completion or until the context
|
||||
// is done. In any case, it kills the unix process and all its children.
|
||||
func (cmd Cmd) Exec() (int, error) {
|
||||
func (cmd *Cmd) Exec() (int, error) {
|
||||
if cmd.ctx == nil {
|
||||
return 10, errors.New("nil context")
|
||||
}
|
||||
@@ -134,7 +134,7 @@ func (cmd Cmd) Exec() (int, error) {
|
||||
|
||||
// pipeOutput creates logs entries according to the process stdout and stderr.
|
||||
// It does nothing if the logging level is not debug.
|
||||
func (cmd Cmd) pipeOutput() error {
|
||||
func (cmd *Cmd) pipeOutput() error {
|
||||
checkedEntry := cmd.logger.Check(zap.DebugLevel, "check for debug level before piping unix process output")
|
||||
if checkedEntry == nil {
|
||||
return nil
|
||||
@@ -154,11 +154,15 @@ func (cmd Cmd) pipeOutput() error {
|
||||
// (either stdout or stderr).
|
||||
logCommandOutput := func(logger *zap.Logger, reader io.ReadCloser) {
|
||||
r := bufio.NewReader(reader)
|
||||
defer reader.Close()
|
||||
defer func(reader io.ReadCloser) {
|
||||
err := reader.Close()
|
||||
if err != nil && !strings.Contains(err.Error(), "file already closed") {
|
||||
logger.Error(fmt.Sprintf("close reader: %s", err))
|
||||
}
|
||||
}(reader)
|
||||
|
||||
for {
|
||||
line, _, err := r.ReadLine()
|
||||
|
||||
if err != nil {
|
||||
if err != io.EOF && !strings.Contains(err.Error(), "file already closed") {
|
||||
logger.Error(fmt.Sprintf("pipe unix process output error: %s", err))
|
||||
@@ -182,7 +186,7 @@ func (cmd Cmd) pipeOutput() error {
|
||||
// Kill kills the unix process and all its children without creating orphans.
|
||||
//
|
||||
// See https://medium.com/@felixge/killing-a-child-process-and-all-of-its-children-in-go-54079af94773.
|
||||
func (cmd Cmd) Kill() error {
|
||||
func (cmd *Cmd) Kill() error {
|
||||
if cmd.process == nil {
|
||||
// We cannot use the logger here, because for whatever reason using it
|
||||
// result to a panic.
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
|
||||
func TestCommand(t *testing.T) {
|
||||
cmd := Command(zap.NewNop(), "foo")
|
||||
|
||||
if !cmd.process.SysProcAttr.Setpgid {
|
||||
t.Error("expected cmd.process.SysProcAttr.Setpgid to be true")
|
||||
}
|
||||
@@ -18,34 +17,36 @@ func TestCommand(t *testing.T) {
|
||||
|
||||
func TestCommandContext(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ctx context.Context
|
||||
expectCommandContextErr bool
|
||||
scenario string
|
||||
ctx context.Context
|
||||
expectCommandContextError bool
|
||||
}{
|
||||
{
|
||||
name: "nominal behavior",
|
||||
ctx: context.Background(),
|
||||
scenario: "nominal behavior",
|
||||
ctx: context.Background(),
|
||||
expectCommandContextError: false,
|
||||
},
|
||||
{
|
||||
name: "nil context",
|
||||
expectCommandContextErr: true,
|
||||
scenario: "nil context",
|
||||
ctx: nil,
|
||||
expectCommandContextError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
cmd, err := CommandContext(tc.ctx, zap.NewNop(), "foo")
|
||||
|
||||
if err == nil && !cmd.process.SysProcAttr.Setpgid {
|
||||
t.Fatal("expected cmd.process.SysProcAttr.Setpgid to be true")
|
||||
}
|
||||
|
||||
if tc.expectCommandContextErr && err == nil {
|
||||
t.Error("expected error from CommandContext(), but got none")
|
||||
if !tc.expectCommandContextError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if !tc.expectCommandContextErr && err != nil {
|
||||
t.Errorf("expected no error from CommandContext(), but got: %v", err)
|
||||
if tc.expectCommandContextError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -53,31 +54,32 @@ func TestCommandContext(t *testing.T) {
|
||||
|
||||
func TestCmd_Start(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cmd Cmd
|
||||
expectStartErr bool
|
||||
scenario string
|
||||
cmd *Cmd
|
||||
expectStartError bool
|
||||
}{
|
||||
{
|
||||
name: "nominal behavior",
|
||||
cmd: Command(zap.NewNop(), "echo", "Hello", "World"),
|
||||
scenario: "nominal behavior",
|
||||
cmd: Command(zap.NewNop(), "echo", "Hello", "World"),
|
||||
expectStartError: false,
|
||||
},
|
||||
{
|
||||
name: "start error",
|
||||
cmd: Command(zap.NewNop(), "foo"),
|
||||
expectStartErr: true,
|
||||
scenario: "start error",
|
||||
cmd: Command(zap.NewNop(), "foo"),
|
||||
expectStartError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
err := tc.cmd.Start()
|
||||
|
||||
if tc.expectStartErr && err == nil {
|
||||
t.Error("expected error from cmd.Start(), but got none")
|
||||
if !tc.expectStartError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if !tc.expectStartErr && err != nil {
|
||||
t.Errorf("expected no error from cmd.Start(), but got: %v", err)
|
||||
if tc.expectStartError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -85,54 +87,39 @@ func TestCmd_Start(t *testing.T) {
|
||||
|
||||
func TestCmd_Wait(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cmd Cmd
|
||||
expectWaitErr bool
|
||||
scenario string
|
||||
cmd *Cmd
|
||||
expectWaitError bool
|
||||
}{
|
||||
{
|
||||
name: "nominal behavior",
|
||||
cmd: func() Cmd {
|
||||
scenario: "nominal behavior",
|
||||
cmd: func() *Cmd {
|
||||
cmd := Command(zap.NewNop(), "echo", "Hello", "World")
|
||||
|
||||
err := cmd.Start()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error from cmd.Start(), but got: %v", err)
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
return cmd
|
||||
}(),
|
||||
expectWaitError: false,
|
||||
},
|
||||
{
|
||||
name: "wait error",
|
||||
cmd: func() Cmd {
|
||||
cmd := Command(zap.NewNop(), "echo", "Hello", "World")
|
||||
|
||||
err := cmd.Start()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error from cmd.Start(), but got: %v", err)
|
||||
}
|
||||
|
||||
err = cmd.Kill()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error from cmd.Kill(), but got: %v", err)
|
||||
}
|
||||
|
||||
return cmd
|
||||
}(),
|
||||
expectWaitErr: true,
|
||||
scenario: "wait error",
|
||||
cmd: Command(zap.NewNop(), "echo", "Hello", "World"),
|
||||
expectWaitError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
err := tc.cmd.Wait()
|
||||
|
||||
if tc.expectWaitErr && err == nil {
|
||||
t.Error("expected error from cmd.Wait(), but got none")
|
||||
if !tc.expectWaitError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if !tc.expectWaitErr && err != nil {
|
||||
t.Errorf("expected no error from cmd.Wait(), but got: %v", err)
|
||||
if tc.expectWaitError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -140,49 +127,48 @@ func TestCmd_Wait(t *testing.T) {
|
||||
|
||||
func TestCmd_Exec(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cmd Cmd
|
||||
timeout time.Duration
|
||||
expectExecErr bool
|
||||
scenario string
|
||||
cmd *Cmd
|
||||
timeout time.Duration
|
||||
expectExecError bool
|
||||
}{
|
||||
{
|
||||
name: "nominal behavior",
|
||||
cmd: func() Cmd {
|
||||
scenario: "nominal behavior",
|
||||
cmd: func() *Cmd {
|
||||
cmd, err := CommandContext(context.Background(), zap.NewNop(), "echo", "Hello", "World")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error from CommandContext(), but got: %v", err)
|
||||
}
|
||||
|
||||
return cmd
|
||||
}(),
|
||||
expectExecError: false,
|
||||
},
|
||||
{
|
||||
name: "nil context",
|
||||
cmd: Command(zap.NewNop(), "echo", "Hello", "World"),
|
||||
expectExecErr: true,
|
||||
scenario: "nil context",
|
||||
cmd: Command(zap.NewNop(), "echo", "Hello", "World"),
|
||||
expectExecError: true,
|
||||
},
|
||||
{
|
||||
name: "start error",
|
||||
cmd: func() Cmd {
|
||||
scenario: "start error",
|
||||
cmd: func() *Cmd {
|
||||
cmd, err := CommandContext(context.Background(), zap.NewNop(), "foo")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error from CommandContext(), but got: %v", err)
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
return cmd
|
||||
}(),
|
||||
expectExecErr: true,
|
||||
expectExecError: true,
|
||||
},
|
||||
{
|
||||
name: "context done",
|
||||
cmd: Command(zap.NewNop(), "sleep", "2"),
|
||||
timeout: time.Duration(1) * time.Second,
|
||||
expectExecErr: true,
|
||||
scenario: "context done",
|
||||
cmd: Command(zap.NewNop(), "sleep", "2"),
|
||||
timeout: time.Duration(1) * time.Second,
|
||||
expectExecError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
if tc.timeout > 0 {
|
||||
ctx, cancel := context.WithTimeout(context.TODO(), tc.timeout)
|
||||
defer cancel()
|
||||
@@ -192,12 +178,12 @@ func TestCmd_Exec(t *testing.T) {
|
||||
|
||||
_, err := tc.cmd.Exec()
|
||||
|
||||
if tc.expectExecErr && err == nil {
|
||||
t.Error("expected error from cmd.Exec(), but got none")
|
||||
if !tc.expectExecError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if !tc.expectExecErr && err != nil {
|
||||
t.Errorf("expected no error from cmd.Exec(), but got: %v", err)
|
||||
if tc.expectExecError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -205,67 +191,68 @@ func TestCmd_Exec(t *testing.T) {
|
||||
|
||||
func TestCmd_pipeOutput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cmd Cmd
|
||||
run bool
|
||||
expectPipeOutputErr bool
|
||||
scenario string
|
||||
cmd *Cmd
|
||||
run bool
|
||||
expectPipeOutputError bool
|
||||
}{
|
||||
{
|
||||
name: "nominal behavior",
|
||||
cmd: Command(zap.NewExample(), "echo", "Hello", "World"),
|
||||
run: true,
|
||||
scenario: "nominal behavior",
|
||||
cmd: Command(zap.NewExample(), "echo", "Hello", "World"),
|
||||
run: true,
|
||||
expectPipeOutputError: false,
|
||||
},
|
||||
{
|
||||
name: "no debug, no pipe",
|
||||
cmd: Command(zap.NewNop(), "echo", "Hello", "World"),
|
||||
scenario: "no debug, no pipe",
|
||||
cmd: Command(zap.NewNop(), "echo", "Hello", "World"),
|
||||
run: false,
|
||||
expectPipeOutputError: false,
|
||||
},
|
||||
{
|
||||
name: "stdout already piped",
|
||||
cmd: func() Cmd {
|
||||
scenario: "stdout already piped",
|
||||
cmd: func() *Cmd {
|
||||
cmd := Command(zap.NewExample(), "echo", "Hello", "World")
|
||||
|
||||
_, err := cmd.process.StdoutPipe()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error from cmd.process.StdoutPipe(), but got: %v", err)
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
return cmd
|
||||
}(),
|
||||
expectPipeOutputErr: true,
|
||||
run: false,
|
||||
expectPipeOutputError: true,
|
||||
},
|
||||
{
|
||||
name: "stderr already piped",
|
||||
cmd: func() Cmd {
|
||||
scenario: "stderr already piped",
|
||||
cmd: func() *Cmd {
|
||||
cmd := Command(zap.NewExample(), "echo", "Hello", "World")
|
||||
|
||||
_, err := cmd.process.StderrPipe()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error from cmd.process.StderrPipe(), but got: %v", err)
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
return cmd
|
||||
}(),
|
||||
expectPipeOutputErr: true,
|
||||
run: false,
|
||||
expectPipeOutputError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
err := tc.cmd.pipeOutput()
|
||||
|
||||
if tc.run {
|
||||
errStart := tc.cmd.process.Start()
|
||||
if errStart != nil {
|
||||
t.Fatalf("expected no error from tc.cmd.process.Start(), but got: %v", errStart)
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if tc.expectPipeOutputErr && err == nil {
|
||||
t.Error("expected error from cmd.pipeOutput(), but got none")
|
||||
if !tc.expectPipeOutputError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if !tc.expectPipeOutputErr && err != nil {
|
||||
t.Errorf("expected no error from cmd.pipeOutput(), but got: %v", err)
|
||||
if tc.expectPipeOutputError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -273,51 +260,46 @@ func TestCmd_pipeOutput(t *testing.T) {
|
||||
|
||||
func TestCmd_Kill(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cmd Cmd
|
||||
scenario string
|
||||
cmd *Cmd
|
||||
}{
|
||||
{
|
||||
name: "nominal behavior",
|
||||
cmd: func() Cmd {
|
||||
scenario: "nominal behavior",
|
||||
cmd: func() *Cmd {
|
||||
cmd := Command(zap.NewNop(), "sleep", "60")
|
||||
|
||||
err := cmd.process.Start()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error from cmd.process.Start(), but got: %v", err)
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
return cmd
|
||||
}(),
|
||||
},
|
||||
{
|
||||
name: "no process",
|
||||
cmd: Cmd{logger: zap.NewNop()},
|
||||
scenario: "no process",
|
||||
cmd: &Cmd{logger: zap.NewNop()},
|
||||
},
|
||||
{
|
||||
name: "process already killed",
|
||||
cmd: func() Cmd {
|
||||
scenario: "process already killed",
|
||||
cmd: func() *Cmd {
|
||||
cmd := Command(zap.NewNop(), "sleep", "60")
|
||||
|
||||
err := cmd.process.Start()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error from cmd.process.Start(), but got: %v", err)
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
err = cmd.Kill()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error from cmd.Kill(), but got: %v", err)
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
return cmd
|
||||
}(),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
err := tc.cmd.Kill()
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from cmd.Kill(), but got: %v", err)
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"reflect"
|
||||
)
|
||||
|
||||
// Context is a struct which helps initializing modules. When provisioning, a
|
||||
// Context is a struct which helps to initialize modules. When provisioning, a
|
||||
// module may use the context to get other modules that it needs internally.
|
||||
type Context struct {
|
||||
flags ParsedFlags
|
||||
@@ -13,8 +13,8 @@ type Context struct {
|
||||
moduleInstances map[string]interface{}
|
||||
}
|
||||
|
||||
// NewContext creates a Context.
|
||||
// In a module, prefer the Provisioner interface to get a Context.
|
||||
// NewContext creates a [Context].
|
||||
// In a module, prefer the [Provisioner] interface to get a [Context].
|
||||
func NewContext(
|
||||
flags ParsedFlags,
|
||||
descriptors []ModuleDescriptor,
|
||||
@@ -32,7 +32,7 @@ func NewContext(
|
||||
// flags := ctx.ParsedFlags()
|
||||
// m.foo = flags.RequiredString("foo")
|
||||
// }
|
||||
func (ctx Context) ParsedFlags() ParsedFlags {
|
||||
func (ctx *Context) ParsedFlags() ParsedFlags {
|
||||
return ctx.flags
|
||||
}
|
||||
|
||||
@@ -47,7 +47,6 @@ func (ctx Context) ParsedFlags() ParsedFlags {
|
||||
// initializes it. Otherwise, returns the already initialized instance.
|
||||
func (ctx *Context) Module(kind interface{}) (interface{}, error) {
|
||||
mods, err := ctx.Modules(kind)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get module: %w", err)
|
||||
}
|
||||
@@ -101,7 +100,7 @@ func (ctx *Context) Modules(kind interface{}) ([]interface{}, error) {
|
||||
}
|
||||
|
||||
// loadModule calls the Provision and/or Validate methods of the requested
|
||||
// module if it satisfies the Provisioner and/or Validator interfaces.
|
||||
// module if it satisfies the [Provisioner] and/or [Validator] interfaces.
|
||||
func (ctx *Context) loadModule(id string, instance interface{}) error {
|
||||
if prov, ok := instance.(Provisioner); ok {
|
||||
// The instance can be provisioned.
|
||||
|
||||
@@ -23,173 +23,212 @@ func TestContext_ParsedFlags(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestContext_Module(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
mods []ModuleDescriptor
|
||||
kind interface{}
|
||||
expectErr bool
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
mods []ModuleDescriptor
|
||||
kind interface{}
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
scenario: "module with error on provision",
|
||||
mods: func() []ModuleDescriptor {
|
||||
mod := struct{ ProtoProvisioner }{}
|
||||
mod.descriptor = func() ModuleDescriptor {
|
||||
mod := &struct {
|
||||
ModuleMock
|
||||
ProvisionerMock
|
||||
}{}
|
||||
mod.DescriptorMock = func() ModuleDescriptor {
|
||||
return ModuleDescriptor{ID: "foo", New: func() Module { return mod }}
|
||||
}
|
||||
mod.provision = func(ctx *Context) error { return errors.New("foo") }
|
||||
|
||||
mod.ProvisionMock = func(ctx *Context) error { return errors.New("foo") }
|
||||
return []ModuleDescriptor{mod.Descriptor()}
|
||||
}(),
|
||||
kind: new(Provisioner),
|
||||
expectErr: true,
|
||||
kind: new(Provisioner),
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "two modules instead of one",
|
||||
mods: func() []ModuleDescriptor {
|
||||
mod := struct{ ProtoProvisioner }{}
|
||||
mod.descriptor = func() ModuleDescriptor {
|
||||
mod := &struct {
|
||||
ModuleMock
|
||||
ProvisionerMock
|
||||
}{}
|
||||
mod.DescriptorMock = func() ModuleDescriptor {
|
||||
return ModuleDescriptor{ID: "foo", New: func() Module { return mod }}
|
||||
}
|
||||
mod.provision = func(ctx *Context) error { return nil }
|
||||
|
||||
mod.ProvisionMock = func(ctx *Context) error { return nil }
|
||||
return []ModuleDescriptor{mod.Descriptor(), mod.Descriptor()}
|
||||
}(),
|
||||
kind: new(Provisioner),
|
||||
expectErr: true,
|
||||
kind: new(Provisioner),
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "success",
|
||||
mods: func() []ModuleDescriptor {
|
||||
mod := struct{ ProtoProvisioner }{}
|
||||
mod.descriptor = func() ModuleDescriptor {
|
||||
mod := &struct {
|
||||
ModuleMock
|
||||
ProvisionerMock
|
||||
}{}
|
||||
mod.DescriptorMock = func() ModuleDescriptor {
|
||||
return ModuleDescriptor{ID: "foo", New: func() Module { return mod }}
|
||||
}
|
||||
mod.provision = func(ctx *Context) error { return nil }
|
||||
|
||||
mod.ProvisionMock = func(ctx *Context) error { return nil }
|
||||
return []ModuleDescriptor{mod.Descriptor()}
|
||||
}(),
|
||||
kind: new(Provisioner),
|
||||
kind: new(Provisioner),
|
||||
expectError: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
ctx := NewContext(ParsedFlags{}, tc.mods)
|
||||
_, err := ctx.Module(tc.kind)
|
||||
|
||||
ctx := NewContext(ParsedFlags{}, tc.mods)
|
||||
_, err := ctx.Module(tc.kind)
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectErr && err == nil {
|
||||
t.Errorf("test %d: expected error but got: %v", i, err)
|
||||
}
|
||||
|
||||
if !tc.expectErr && err != nil {
|
||||
t.Errorf("test %d: expected no error but got: %v", i, err)
|
||||
}
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestContext_Modules(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
mods []ModuleDescriptor
|
||||
kind interface{}
|
||||
expectErr bool
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
mods []ModuleDescriptor
|
||||
kind interface{}
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
scenario: "module with error on provision",
|
||||
mods: func() []ModuleDescriptor {
|
||||
mod := struct{ ProtoProvisioner }{}
|
||||
mod.descriptor = func() ModuleDescriptor {
|
||||
mod := &struct {
|
||||
ModuleMock
|
||||
ProvisionerMock
|
||||
}{}
|
||||
mod.DescriptorMock = func() ModuleDescriptor {
|
||||
return ModuleDescriptor{ID: "foo", New: func() Module { return mod }}
|
||||
}
|
||||
mod.provision = func(ctx *Context) error { return errors.New("foo") }
|
||||
|
||||
mod.ProvisionMock = func(ctx *Context) error { return errors.New("foo") }
|
||||
return []ModuleDescriptor{mod.Descriptor()}
|
||||
}(),
|
||||
kind: new(Provisioner),
|
||||
expectErr: true,
|
||||
kind: new(Provisioner),
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "success (module)",
|
||||
mods: func() []ModuleDescriptor {
|
||||
mod := struct{ ProtoProvisioner }{}
|
||||
mod.descriptor = func() ModuleDescriptor {
|
||||
mod := &struct {
|
||||
ModuleMock
|
||||
ProvisionerMock
|
||||
}{}
|
||||
mod.DescriptorMock = func() ModuleDescriptor {
|
||||
return ModuleDescriptor{ID: "foo", New: func() Module { return mod }}
|
||||
}
|
||||
mod.provision = func(ctx *Context) error { return nil }
|
||||
|
||||
mod.ProvisionMock = func(ctx *Context) error { return nil }
|
||||
return []ModuleDescriptor{mod.Descriptor(), mod.Descriptor()}
|
||||
}(),
|
||||
kind: new(Provisioner),
|
||||
kind: new(Provisioner),
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "success (one module)",
|
||||
mods: func() []ModuleDescriptor {
|
||||
mod := struct{ ProtoProvisioner }{}
|
||||
mod.descriptor = func() ModuleDescriptor {
|
||||
mod := &struct {
|
||||
ModuleMock
|
||||
ProvisionerMock
|
||||
}{}
|
||||
mod.DescriptorMock = func() ModuleDescriptor {
|
||||
return ModuleDescriptor{ID: "foo", New: func() Module { return mod }}
|
||||
}
|
||||
mod.provision = func(ctx *Context) error { return nil }
|
||||
mod.ProvisionMock = func(ctx *Context) error { return nil }
|
||||
|
||||
return []ModuleDescriptor{mod.Descriptor()}
|
||||
}(),
|
||||
kind: new(Provisioner),
|
||||
kind: new(Provisioner),
|
||||
expectError: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
ctx := NewContext(ParsedFlags{}, tc.mods)
|
||||
_, err := ctx.Modules(tc.kind)
|
||||
|
||||
ctx := NewContext(ParsedFlags{}, tc.mods)
|
||||
_, err := ctx.Modules(tc.kind)
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectErr && err == nil {
|
||||
t.Errorf("test %d: expected error but got: %v", i, err)
|
||||
}
|
||||
|
||||
if !tc.expectErr && err != nil {
|
||||
t.Errorf("test %d: expected no error but got: %v", i, err)
|
||||
}
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestContext_loadModule(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
instance interface{}
|
||||
expectErr bool
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
instance interface{}
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
scenario: "module with error on provision",
|
||||
instance: func() interface{} {
|
||||
mod := struct{ ProtoProvisioner }{}
|
||||
mod.descriptor = func() ModuleDescriptor {
|
||||
mod := &struct {
|
||||
ModuleMock
|
||||
ProvisionerMock
|
||||
}{}
|
||||
mod.DescriptorMock = func() ModuleDescriptor {
|
||||
return ModuleDescriptor{ID: "foo", New: func() Module { return mod }}
|
||||
}
|
||||
mod.provision = func(ctx *Context) error { return errors.New("foo") }
|
||||
|
||||
mod.ProvisionMock = func(ctx *Context) error { return errors.New("foo") }
|
||||
return mod
|
||||
}(),
|
||||
expectErr: true,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "module with error on validation",
|
||||
instance: func() interface{} {
|
||||
mod := struct{ ProtoValidator }{}
|
||||
mod.descriptor = func() ModuleDescriptor {
|
||||
mod := &struct {
|
||||
ModuleMock
|
||||
ValidatorMock
|
||||
}{}
|
||||
mod.DescriptorMock = func() ModuleDescriptor {
|
||||
return ModuleDescriptor{ID: "foo", New: func() Module { return mod }}
|
||||
}
|
||||
mod.validate = func() error { return errors.New("foo") }
|
||||
|
||||
mod.ValidateMock = func() error { return errors.New("foo") }
|
||||
return mod
|
||||
}(),
|
||||
expectErr: true,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "success",
|
||||
instance: func() interface{} {
|
||||
mod := struct{ ProtoValidator }{}
|
||||
mod.descriptor = func() ModuleDescriptor {
|
||||
mod := &struct {
|
||||
ModuleMock
|
||||
ValidatorMock
|
||||
}{}
|
||||
mod.DescriptorMock = func() ModuleDescriptor {
|
||||
return ModuleDescriptor{ID: "foo", New: func() Module { return mod }}
|
||||
}
|
||||
mod.validate = func() error { return nil }
|
||||
mod.ValidateMock = func() error { return nil }
|
||||
|
||||
return mod
|
||||
}(),
|
||||
expectError: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
ctx := NewContext(ParsedFlags{}, nil)
|
||||
err := ctx.loadModule("foo", tc.instance)
|
||||
|
||||
ctx := NewContext(ParsedFlags{}, nil)
|
||||
err := ctx.loadModule("foo", tc.instance)
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectErr && err == nil {
|
||||
t.Errorf("test %d: expected error but got: %v", i, err)
|
||||
}
|
||||
|
||||
if !tc.expectErr && err != nil {
|
||||
t.Errorf("test %d: expected no error but got: %v", i, err)
|
||||
}
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
35
pkg/gotenberg/env.go
Normal file
35
pkg/gotenberg/env.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// StringEnv retrieves the value of the environment variable named by the key.
|
||||
// If the variable is present in the environment and not empty, the value is
|
||||
// returned.
|
||||
func StringEnv(key string) (string, error) {
|
||||
val, ok := os.LookupEnv(key)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("environment variable '%s' does not exist", key)
|
||||
}
|
||||
if val == "" {
|
||||
return "", fmt.Errorf("environment variable '%s' is empty", key)
|
||||
}
|
||||
return val, nil
|
||||
}
|
||||
|
||||
// IntEnv relies on [StringEnv] and converts the values if it exists and is not
|
||||
// empty.
|
||||
func IntEnv(key string) (int, error) {
|
||||
val, err := StringEnv(key)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
intVal, err := strconv.Atoi(val)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("get int value of environment variable '%s': %w", key, err)
|
||||
}
|
||||
return intVal, nil
|
||||
}
|
||||
134
pkg/gotenberg/env_test.go
Normal file
134
pkg/gotenberg/env_test.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStringEnv(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
key string
|
||||
setEnv func()
|
||||
expectVal string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
scenario: "non-existing environment variable",
|
||||
key: "NON_EXISTING",
|
||||
expectVal: "",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "empty environment variable",
|
||||
key: "EMPTY_STRING",
|
||||
setEnv: func() {
|
||||
err := os.Setenv("EMPTY_STRING", "")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
},
|
||||
expectVal: "",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "success",
|
||||
key: "EXISTING_STRING_VALUE",
|
||||
setEnv: func() {
|
||||
err := os.Setenv("EXISTING_STRING_VALUE", "foo")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
},
|
||||
expectVal: "foo",
|
||||
expectError: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
if tc.setEnv != nil {
|
||||
tc.setEnv()
|
||||
}
|
||||
|
||||
val, err := StringEnv(tc.key)
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
|
||||
if tc.expectVal != val {
|
||||
t.Errorf("expected value '%s' but got '%s'", tc.expectVal, val)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntEnv(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
key string
|
||||
setEnv func()
|
||||
expectVal int
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
scenario: "empty environment variable",
|
||||
key: "EMPTY_INT",
|
||||
setEnv: func() {
|
||||
err := os.Setenv("EMPTY_INT", "")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
},
|
||||
expectVal: 0,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "non-integer value",
|
||||
key: "NON_INTEGER",
|
||||
setEnv: func() {
|
||||
err := os.Setenv("NON_INTEGER", "foo")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
},
|
||||
expectVal: 0,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "success",
|
||||
key: "EXISTING_INT_VALUE",
|
||||
setEnv: func() {
|
||||
err := os.Setenv("EXISTING_INT_VALUE", "123")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
},
|
||||
expectVal: 123,
|
||||
expectError: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
if tc.setEnv != nil {
|
||||
tc.setEnv()
|
||||
}
|
||||
|
||||
val, err := IntEnv(tc.key)
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
|
||||
if tc.expectVal != val {
|
||||
t.Errorf("expected value %d but got %d", tc.expectVal, val)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
53
pkg/gotenberg/filter.go
Normal file
53
pkg/gotenberg/filter.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/dlclark/regexp2"
|
||||
)
|
||||
|
||||
// ErrFiltered happens if a value is filtered by the [FilterDeadline] function.
|
||||
var ErrFiltered = errors.New("value filtered")
|
||||
|
||||
// FilterDeadline checks if given value is allowed and not denied according to
|
||||
// regex patterns. It returns a [context.DeadlineExceeded] if it takes too long
|
||||
// to process.
|
||||
func FilterDeadline(allowed, denied *regexp2.Regexp, s string, deadline time.Time) error {
|
||||
// FIXME: not ideal to compile everytime, but is there another way to create a clone?
|
||||
if allowed.String() != "" {
|
||||
allow := regexp2.MustCompile(allowed.String(), 0)
|
||||
allow.MatchTimeout = time.Until(deadline)
|
||||
|
||||
ok, err := allow.MatchString(s)
|
||||
if err != nil {
|
||||
if time.Now().After(deadline) {
|
||||
return context.DeadlineExceeded
|
||||
}
|
||||
return fmt.Errorf("'%s' cannot handle '%s': %w", allow.String(), s, err)
|
||||
}
|
||||
if !ok {
|
||||
return fmt.Errorf("'%s' does not match the expression from the allowed list: %w", s, ErrFiltered)
|
||||
}
|
||||
}
|
||||
|
||||
if denied.String() != "" {
|
||||
deny := regexp2.MustCompile(denied.String(), 0)
|
||||
deny.MatchTimeout = time.Until(deadline)
|
||||
|
||||
ok, err := deny.MatchString(s)
|
||||
if err != nil {
|
||||
if time.Now().After(deadline) {
|
||||
return context.DeadlineExceeded
|
||||
}
|
||||
return fmt.Errorf("'%s' cannot handle '%s': %w", deny.String(), s, err)
|
||||
}
|
||||
if ok {
|
||||
return fmt.Errorf("'%s' matches the expression from the denied list: %w", s, ErrFiltered)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
83
pkg/gotenberg/filter_test.go
Normal file
83
pkg/gotenberg/filter_test.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dlclark/regexp2"
|
||||
)
|
||||
|
||||
func TestFilterDeadline(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
allowed *regexp2.Regexp
|
||||
denied *regexp2.Regexp
|
||||
s string
|
||||
deadline time.Time
|
||||
expectError bool
|
||||
expectedError error
|
||||
}{
|
||||
{
|
||||
scenario: "DeadlineExceeded (allowed)",
|
||||
allowed: regexp2.MustCompile("foo", 0),
|
||||
denied: regexp2.MustCompile("", 0),
|
||||
s: "foo",
|
||||
deadline: time.Now().Add(time.Duration(-1) * time.Hour),
|
||||
expectError: true,
|
||||
expectedError: context.DeadlineExceeded,
|
||||
},
|
||||
{
|
||||
scenario: "ErrFiltered (allowed)",
|
||||
allowed: regexp2.MustCompile("foo", 0),
|
||||
denied: regexp2.MustCompile("", 0),
|
||||
s: "bar",
|
||||
deadline: time.Now().Add(time.Duration(5) * time.Second),
|
||||
expectError: true,
|
||||
expectedError: ErrFiltered,
|
||||
},
|
||||
{
|
||||
scenario: "DeadlineExceeded (denied)",
|
||||
allowed: regexp2.MustCompile("", 0),
|
||||
denied: regexp2.MustCompile("foo", 0),
|
||||
s: "foo",
|
||||
deadline: time.Now().Add(time.Duration(-1) * time.Hour),
|
||||
expectError: true,
|
||||
expectedError: context.DeadlineExceeded,
|
||||
},
|
||||
{
|
||||
scenario: "ErrFiltered (denied)",
|
||||
allowed: regexp2.MustCompile("", 0),
|
||||
denied: regexp2.MustCompile("foo", 0),
|
||||
s: "foo",
|
||||
deadline: time.Now().Add(time.Duration(5) * time.Second),
|
||||
expectError: true,
|
||||
expectedError: ErrFiltered,
|
||||
},
|
||||
{
|
||||
scenario: "success",
|
||||
allowed: regexp2.MustCompile("", 0),
|
||||
denied: regexp2.MustCompile("", 0),
|
||||
s: "foo",
|
||||
deadline: time.Now().Add(time.Duration(5) * time.Second),
|
||||
expectError: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
err := FilterDeadline(tc.allowed, tc.denied, tc.s, tc.deadline)
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected an error but got none")
|
||||
}
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectedError != nil && !errors.Is(err, tc.expectedError) {
|
||||
t.Fatalf("expected error %v but got: %v", tc.expectedError, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"github.com/dlclark/regexp2"
|
||||
"github.com/labstack/gommon/bytes"
|
||||
flag "github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// ParsedFlags wraps a flag.FlagSet so that retrieving the typed values is
|
||||
// ParsedFlags wraps a [flag.FlagSet] so that retrieving the typed values is
|
||||
// easier.
|
||||
type ParsedFlags struct {
|
||||
*flag.FlagSet
|
||||
@@ -80,6 +80,28 @@ func (f *ParsedFlags) MustDeprecatedBool(deprecated string, newName string) bool
|
||||
return f.MustBool(newName)
|
||||
}
|
||||
|
||||
// MustInt64 returns the int64 value of a flag given by name.
|
||||
// It panics if an error occurs.
|
||||
func (f *ParsedFlags) MustInt64(name string) int64 {
|
||||
val, err := f.GetInt64(name)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return val
|
||||
}
|
||||
|
||||
// MustDeprecatedInt64 returns the int64 value of a deprecated flag if it was
|
||||
// explicitly set or the int64 value of the new flag.
|
||||
// It panics if an error occurs.
|
||||
func (f *ParsedFlags) MustDeprecatedInt64(deprecated string, newName string) int64 {
|
||||
if f.Changed(deprecated) {
|
||||
return f.MustInt64(deprecated)
|
||||
}
|
||||
|
||||
return f.MustInt64(newName)
|
||||
}
|
||||
|
||||
// MustInt returns the int value of a flag given by name.
|
||||
// It panics if an error occurs.
|
||||
func (f *ParsedFlags) MustInt(name string) int {
|
||||
@@ -146,50 +168,54 @@ func (f *ParsedFlags) MustDeprecatedDuration(deprecated string, newName string)
|
||||
return f.MustDuration(newName)
|
||||
}
|
||||
|
||||
// MustHumanReadableBytesString returns the human-readable bytes string of a
|
||||
// flag given by name.
|
||||
// MustHumanReadableBytes returns the human-readable bytes string of a flag
|
||||
// given by name.
|
||||
// It panics if an error occurs.
|
||||
func (f *ParsedFlags) MustHumanReadableBytesString(name string) string {
|
||||
func (f *ParsedFlags) MustHumanReadableBytes(name string) int64 {
|
||||
val, err := f.GetString(name)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
_, err = bytes.Parse(val)
|
||||
if val == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
b, err := bytes.Parse(val)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return val
|
||||
return b
|
||||
}
|
||||
|
||||
// MustDeprecatedHumanReadableBytesString returns the human-readable bytes
|
||||
// string of a deprecated flag if it was explicitly set or the human-readable
|
||||
// bytes string of the new flag.
|
||||
// MustDeprecatedHumanReadableBytes returns the human-readable bytes of a
|
||||
// deprecated flag if it was explicitly set or the human-readable bytes string
|
||||
// of the new flag.
|
||||
// It panics if an error occurs.
|
||||
func (f *ParsedFlags) MustDeprecatedHumanReadableBytesString(deprecated string, newName string) string {
|
||||
func (f *ParsedFlags) MustDeprecatedHumanReadableBytes(deprecated string, newName string) int64 {
|
||||
if f.Changed(deprecated) {
|
||||
return f.MustHumanReadableBytesString(deprecated)
|
||||
return f.MustHumanReadableBytes(deprecated)
|
||||
}
|
||||
|
||||
return f.MustHumanReadableBytesString(newName)
|
||||
return f.MustHumanReadableBytes(newName)
|
||||
}
|
||||
|
||||
// MustRegexp returns the regular expression of a flag given by name.
|
||||
// It panics if an error occurs.
|
||||
func (f *ParsedFlags) MustRegexp(name string) *regexp.Regexp {
|
||||
func (f *ParsedFlags) MustRegexp(name string) *regexp2.Regexp {
|
||||
val, err := f.GetString(name)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return regexp.MustCompile(val)
|
||||
return regexp2.MustCompile(val, 0)
|
||||
}
|
||||
|
||||
// MustDeprecatedRegexp returns the regular expression of a deprecated flag if
|
||||
// it was explicitly set or the regular expression of the new flag.
|
||||
// It panics if an error occurs.
|
||||
func (f *ParsedFlags) MustDeprecatedRegexp(deprecated string, newName string) *regexp.Regexp {
|
||||
func (f *ParsedFlags) MustDeprecatedRegexp(deprecated string, newName string) *regexp2.Regexp {
|
||||
if f.Changed(deprecated) {
|
||||
return f.MustRegexp(deprecated)
|
||||
}
|
||||
|
||||
@@ -20,23 +20,27 @@ func TestParsedFlags_MustString(t *testing.T) {
|
||||
|
||||
parsedFlags := ParsedFlags{FlagSet: fs}
|
||||
|
||||
for i, tc := range []struct {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
name string
|
||||
expectPanic bool
|
||||
}{
|
||||
{
|
||||
name: "foo",
|
||||
scenario: "success",
|
||||
name: "foo",
|
||||
expectPanic: false,
|
||||
},
|
||||
{
|
||||
scenario: "non-existing flag",
|
||||
name: "bar",
|
||||
expectPanic: true,
|
||||
},
|
||||
} {
|
||||
func() {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
if tc.expectPanic {
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Errorf("test %d: expected panic but got none", i)
|
||||
t.Fatal("expected panic but got none")
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -44,49 +48,55 @@ func TestParsedFlags_MustString(t *testing.T) {
|
||||
if !tc.expectPanic {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("test %d: expected no panic but got: %v", i, r)
|
||||
t.Fatalf("expected no panic but got: %v", r)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
parsedFlags.MustString(tc.name)
|
||||
}()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsedFlags_MustDeprecatedString(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
rawFlags []string
|
||||
expectValue string
|
||||
}{
|
||||
{
|
||||
scenario: "deprecated flag value",
|
||||
rawFlags: []string{"--foo=foo"},
|
||||
expectValue: "foo",
|
||||
},
|
||||
{
|
||||
scenario: "non-deprecated flag value",
|
||||
rawFlags: []string{"--bar=bar"},
|
||||
expectValue: "bar",
|
||||
},
|
||||
{
|
||||
scenario: "deprecated flag value > non-deprecated flag value",
|
||||
rawFlags: []string{"--foo=foo", "--bar=bar"},
|
||||
expectValue: "foo",
|
||||
},
|
||||
} {
|
||||
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
|
||||
fs.String("foo", "", "")
|
||||
fs.String("bar", "", "")
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
|
||||
fs.String("foo", "", "")
|
||||
fs.String("bar", "", "")
|
||||
|
||||
parsedFlags := ParsedFlags{FlagSet: fs}
|
||||
parsedFlags := ParsedFlags{FlagSet: fs}
|
||||
|
||||
err := parsedFlags.Parse(tc.rawFlags)
|
||||
if err != nil {
|
||||
t.Fatalf("test %d: expected no error but got: %v", i, err)
|
||||
}
|
||||
err := parsedFlags.Parse(tc.rawFlags)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
actual := parsedFlags.MustDeprecatedString("foo", "bar")
|
||||
if actual != tc.expectValue {
|
||||
t.Errorf("test %d: expected '%s' but got '%s'", i, tc.expectValue, actual)
|
||||
}
|
||||
actual := parsedFlags.MustDeprecatedString("foo", "bar")
|
||||
if actual != tc.expectValue {
|
||||
t.Errorf("expected '%s' but got '%s'", tc.expectValue, actual)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,23 +111,27 @@ func TestParsedFlags_MustStringSlice(t *testing.T) {
|
||||
|
||||
parsedFlags := ParsedFlags{FlagSet: fs}
|
||||
|
||||
for i, tc := range []struct {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
name string
|
||||
expectPanic bool
|
||||
}{
|
||||
{
|
||||
name: "foo",
|
||||
scenario: "success",
|
||||
name: "foo",
|
||||
expectPanic: false,
|
||||
},
|
||||
{
|
||||
scenario: "non-existing flag",
|
||||
name: "bar",
|
||||
expectPanic: true,
|
||||
},
|
||||
} {
|
||||
func() {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
if tc.expectPanic {
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Errorf("test %d: expected panic but got none", i)
|
||||
t.Fatal("expected panic but got none")
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -125,49 +139,55 @@ func TestParsedFlags_MustStringSlice(t *testing.T) {
|
||||
if !tc.expectPanic {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("test %d: expected no panic but got: %v", i, r)
|
||||
t.Fatalf("expected no panic but got: %v", r)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
parsedFlags.MustStringSlice(tc.name)
|
||||
}()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsedFlags_MustDeprecatedStringSlice(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
rawFlags []string
|
||||
expectValue []string
|
||||
}{
|
||||
{
|
||||
scenario: "deprecated flag value",
|
||||
rawFlags: []string{"--foo=foo"},
|
||||
expectValue: []string{"foo"},
|
||||
},
|
||||
{
|
||||
scenario: "non-deprecated flag value",
|
||||
rawFlags: []string{"--bar=bar"},
|
||||
expectValue: []string{"bar"},
|
||||
},
|
||||
{
|
||||
scenario: "deprecated flag value > non-deprecated flag value",
|
||||
rawFlags: []string{"--foo=foo", "--bar=bar"},
|
||||
expectValue: []string{"foo"},
|
||||
},
|
||||
} {
|
||||
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
|
||||
fs.StringSlice("foo", make([]string, 0), "")
|
||||
fs.StringSlice("bar", make([]string, 0), "")
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
|
||||
fs.StringSlice("foo", make([]string, 0), "")
|
||||
fs.StringSlice("bar", make([]string, 0), "")
|
||||
|
||||
parsedFlags := ParsedFlags{FlagSet: fs}
|
||||
parsedFlags := ParsedFlags{FlagSet: fs}
|
||||
|
||||
err := parsedFlags.Parse(tc.rawFlags)
|
||||
if err != nil {
|
||||
t.Fatalf("test %d: expected no error but got: %v", i, err)
|
||||
}
|
||||
err := parsedFlags.Parse(tc.rawFlags)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
actual := parsedFlags.MustDeprecatedStringSlice("foo", "bar")
|
||||
if !reflect.DeepEqual(actual, tc.expectValue) {
|
||||
t.Errorf("test %d: expected %+v but got %+v", i, tc.expectValue, actual)
|
||||
}
|
||||
actual := parsedFlags.MustDeprecatedStringSlice("foo", "bar")
|
||||
if !reflect.DeepEqual(actual, tc.expectValue) {
|
||||
t.Errorf("expected %+v but got %+v", tc.expectValue, actual)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,23 +202,27 @@ func TestParsedFlags_MustBool(t *testing.T) {
|
||||
|
||||
parsedFlags := ParsedFlags{FlagSet: fs}
|
||||
|
||||
for i, tc := range []struct {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
name string
|
||||
expectPanic bool
|
||||
}{
|
||||
{
|
||||
name: "foo",
|
||||
scenario: "success",
|
||||
name: "foo",
|
||||
expectPanic: false,
|
||||
},
|
||||
{
|
||||
scenario: "non-existing flag",
|
||||
name: "bar",
|
||||
expectPanic: true,
|
||||
},
|
||||
} {
|
||||
func() {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
if tc.expectPanic {
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Errorf("test %d: expected panic but got none", i)
|
||||
t.Fatal("expected panic but got none")
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -206,48 +230,143 @@ func TestParsedFlags_MustBool(t *testing.T) {
|
||||
if !tc.expectPanic {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("test %d: expected no panic but got: %v", i, r)
|
||||
t.Fatalf("expected no panic but got: %v", r)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
parsedFlags.MustBool(tc.name)
|
||||
}()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsedFlags_MustDeprecatedBool(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
rawFlags []string
|
||||
expectValue bool
|
||||
}{
|
||||
{
|
||||
scenario: "deprecated flag value",
|
||||
rawFlags: []string{"--foo=true"},
|
||||
expectValue: true,
|
||||
},
|
||||
{
|
||||
scenario: "non-deprecated flag value",
|
||||
rawFlags: []string{"--bar=false"},
|
||||
expectValue: false,
|
||||
},
|
||||
{
|
||||
scenario: "deprecated flag value > non-deprecated flag value",
|
||||
rawFlags: []string{"--foo=true", "--bar=false"},
|
||||
expectValue: true,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
|
||||
fs.Bool("foo", false, "")
|
||||
fs.Bool("bar", true, "")
|
||||
|
||||
parsedFlags := ParsedFlags{FlagSet: fs}
|
||||
|
||||
err := parsedFlags.Parse(tc.rawFlags)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
actual := parsedFlags.MustDeprecatedBool("foo", "bar")
|
||||
if actual != tc.expectValue {
|
||||
t.Errorf("expected %v but got %v", tc.expectValue, actual)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsedFlags_MustInt64(t *testing.T) {
|
||||
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
|
||||
fs.Int64("foo", 0, "")
|
||||
|
||||
err := fs.Parse([]string{"--foo=1"})
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
parsedFlags := ParsedFlags{FlagSet: fs}
|
||||
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
name string
|
||||
expectPanic bool
|
||||
}{
|
||||
{
|
||||
scenario: "success",
|
||||
name: "foo",
|
||||
expectPanic: false,
|
||||
},
|
||||
{
|
||||
scenario: "non-existing flag",
|
||||
name: "bar",
|
||||
expectPanic: true,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
if tc.expectPanic {
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Fatal("expected panic but got none")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
if !tc.expectPanic {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("expected no panic but got: %v", r)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
parsedFlags.MustInt64(tc.name)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsedFlags_MustDeprecatedInt64(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
rawFlags []string
|
||||
expectValue int64
|
||||
}{
|
||||
{
|
||||
scenario: "deprecated flag value",
|
||||
rawFlags: []string{"--foo=1"},
|
||||
expectValue: 1,
|
||||
},
|
||||
{
|
||||
scenario: "non-deprecated flag value",
|
||||
rawFlags: []string{"--bar=2"},
|
||||
expectValue: 2,
|
||||
},
|
||||
{
|
||||
scenario: "deprecated flag value > non-deprecated flag value",
|
||||
rawFlags: []string{"--foo=1", "--bar=2"},
|
||||
expectValue: 1,
|
||||
},
|
||||
} {
|
||||
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
|
||||
fs.Bool("foo", false, "")
|
||||
fs.Bool("bar", true, "")
|
||||
fs.Int64("foo", 0, "")
|
||||
fs.Int64("bar", 0, "")
|
||||
|
||||
parsedFlags := ParsedFlags{FlagSet: fs}
|
||||
|
||||
err := parsedFlags.Parse(tc.rawFlags)
|
||||
if err != nil {
|
||||
t.Fatalf("test %d: expected no error but got: %v", i, err)
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
actual := parsedFlags.MustDeprecatedBool("foo", "bar")
|
||||
actual := parsedFlags.MustDeprecatedInt64("foo", "bar")
|
||||
if actual != tc.expectValue {
|
||||
t.Errorf("test %d: expected %v but got %v", i, tc.expectValue, actual)
|
||||
t.Errorf("expected %d but got %d", tc.expectValue, actual)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -263,23 +382,27 @@ func TestParsedFlags_MustInt(t *testing.T) {
|
||||
|
||||
parsedFlags := ParsedFlags{FlagSet: fs}
|
||||
|
||||
for i, tc := range []struct {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
name string
|
||||
expectPanic bool
|
||||
}{
|
||||
{
|
||||
name: "foo",
|
||||
scenario: "success",
|
||||
name: "foo",
|
||||
expectPanic: false,
|
||||
},
|
||||
{
|
||||
scenario: "non-existing flag",
|
||||
name: "bar",
|
||||
expectPanic: true,
|
||||
},
|
||||
} {
|
||||
func() {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
if tc.expectPanic {
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Errorf("test %d: expected panic but got none", i)
|
||||
t.Fatal("expected panic but got none")
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -287,49 +410,55 @@ func TestParsedFlags_MustInt(t *testing.T) {
|
||||
if !tc.expectPanic {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("test %d: expected no panic but got: %v", i, r)
|
||||
t.Fatalf("expected no panic but got: %v", r)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
parsedFlags.MustInt(tc.name)
|
||||
}()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsedFlags_MustDeprecatedInt(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
rawFlags []string
|
||||
expectValue int
|
||||
}{
|
||||
{
|
||||
scenario: "deprecated flag value",
|
||||
rawFlags: []string{"--foo=1"},
|
||||
expectValue: 1,
|
||||
},
|
||||
{
|
||||
scenario: "non-deprecated flag value",
|
||||
rawFlags: []string{"--bar=2"},
|
||||
expectValue: 2,
|
||||
},
|
||||
{
|
||||
scenario: "deprecated flag value > non-deprecated flag value",
|
||||
rawFlags: []string{"--foo=1", "--bar=2"},
|
||||
expectValue: 1,
|
||||
},
|
||||
} {
|
||||
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
|
||||
fs.Int("foo", 0, "")
|
||||
fs.Int("bar", 0, "")
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
|
||||
fs.Int("foo", 0, "")
|
||||
fs.Int("bar", 0, "")
|
||||
|
||||
parsedFlags := ParsedFlags{FlagSet: fs}
|
||||
parsedFlags := ParsedFlags{FlagSet: fs}
|
||||
|
||||
err := parsedFlags.Parse(tc.rawFlags)
|
||||
if err != nil {
|
||||
t.Fatalf("test %d: expected no error but got: %v", i, err)
|
||||
}
|
||||
err := parsedFlags.Parse(tc.rawFlags)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
actual := parsedFlags.MustDeprecatedInt("foo", "bar")
|
||||
if actual != tc.expectValue {
|
||||
t.Errorf("test %d: expected %d but got %d", i, tc.expectValue, actual)
|
||||
}
|
||||
actual := parsedFlags.MustDeprecatedInt("foo", "bar")
|
||||
if actual != tc.expectValue {
|
||||
t.Errorf("expected %d but got %d", tc.expectValue, actual)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -344,23 +473,27 @@ func TestParsedFlags_MustFloat64(t *testing.T) {
|
||||
|
||||
parsedFlags := ParsedFlags{FlagSet: fs}
|
||||
|
||||
for i, tc := range []struct {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
name string
|
||||
expectPanic bool
|
||||
}{
|
||||
{
|
||||
name: "foo",
|
||||
scenario: "success",
|
||||
name: "foo",
|
||||
expectPanic: false,
|
||||
},
|
||||
{
|
||||
scenario: "non-existing flag",
|
||||
name: "bar",
|
||||
expectPanic: true,
|
||||
},
|
||||
} {
|
||||
func() {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
if tc.expectPanic {
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Errorf("test %d: expected panic but got none", i)
|
||||
t.Fatal("expected panic but got none")
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -368,49 +501,55 @@ func TestParsedFlags_MustFloat64(t *testing.T) {
|
||||
if !tc.expectPanic {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("test %d: expected no panic but got: %v", i, r)
|
||||
t.Fatalf("expected no panic but got: %v", r)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
parsedFlags.MustFloat64(tc.name)
|
||||
}()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsedFlags_MustDeprecatedFloat64(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
rawFlags []string
|
||||
expectValue float64
|
||||
}{
|
||||
{
|
||||
scenario: "deprecated flag value",
|
||||
rawFlags: []string{"--foo=1.0"},
|
||||
expectValue: 1.0,
|
||||
},
|
||||
{
|
||||
scenario: "non-deprecated flag value",
|
||||
rawFlags: []string{"--bar=2.0"},
|
||||
expectValue: 2.0,
|
||||
},
|
||||
{
|
||||
scenario: "deprecated flag value > non-deprecated flag value",
|
||||
rawFlags: []string{"--foo=1.0", "--bar=2.0"},
|
||||
expectValue: 1.0,
|
||||
},
|
||||
} {
|
||||
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
|
||||
fs.Float64("foo", 0, "")
|
||||
fs.Float64("bar", 0, "")
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
|
||||
fs.Float64("foo", 0, "")
|
||||
fs.Float64("bar", 0, "")
|
||||
|
||||
parsedFlags := ParsedFlags{FlagSet: fs}
|
||||
parsedFlags := ParsedFlags{FlagSet: fs}
|
||||
|
||||
err := parsedFlags.Parse(tc.rawFlags)
|
||||
if err != nil {
|
||||
t.Fatalf("test %d: expected no error but got: %v", i, err)
|
||||
}
|
||||
err := parsedFlags.Parse(tc.rawFlags)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
actual := parsedFlags.MustDeprecatedFloat64("foo", "bar")
|
||||
if actual != tc.expectValue {
|
||||
t.Errorf("test %d: expected %f but got %f", i, tc.expectValue, actual)
|
||||
}
|
||||
actual := parsedFlags.MustDeprecatedFloat64("foo", "bar")
|
||||
if actual != tc.expectValue {
|
||||
t.Errorf("expected %f but got %f", tc.expectValue, actual)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -425,23 +564,27 @@ func TestParsedFlags_MustDuration(t *testing.T) {
|
||||
|
||||
parsedFlags := ParsedFlags{FlagSet: fs}
|
||||
|
||||
for i, tc := range []struct {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
name string
|
||||
expectPanic bool
|
||||
}{
|
||||
{
|
||||
name: "foo",
|
||||
scenario: "success",
|
||||
name: "foo",
|
||||
expectPanic: false,
|
||||
},
|
||||
{
|
||||
scenario: "non-existing flag",
|
||||
name: "bar",
|
||||
expectPanic: true,
|
||||
},
|
||||
} {
|
||||
func() {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
if tc.expectPanic {
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Errorf("test %d: expected panic but got none", i)
|
||||
t.Fatal("expected panic but got none")
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -449,56 +592,63 @@ func TestParsedFlags_MustDuration(t *testing.T) {
|
||||
if !tc.expectPanic {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("test %d: expected no panic but got: %v", i, r)
|
||||
t.Fatalf("expected no panic but got: %v", r)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
parsedFlags.MustDuration(tc.name)
|
||||
}()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsedFlags_MustDeprecatedDuration(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
rawFlags []string
|
||||
expectValue time.Duration
|
||||
}{
|
||||
{
|
||||
scenario: "deprecated flag value",
|
||||
rawFlags: []string{"--foo=1s"},
|
||||
expectValue: time.Duration(1) * time.Second,
|
||||
},
|
||||
{
|
||||
scenario: "non-deprecated flag value",
|
||||
rawFlags: []string{"--bar=2s"},
|
||||
expectValue: time.Duration(2) * time.Second,
|
||||
},
|
||||
{
|
||||
scenario: "deprecated flag value > non-deprecated flag value",
|
||||
rawFlags: []string{"--foo=1s", "--bar=2s"},
|
||||
expectValue: time.Duration(1) * time.Second,
|
||||
},
|
||||
} {
|
||||
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
|
||||
fs.Duration("foo", 0, "")
|
||||
fs.Duration("bar", 0, "")
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
|
||||
fs.Duration("foo", 0, "")
|
||||
fs.Duration("bar", 0, "")
|
||||
|
||||
parsedFlags := ParsedFlags{FlagSet: fs}
|
||||
parsedFlags := ParsedFlags{FlagSet: fs}
|
||||
|
||||
err := parsedFlags.Parse(tc.rawFlags)
|
||||
if err != nil {
|
||||
t.Fatalf("test %d: expected no error but got: %v", i, err)
|
||||
}
|
||||
err := parsedFlags.Parse(tc.rawFlags)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
actual := parsedFlags.MustDeprecatedDuration("foo", "bar")
|
||||
if actual != tc.expectValue {
|
||||
t.Errorf("test %d: expected '%s' but got '%s'", i, tc.expectValue, actual)
|
||||
}
|
||||
actual := parsedFlags.MustDeprecatedDuration("foo", "bar")
|
||||
if actual != tc.expectValue {
|
||||
t.Errorf("expected '%s' but got '%s'", tc.expectValue, actual)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsedFlags_MustHumanReadableBytesString(t *testing.T) {
|
||||
func TestParsedFlags_MustHumanReadableBytes(t *testing.T) {
|
||||
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
|
||||
fs.String("foo", "1MB", "")
|
||||
fs.String("bar", "1MB", "")
|
||||
fs.String("qux", "", "")
|
||||
|
||||
err := fs.Parse([]string{"--foo=1GB", "--bar=foo"})
|
||||
if err != nil {
|
||||
@@ -507,27 +657,32 @@ func TestParsedFlags_MustHumanReadableBytesString(t *testing.T) {
|
||||
|
||||
parsedFlags := ParsedFlags{FlagSet: fs}
|
||||
|
||||
for i, tc := range []struct {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
name string
|
||||
expectPanic bool
|
||||
}{
|
||||
{
|
||||
name: "foo",
|
||||
scenario: "success",
|
||||
name: "foo",
|
||||
expectPanic: false,
|
||||
},
|
||||
{
|
||||
scenario: "non-existing flag",
|
||||
name: "bar",
|
||||
expectPanic: true,
|
||||
},
|
||||
{
|
||||
name: "baz",
|
||||
expectPanic: true,
|
||||
scenario: "success: empty value",
|
||||
name: "qux",
|
||||
expectPanic: false,
|
||||
},
|
||||
} {
|
||||
func() {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
if tc.expectPanic {
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Errorf("test %d: expected panic but got none", i)
|
||||
t.Fatal("expected panic but got none")
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -535,49 +690,55 @@ func TestParsedFlags_MustHumanReadableBytesString(t *testing.T) {
|
||||
if !tc.expectPanic {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("test %d: expected no panic but got: %v", i, r)
|
||||
t.Fatalf("expected no panic but got: %v", r)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
parsedFlags.MustHumanReadableBytesString(tc.name)
|
||||
}()
|
||||
parsedFlags.MustHumanReadableBytes(tc.name)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsedFlags_MustDeprecatedHumanReadableBytesString(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
func TestParsedFlags_MustDeprecatedHumanReadableBytes(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
rawFlags []string
|
||||
expectValue string
|
||||
expectValue int64
|
||||
}{
|
||||
{
|
||||
scenario: "deprecated flag value",
|
||||
rawFlags: []string{"--foo=1MB"},
|
||||
expectValue: "1MB",
|
||||
expectValue: 1000000,
|
||||
},
|
||||
{
|
||||
scenario: "non-deprecated flag value",
|
||||
rawFlags: []string{"--bar=2MB"},
|
||||
expectValue: "2MB",
|
||||
expectValue: 2000000,
|
||||
},
|
||||
{
|
||||
scenario: "deprecated flag value > non-deprecated flag value",
|
||||
rawFlags: []string{"--foo=1MB", "--bar=2MB"},
|
||||
expectValue: "1MB",
|
||||
expectValue: 1000000,
|
||||
},
|
||||
} {
|
||||
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
|
||||
fs.String("foo", "", "")
|
||||
fs.String("bar", "", "")
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
|
||||
fs.String("foo", "", "")
|
||||
fs.String("bar", "", "")
|
||||
|
||||
parsedFlags := ParsedFlags{FlagSet: fs}
|
||||
parsedFlags := ParsedFlags{FlagSet: fs}
|
||||
|
||||
err := parsedFlags.Parse(tc.rawFlags)
|
||||
if err != nil {
|
||||
t.Fatalf("test %d: expected no error but got: %v", i, err)
|
||||
}
|
||||
err := parsedFlags.Parse(tc.rawFlags)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
actual := parsedFlags.MustDeprecatedHumanReadableBytesString("foo", "bar")
|
||||
if actual != tc.expectValue {
|
||||
t.Errorf("test %d: expected '%s' but got '%s'", i, tc.expectValue, actual)
|
||||
}
|
||||
actual := parsedFlags.MustDeprecatedHumanReadableBytes("foo", "bar")
|
||||
if actual != tc.expectValue {
|
||||
t.Errorf("expected %d but got %d", tc.expectValue, actual)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -593,27 +754,27 @@ func TestParsedFlags_MustRegexp(t *testing.T) {
|
||||
|
||||
parsedFlags := ParsedFlags{FlagSet: fs}
|
||||
|
||||
for i, tc := range []struct {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
name string
|
||||
expectPanic bool
|
||||
}{
|
||||
{
|
||||
name: "foo",
|
||||
scenario: "success",
|
||||
name: "foo",
|
||||
expectPanic: false,
|
||||
},
|
||||
{
|
||||
scenario: "non-existing flag",
|
||||
name: "bar",
|
||||
expectPanic: true,
|
||||
},
|
||||
{
|
||||
name: "baz",
|
||||
expectPanic: true,
|
||||
},
|
||||
} {
|
||||
func() {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
if tc.expectPanic {
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Errorf("test %d: expected panic but got none", i)
|
||||
t.Fatal("expected panic but got none")
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -621,48 +782,54 @@ func TestParsedFlags_MustRegexp(t *testing.T) {
|
||||
if !tc.expectPanic {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("test %d: expected no panic but got: %v", i, r)
|
||||
t.Fatalf("expected no panic but got: %v", r)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
parsedFlags.MustRegexp(tc.name)
|
||||
}()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsedFlags_MustDeprecatedRegexp(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
rawFlags []string
|
||||
expectValue *regexp.Regexp
|
||||
}{
|
||||
{
|
||||
scenario: "deprecated flag value",
|
||||
rawFlags: []string{"--foo=foo"},
|
||||
expectValue: regexp.MustCompile("foo"),
|
||||
},
|
||||
{
|
||||
scenario: "non-deprecated flag value",
|
||||
rawFlags: []string{"--bar=bar"},
|
||||
expectValue: regexp.MustCompile("bar"),
|
||||
},
|
||||
{
|
||||
scenario: "deprecated flag value > non-deprecated flag value",
|
||||
rawFlags: []string{"--foo=foo", "--bar=bar"},
|
||||
expectValue: regexp.MustCompile("foo"),
|
||||
},
|
||||
} {
|
||||
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
|
||||
fs.String("foo", "", "")
|
||||
fs.String("bar", "", "")
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
|
||||
fs.String("foo", "", "")
|
||||
fs.String("bar", "", "")
|
||||
|
||||
parsedFlags := ParsedFlags{FlagSet: fs}
|
||||
parsedFlags := ParsedFlags{FlagSet: fs}
|
||||
|
||||
err := parsedFlags.Parse(tc.rawFlags)
|
||||
if err != nil {
|
||||
t.Fatalf("test %d: expected no error but got: %v", i, err)
|
||||
}
|
||||
err := parsedFlags.Parse(tc.rawFlags)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
actual := parsedFlags.MustDeprecatedRegexp("foo", "bar")
|
||||
if actual.String() != tc.expectValue.String() {
|
||||
t.Errorf("test %d: expected '%s' but got '%s'", i, tc.expectValue.String(), actual.String())
|
||||
}
|
||||
actual := parsedFlags.MustDeprecatedRegexp("foo", "bar")
|
||||
if actual.String() != tc.expectValue.String() {
|
||||
t.Errorf("expected '%s' but got '%s'", tc.expectValue.String(), actual.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,27 +7,55 @@ import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// TmpPath returns the default directory to use for temporary files and
|
||||
// directories. Most if not all files and directories created by the
|
||||
// application and its dependencies must be based on this default directory.
|
||||
func TmpPath() string {
|
||||
return os.TempDir()
|
||||
// FileSystem provides utilities for managing temporary directories. It creates
|
||||
// unique directory names based on UUIDs to ensure isolation of temporary files
|
||||
// for different modules.
|
||||
type FileSystem struct {
|
||||
workingDir string
|
||||
}
|
||||
|
||||
// NewDirPath returns a random absolute path based on the temporary path.
|
||||
func NewDirPath() string {
|
||||
return fmt.Sprintf("%s/%s", TmpPath(), uuid.New())
|
||||
// NewFileSystem initializes a new [FileSystem] instance with a unique working
|
||||
// directory.
|
||||
func NewFileSystem() *FileSystem {
|
||||
return &FileSystem{
|
||||
workingDir: uuid.NewString(),
|
||||
}
|
||||
}
|
||||
|
||||
// MkdirAll creates a random directory based on the temporary path and
|
||||
// returns its absolute path.
|
||||
func MkdirAll() (string, error) {
|
||||
path := NewDirPath()
|
||||
// WorkingDir returns the unique name of the working directory.
|
||||
func (fs *FileSystem) WorkingDir() string {
|
||||
return fs.workingDir
|
||||
}
|
||||
|
||||
err := os.MkdirAll(path, 0755)
|
||||
// WorkingDirPath constructs and returns the full path to the working directory
|
||||
// inside the system's temporary directory.
|
||||
func (fs *FileSystem) WorkingDirPath() string {
|
||||
return fmt.Sprintf("%s/%s", os.TempDir(), fs.workingDir)
|
||||
}
|
||||
|
||||
// NewDirPath generates a new unique path for a directory inside the working
|
||||
// directory.
|
||||
func (fs *FileSystem) NewDirPath() string {
|
||||
return fmt.Sprintf("%s/%s", fs.WorkingDirPath(), uuid.NewString())
|
||||
}
|
||||
|
||||
// MkdirAll creates a new unique directory inside the working directory and
|
||||
// returns its path. If the directory creation fails, an error is returned.
|
||||
func (fs *FileSystem) MkdirAll() (string, error) {
|
||||
path := fs.NewDirPath()
|
||||
|
||||
err := os.MkdirAll(path, 0o755)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create directory %s: %w", path, err)
|
||||
}
|
||||
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// PathRename defines the method signature for renaming files. Implement this
|
||||
// interface if you don't want to rely on [os.Rename], notably for testing
|
||||
// purpose.
|
||||
type PathRename interface {
|
||||
// Rename uses the same signature as [os.Rename].
|
||||
Rename(oldpath, newpath string) error
|
||||
}
|
||||
|
||||
@@ -1,59 +1,55 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTmpPath(t *testing.T) {
|
||||
osTempDir := os.TempDir()
|
||||
tmpPath := TmpPath()
|
||||
func TestFileSystem_WorkingDir(t *testing.T) {
|
||||
fs := NewFileSystem()
|
||||
dirName := fs.WorkingDir()
|
||||
|
||||
if tmpPath != osTempDir {
|
||||
t.Errorf("expected path '%s' but got '%s'", osTempDir, tmpPath)
|
||||
if dirName == "" {
|
||||
t.Error("expected directory name but got empty string")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDirPath(t *testing.T) {
|
||||
newDirPath := NewDirPath()
|
||||
tmpPath := TmpPath()
|
||||
func TestFileSystem_WorkingDirPath(t *testing.T) {
|
||||
fs := NewFileSystem()
|
||||
expectedPath := fmt.Sprintf("%s/%s", os.TempDir(), fs.WorkingDir())
|
||||
|
||||
if !strings.HasPrefix(newDirPath, tmpPath) {
|
||||
t.Fatalf("expected path '%s' to start with '%s'", newDirPath, tmpPath)
|
||||
}
|
||||
|
||||
newDirPaths := make([]string, 1000)
|
||||
for i := range newDirPaths {
|
||||
newDirPaths[i] = NewDirPath()
|
||||
}
|
||||
|
||||
for i, newDirPath := range newDirPaths {
|
||||
for j, comparison := range newDirPaths {
|
||||
if i == j {
|
||||
continue
|
||||
}
|
||||
|
||||
if newDirPath == comparison {
|
||||
t.Fatalf("expected path '%s' (index %d) to be unique, but found an identical path on index %d", newDirPath, i, j)
|
||||
}
|
||||
}
|
||||
if fs.WorkingDirPath() != expectedPath {
|
||||
t.Errorf("expected path '%s' but got '%s'", expectedPath, fs.WorkingDirPath())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMkdirAll(t *testing.T) {
|
||||
path, err := MkdirAll()
|
||||
func TestFileSystem_NewDirPath(t *testing.T) {
|
||||
fs := NewFileSystem()
|
||||
newDir := fs.NewDirPath()
|
||||
expectedPrefix := fs.WorkingDirPath()
|
||||
|
||||
if !strings.HasPrefix(newDir, expectedPrefix) {
|
||||
t.Errorf("expected new directory to start with '%s' but got '%s'", expectedPrefix, newDir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileSystem_MkdirAll(t *testing.T) {
|
||||
fs := NewFileSystem()
|
||||
|
||||
newPath, err := fs.MkdirAll()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
tmpPath := TmpPath()
|
||||
if !strings.HasPrefix(path, tmpPath) {
|
||||
t.Fatalf("expected path '%s' to start with '%s'", path, tmpPath)
|
||||
_, err = os.Stat(newPath)
|
||||
if os.IsNotExist(err) {
|
||||
t.Errorf("expected directory '%s' to exist but it doesn't", newPath)
|
||||
}
|
||||
|
||||
_, err = os.Stat(path)
|
||||
if os.IsNotExist(err) {
|
||||
t.Errorf("expected path '%s' to exist but got: %v", path, err)
|
||||
err = os.RemoveAll(fs.WorkingDirPath())
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error while cleaning up but got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
53
pkg/gotenberg/gc.go
Normal file
53
pkg/gotenberg/gc.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// GarbageCollect scans the root path and deletes files or directories with
|
||||
// names containing specific substrings.
|
||||
func GarbageCollect(logger *zap.Logger, rootPath string, includeSubstr []string) error {
|
||||
logger = logger.Named("gc")
|
||||
|
||||
// To make sure that the next Walk method stays on
|
||||
// the root level of the considered path, we have to
|
||||
// return a filepath.SkipDir error if the current path
|
||||
// is a directory.
|
||||
skipDirOrNil := func(info os.FileInfo) error {
|
||||
if info.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return filepath.Walk(rootPath, func(path string, info os.FileInfo, pathErr error) error {
|
||||
if pathErr != nil {
|
||||
return pathErr
|
||||
}
|
||||
|
||||
if path == rootPath {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, substr := range includeSubstr {
|
||||
if strings.Contains(info.Name(), substr) || path == substr {
|
||||
err := os.RemoveAll(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("garbage collect '%s': %w", path, err)
|
||||
}
|
||||
|
||||
logger.Debug(fmt.Sprintf("'%s' removed", path))
|
||||
|
||||
return skipDirOrNil(info)
|
||||
}
|
||||
}
|
||||
|
||||
return skipDirOrNil(info)
|
||||
})
|
||||
}
|
||||
98
pkg/gotenberg/gc_test.go
Normal file
98
pkg/gotenberg/gc_test.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestGarbageCollect(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
rootPath string
|
||||
includeSubstr []string
|
||||
expectError bool
|
||||
expectNotExists []string
|
||||
expectExists []string
|
||||
}{
|
||||
{
|
||||
scenario: "root path does not exist",
|
||||
rootPath: uuid.NewString(),
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "remove include substrings",
|
||||
rootPath: func() string {
|
||||
path := fmt.Sprintf("%s/a_directory", os.TempDir())
|
||||
|
||||
err := os.MkdirAll(path, 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
|
||||
}
|
||||
|
||||
err = os.WriteFile(fmt.Sprintf("%s/a_foo_file", path), []byte{1}, 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
err = os.WriteFile(fmt.Sprintf("%s/a_bar_file", path), []byte{1}, 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
err = os.WriteFile(fmt.Sprintf("%s/a_baz_file", path), []byte{1}, 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
return path
|
||||
}(),
|
||||
includeSubstr: []string{"foo", fmt.Sprintf("%s/a_directory/a_bar_file", os.TempDir())},
|
||||
expectError: false,
|
||||
expectExists: []string{"a_baz_file"},
|
||||
expectNotExists: []string{"a_foo_file", "a_bar_file"},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
defer func() {
|
||||
err := os.RemoveAll(tc.rootPath)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error while cleaning up but got: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
err := GarbageCollect(zap.NewNop(), tc.rootPath, tc.includeSubstr)
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
|
||||
if tc.expectError && err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, name := range tc.expectNotExists {
|
||||
path := fmt.Sprintf("%s/%s", tc.rootPath, name)
|
||||
_, err = os.Stat(path)
|
||||
if !os.IsNotExist(err) {
|
||||
t.Errorf("expected '%s' not to exist but it does: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, name := range tc.expectExists {
|
||||
path := fmt.Sprintf("%s/%s", tc.rootPath, name)
|
||||
_, err = os.Stat(path)
|
||||
if os.IsNotExist(err) {
|
||||
t.Errorf("expected '%s' to exist but it does not: %v", path, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
package gotenberg
|
||||
|
||||
import "go.uber.org/zap"
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/hashicorp/go-retryablehttp"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// LoggerProvider is an interface for a module that supplies a method for
|
||||
// creating a zap.Logger instance for use by other modules.
|
||||
// creating a [zap.Logger] instance for use by other modules.
|
||||
//
|
||||
// func (m *YourModule) Provision(ctx *gotenberg.Context) error {
|
||||
// provider, _ := ctx.Module(new(gotenberg.LoggerProvider))
|
||||
@@ -12,3 +17,41 @@ import "go.uber.org/zap"
|
||||
type LoggerProvider interface {
|
||||
Logger(mod Module) (*zap.Logger, error)
|
||||
}
|
||||
|
||||
// LeveledLogger is wrapper around a [zap.Logger] so that it may be used by a
|
||||
// [retryablehttp.Client].
|
||||
type LeveledLogger struct {
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewLeveledLogger instantiates a [LeveledLogger].
|
||||
func NewLeveledLogger(logger *zap.Logger) *LeveledLogger {
|
||||
return &LeveledLogger{
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Error logs a message at error level using the wrapped zap.Logger.
|
||||
func (leveled LeveledLogger) Error(msg string, keysAndValues ...interface{}) {
|
||||
leveled.logger.Error(fmt.Sprintf("%s: %+v", msg, keysAndValues))
|
||||
}
|
||||
|
||||
// Warn logs a message at warning level using the wrapped zap.Logger.
|
||||
func (leveled LeveledLogger) Warn(msg string, keysAndValues ...interface{}) {
|
||||
leveled.logger.Warn(fmt.Sprintf("%s: %+v", msg, keysAndValues))
|
||||
}
|
||||
|
||||
// Info logs a message at info level using the wrapped zap.Logger.
|
||||
func (leveled LeveledLogger) Info(msg string, keysAndValues ...interface{}) {
|
||||
leveled.logger.Info(fmt.Sprintf("%s: %+v", msg, keysAndValues))
|
||||
}
|
||||
|
||||
// Debug logs a message at debug level using the wrapped zap.Logger.
|
||||
func (leveled LeveledLogger) Debug(msg string, keysAndValues ...interface{}) {
|
||||
leveled.logger.Debug(fmt.Sprintf("%s: %+v", msg, keysAndValues))
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ retryablehttp.LeveledLogger = (*LeveledLogger)(nil)
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package webhook
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"testing"
|
||||
@@ -7,17 +7,17 @@ import (
|
||||
)
|
||||
|
||||
func TestLeveledLogger_Error(t *testing.T) {
|
||||
leveledLogger{logger: zap.NewNop()}.Error("foo")
|
||||
NewLeveledLogger(zap.NewNop()).Error("foo")
|
||||
}
|
||||
|
||||
func TestLeveledLogger_Warn(t *testing.T) {
|
||||
leveledLogger{logger: zap.NewNop()}.Warn("foo")
|
||||
NewLeveledLogger(zap.NewNop()).Warn("foo")
|
||||
}
|
||||
|
||||
func TestLeveledLogger_Info(t *testing.T) {
|
||||
leveledLogger{logger: zap.NewNop()}.Info("foo")
|
||||
NewLeveledLogger(zap.NewNop()).Info("foo")
|
||||
}
|
||||
|
||||
func TestLeveledLogger_Debug(t *testing.T) {
|
||||
leveledLogger{logger: zap.NewNop()}.Debug("foo")
|
||||
NewLeveledLogger(zap.NewNop()).Debug("foo")
|
||||
}
|
||||
@@ -15,7 +15,7 @@ type Metric struct {
|
||||
Read func() float64
|
||||
}
|
||||
|
||||
// MetricsProvider is a module interface which provides a list of Metric.
|
||||
// MetricsProvider is a module interface which provides a list of [Metric].
|
||||
//
|
||||
// func (m *YourModule) Provision(ctx *gotenberg.Context) error {
|
||||
// provider, _ := ctx.Module(new(gotenberg.MetricsProvider))
|
||||
|
||||
@@ -6,61 +6,154 @@ import (
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// ModuleMock is a mock for the Module interface.
|
||||
// ModuleMock is a mock for the [Module] interface.
|
||||
type ModuleMock struct {
|
||||
DescriptorMock func() ModuleDescriptor
|
||||
}
|
||||
|
||||
func (mod ModuleMock) Descriptor() ModuleDescriptor {
|
||||
func (mod *ModuleMock) Descriptor() ModuleDescriptor {
|
||||
return mod.DescriptorMock()
|
||||
}
|
||||
|
||||
// ValidatorMock is a mock for the Validator interface.
|
||||
// ProvisionerMock is a mock for the [Provisioner] interface.
|
||||
type ProvisionerMock struct {
|
||||
ProvisionMock func(*Context) error
|
||||
}
|
||||
|
||||
func (mod *ProvisionerMock) Provision(ctx *Context) error {
|
||||
return mod.ProvisionMock(ctx)
|
||||
}
|
||||
|
||||
// ValidatorMock is a mock for the [Validator] interface.
|
||||
type ValidatorMock struct {
|
||||
ValidateMock func() error
|
||||
}
|
||||
|
||||
func (mod ValidatorMock) Validate() error {
|
||||
func (mod *ValidatorMock) Validate() error {
|
||||
return mod.ValidateMock()
|
||||
}
|
||||
|
||||
// PDFEngineMock is a mock for the PDFEngine interface.
|
||||
type PDFEngineMock struct {
|
||||
MergeMock func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error
|
||||
ConvertMock func(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error
|
||||
// PdfEngineMock is a mock for the [PdfEngine] interface.
|
||||
type PdfEngineMock struct {
|
||||
MergeMock func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error
|
||||
ConvertMock func(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error
|
||||
ReadMetadataMock func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error)
|
||||
WriteMetadataMock func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error
|
||||
}
|
||||
|
||||
func (engine PDFEngineMock) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
||||
func (engine *PdfEngineMock) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
||||
return engine.MergeMock(ctx, logger, inputPaths, outputPath)
|
||||
}
|
||||
|
||||
func (engine PDFEngineMock) Convert(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error {
|
||||
return engine.ConvertMock(ctx, logger, format, inputPath, outputPath)
|
||||
func (engine *PdfEngineMock) Convert(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error {
|
||||
return engine.ConvertMock(ctx, logger, formats, inputPath, outputPath)
|
||||
}
|
||||
|
||||
// PDFEngineProviderMock is a mock for the PDFEngineProvider interface.
|
||||
type PDFEngineProviderMock struct {
|
||||
PDFEngineMock func() (PDFEngine, error)
|
||||
func (engine *PdfEngineMock) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
|
||||
return engine.ReadMetadataMock(ctx, logger, inputPath)
|
||||
}
|
||||
|
||||
func (provider PDFEngineProviderMock) PDFEngine() (PDFEngine, error) {
|
||||
return provider.PDFEngineMock()
|
||||
func (engine *PdfEngineMock) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
|
||||
return engine.WriteMetadataMock(ctx, logger, metadata, inputPath)
|
||||
}
|
||||
|
||||
// LoggerProviderMock is a mock for the LoggerProvider interface.
|
||||
// PdfEngineProviderMock is a mock for the [PdfEngineProvider] interface.
|
||||
type PdfEngineProviderMock struct {
|
||||
PdfEngineMock func() (PdfEngine, error)
|
||||
}
|
||||
|
||||
func (provider *PdfEngineProviderMock) PdfEngine() (PdfEngine, error) {
|
||||
return provider.PdfEngineMock()
|
||||
}
|
||||
|
||||
// ProcessMock is a mock for the [Process] interface.
|
||||
type ProcessMock struct {
|
||||
StartMock func(logger *zap.Logger) error
|
||||
StopMock func(logger *zap.Logger) error
|
||||
HealthyMock func(logger *zap.Logger) bool
|
||||
}
|
||||
|
||||
func (p *ProcessMock) Start(logger *zap.Logger) error {
|
||||
return p.StartMock(logger)
|
||||
}
|
||||
|
||||
func (p *ProcessMock) Stop(logger *zap.Logger) error {
|
||||
return p.StopMock(logger)
|
||||
}
|
||||
|
||||
func (p *ProcessMock) Healthy(logger *zap.Logger) bool {
|
||||
return p.HealthyMock(logger)
|
||||
}
|
||||
|
||||
// ProcessSupervisorMock is a mock for the [ProcessSupervisor] interface.
|
||||
type ProcessSupervisorMock struct {
|
||||
LaunchMock func() error
|
||||
ShutdownMock func() error
|
||||
HealthyMock func() bool
|
||||
RunMock func(ctx context.Context, logger *zap.Logger, task func() error) error
|
||||
ReqQueueSizeMock func() int64
|
||||
RestartsCountMock func() int64
|
||||
}
|
||||
|
||||
func (s *ProcessSupervisorMock) Launch() error {
|
||||
return s.LaunchMock()
|
||||
}
|
||||
|
||||
func (s *ProcessSupervisorMock) Shutdown() error {
|
||||
return s.ShutdownMock()
|
||||
}
|
||||
|
||||
func (s *ProcessSupervisorMock) Healthy() bool {
|
||||
return s.HealthyMock()
|
||||
}
|
||||
|
||||
func (s *ProcessSupervisorMock) Run(ctx context.Context, logger *zap.Logger, task func() error) error {
|
||||
return s.RunMock(ctx, logger, task)
|
||||
}
|
||||
|
||||
func (s *ProcessSupervisorMock) ReqQueueSize() int64 {
|
||||
return s.ReqQueueSizeMock()
|
||||
}
|
||||
|
||||
func (s *ProcessSupervisorMock) RestartsCount() int64 {
|
||||
return s.RestartsCountMock()
|
||||
}
|
||||
|
||||
// LoggerProviderMock is a mock for the [LoggerProvider] interface.
|
||||
type LoggerProviderMock struct {
|
||||
LoggerMock func(mod Module) (*zap.Logger, error)
|
||||
}
|
||||
|
||||
func (provider LoggerProviderMock) Logger(mod Module) (*zap.Logger, error) {
|
||||
func (provider *LoggerProviderMock) Logger(mod Module) (*zap.Logger, error) {
|
||||
return provider.LoggerMock(mod)
|
||||
}
|
||||
|
||||
// MetricsProviderMock is a mock for the [MetricsProvider] interface.
|
||||
type MetricsProviderMock struct {
|
||||
MetricsMock func() ([]Metric, error)
|
||||
}
|
||||
|
||||
func (provider *MetricsProviderMock) Metrics() ([]Metric, error) {
|
||||
return provider.MetricsMock()
|
||||
}
|
||||
|
||||
// PathRenameMock is a mock for the [PathRename] interface.
|
||||
type PathRenameMock struct {
|
||||
RenameMock func(oldpath, newpath string) error
|
||||
}
|
||||
|
||||
func (rename *PathRenameMock) Rename(oldpath, newpath string) error {
|
||||
return rename.RenameMock(oldpath, newpath)
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ Module = (*ModuleMock)(nil)
|
||||
_ Validator = (*ValidatorMock)(nil)
|
||||
_ PDFEngine = (*PDFEngineMock)(nil)
|
||||
_ PDFEngineProvider = (*PDFEngineProviderMock)(nil)
|
||||
_ PdfEngine = (*PdfEngineMock)(nil)
|
||||
_ PdfEngineProvider = (*PdfEngineProviderMock)(nil)
|
||||
_ Process = (*ProcessMock)(nil)
|
||||
_ ProcessSupervisor = (*ProcessSupervisorMock)(nil)
|
||||
_ LoggerProvider = (*LoggerProviderMock)(nil)
|
||||
_ MetricsProvider = (*MetricsProviderMock)(nil)
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
)
|
||||
|
||||
func TestModuleMock(t *testing.T) {
|
||||
mock := ModuleMock{
|
||||
mock := &ModuleMock{
|
||||
DescriptorMock: func() ModuleDescriptor {
|
||||
return ModuleDescriptor{ID: "foo", New: func() Module {
|
||||
return nil
|
||||
@@ -17,12 +17,25 @@ func TestModuleMock(t *testing.T) {
|
||||
}
|
||||
|
||||
if mock.Descriptor().ID != "foo" {
|
||||
t.Errorf("expected ID '%s' from mock.Descriptor(), but got '%s'", "foo", mock.Descriptor().ID)
|
||||
t.Errorf("expected ID '%s' from ModuleMock.Descriptor, but got '%s'", "foo", mock.Descriptor().ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvisionerMock(t *testing.T) {
|
||||
mock := &ProvisionerMock{
|
||||
ProvisionMock: func(*Context) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
err := mock.Provision(&Context{})
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from ProvisionerMock.Provision, but got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatorMock(t *testing.T) {
|
||||
mock := ValidatorMock{
|
||||
mock := &ValidatorMock{
|
||||
ValidateMock: func() error {
|
||||
return nil
|
||||
},
|
||||
@@ -30,53 +43,177 @@ func TestValidatorMock(t *testing.T) {
|
||||
|
||||
err := mock.Validate()
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from mock.Validate(), but got: %v", err)
|
||||
t.Errorf("expected no error from ValidatorMock.Validate, but got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPDFEngineMock(t *testing.T) {
|
||||
mock := PDFEngineMock{
|
||||
mock := &PdfEngineMock{
|
||||
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
||||
return nil
|
||||
},
|
||||
ConvertMock: func(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error {
|
||||
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error {
|
||||
return nil
|
||||
},
|
||||
ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
|
||||
return nil, nil
|
||||
},
|
||||
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
err := mock.Merge(context.Background(), zap.NewNop(), nil, "")
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from mock.Merge(), but got: %v", err)
|
||||
t.Errorf("expected no error from PdfEngineMock.Merge, but got: %v", err)
|
||||
}
|
||||
|
||||
err = mock.Convert(context.Background(), zap.NewNop(), "", "", "")
|
||||
err = mock.Convert(context.Background(), zap.NewNop(), PdfFormats{}, "", "")
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from mock.Convert(), but got: %v", err)
|
||||
t.Errorf("expected no error from PdfEngineMock.Convert, but got: %v", err)
|
||||
}
|
||||
|
||||
_, err = mock.ReadMetadata(context.Background(), zap.NewNop(), "")
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from PdfEngineMock.ReadMetadata, but got: %v", err)
|
||||
}
|
||||
|
||||
err = mock.WriteMetadata(context.Background(), zap.NewNop(), map[string]interface{}{}, "")
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from PdfEngineMock.WriteMetadata but got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPDFEngineProviderMock(t *testing.T) {
|
||||
mock := PDFEngineProviderMock{
|
||||
PDFEngineMock: func() (PDFEngine, error) {
|
||||
return PDFEngineMock{}, nil
|
||||
mock := &PdfEngineProviderMock{
|
||||
PdfEngineMock: func() (PdfEngine, error) {
|
||||
return new(PdfEngineMock), nil
|
||||
},
|
||||
}
|
||||
|
||||
_, err := mock.PDFEngine()
|
||||
_, err := mock.PdfEngine()
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from mock.PDFEngine(), but got: %v", err)
|
||||
t.Errorf("expected no error from PdfEngineProviderMock.PdfEngine, but got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessMock(t *testing.T) {
|
||||
mock := &ProcessMock{
|
||||
StartMock: func(logger *zap.Logger) error {
|
||||
return nil
|
||||
},
|
||||
StopMock: func(logger *zap.Logger) error {
|
||||
return nil
|
||||
},
|
||||
HealthyMock: func(logger *zap.Logger) bool {
|
||||
return true
|
||||
},
|
||||
}
|
||||
|
||||
err := mock.Start(zap.NewNop())
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from ProcessMock.Start, but got: %v", err)
|
||||
}
|
||||
|
||||
err = mock.Stop(zap.NewNop())
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from ProcessMock.Stop, but got: %v", err)
|
||||
}
|
||||
|
||||
healthy := mock.Healthy(zap.NewNop())
|
||||
if !healthy {
|
||||
t.Error("expected true from ProcessMock.Healthy, but got false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessSupervisorMock(t *testing.T) {
|
||||
mock := &ProcessSupervisorMock{
|
||||
LaunchMock: func() error {
|
||||
return nil
|
||||
},
|
||||
ShutdownMock: func() error {
|
||||
return nil
|
||||
},
|
||||
HealthyMock: func() bool {
|
||||
return true
|
||||
},
|
||||
RunMock: func(ctx context.Context, logger *zap.Logger, task func() error) error {
|
||||
return nil
|
||||
},
|
||||
ReqQueueSizeMock: func() int64 {
|
||||
return 0
|
||||
},
|
||||
RestartsCountMock: func() int64 {
|
||||
return 0
|
||||
},
|
||||
}
|
||||
|
||||
err := mock.Launch()
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from ProcessSupervisorMock.Launch, but got: %v", err)
|
||||
}
|
||||
|
||||
err = mock.Shutdown()
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from ProcessSupervisorMock.Shutdown, but got: %v", err)
|
||||
}
|
||||
|
||||
healthy := mock.Healthy()
|
||||
if !healthy {
|
||||
t.Error("expected true from ProcessSupervisorMock.Healthy, but got false")
|
||||
}
|
||||
|
||||
err = mock.Run(context.TODO(), zap.NewNop(), nil)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from ProcessSupervisorMock.Run, but got: %v", err)
|
||||
}
|
||||
|
||||
size := mock.ReqQueueSize()
|
||||
if size != 0 {
|
||||
t.Errorf("expected 0 from ProcessSupervisorMock.ReqQueueSize, but got: %d", size)
|
||||
}
|
||||
|
||||
restarts := mock.RestartsCount()
|
||||
if restarts != 0 {
|
||||
t.Errorf("expected 0 from ProcessSupervisorMock.RestartsCount, but got: %d", restarts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoggerProviderMock(t *testing.T) {
|
||||
mock := LoggerProviderMock{
|
||||
mock := &LoggerProviderMock{
|
||||
LoggerMock: func(mod Module) (*zap.Logger, error) {
|
||||
return nil, nil
|
||||
},
|
||||
}
|
||||
|
||||
_, err := mock.Logger(ModuleMock{})
|
||||
_, err := mock.Logger(new(ModuleMock))
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from mock.Logger(), but got: %v", err)
|
||||
t.Errorf("expected no error from LoggerProviderMock.Logger, but got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetricsProviderMock(t *testing.T) {
|
||||
mock := &MetricsProviderMock{
|
||||
MetricsMock: func() ([]Metric, error) {
|
||||
return nil, nil
|
||||
},
|
||||
}
|
||||
|
||||
_, err := mock.Metrics()
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from MetricsProviderMock.Metrics, but got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathRenameMock(t *testing.T) {
|
||||
mock := &PathRenameMock{
|
||||
RenameMock: func(oldpath, newpath string) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
err := mock.Rename("", "")
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from PathRenameMock.Rename, but got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ type SystemLogger interface {
|
||||
// gotenberg.MustRegisterModule(YourModule{})
|
||||
// }
|
||||
//
|
||||
// Then, in the main command (github.com/gotenberg/gotenberg/v7/cmd/gotenberg),
|
||||
// Then, in the main command (github.com/gotenberg/gotenberg/v8/cmd/gotenberg),
|
||||
// import the module:
|
||||
//
|
||||
// imports (
|
||||
|
||||
@@ -5,32 +5,6 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
type ProtoModule struct {
|
||||
descriptor func() ModuleDescriptor
|
||||
}
|
||||
|
||||
func (mod ProtoModule) Descriptor() ModuleDescriptor {
|
||||
return mod.descriptor()
|
||||
}
|
||||
|
||||
type ProtoProvisioner struct {
|
||||
ProtoModule
|
||||
provision func(ctx *Context) error
|
||||
}
|
||||
|
||||
func (mod ProtoProvisioner) Provision(ctx *Context) error {
|
||||
return mod.provision(ctx)
|
||||
}
|
||||
|
||||
type ProtoValidator struct {
|
||||
ProtoModule
|
||||
validate func() error
|
||||
}
|
||||
|
||||
func (mod ProtoValidator) Validate() error {
|
||||
return mod.validate()
|
||||
}
|
||||
|
||||
func TestMustRegisterModule(t *testing.T) {
|
||||
descriptorsMu.RLock()
|
||||
descriptors = map[string]ModuleDescriptor{
|
||||
@@ -38,44 +12,51 @@ func TestMustRegisterModule(t *testing.T) {
|
||||
}
|
||||
descriptorsMu.RUnlock()
|
||||
|
||||
for i, tc := range []struct {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
ID string
|
||||
New func() Module
|
||||
expectPanic bool
|
||||
}{
|
||||
{
|
||||
scenario: "no ID",
|
||||
ID: "",
|
||||
New: func() Module { return new(ProtoModule) },
|
||||
New: func() Module { return new(ModuleMock) },
|
||||
expectPanic: true,
|
||||
},
|
||||
{
|
||||
scenario: "nil New method",
|
||||
ID: "b",
|
||||
New: nil,
|
||||
expectPanic: true,
|
||||
},
|
||||
{
|
||||
scenario: "nil module",
|
||||
ID: "b",
|
||||
New: func() Module { return nil },
|
||||
expectPanic: true,
|
||||
},
|
||||
{
|
||||
scenario: "existing module",
|
||||
ID: "a",
|
||||
New: func() Module { return new(ProtoModule) },
|
||||
New: func() Module { return new(ModuleMock) },
|
||||
expectPanic: true,
|
||||
},
|
||||
{
|
||||
ID: "b",
|
||||
New: func() Module { return new(ProtoModule) },
|
||||
scenario: "success",
|
||||
ID: "b",
|
||||
New: func() Module { return new(ModuleMock) },
|
||||
expectPanic: false,
|
||||
},
|
||||
} {
|
||||
func() {
|
||||
mod := struct{ ProtoModule }{}
|
||||
mod.descriptor = func() ModuleDescriptor { return ModuleDescriptor{ID: tc.ID, New: tc.New} }
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
mod := &struct{ ModuleMock }{}
|
||||
mod.DescriptorMock = func() ModuleDescriptor { return ModuleDescriptor{ID: tc.ID, New: tc.New} }
|
||||
|
||||
if tc.expectPanic {
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Errorf("test %d: expected panic but got none", i)
|
||||
t.Error("expected panic but got none")
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -83,13 +64,13 @@ func TestMustRegisterModule(t *testing.T) {
|
||||
if !tc.expectPanic {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("test %d: expected no panic but got: %v", i, r)
|
||||
t.Errorf("expected no panic but got: %v", r)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
MustRegisterModule(mod)
|
||||
}()
|
||||
})
|
||||
}
|
||||
|
||||
descriptorsMu.RLock()
|
||||
@@ -124,12 +105,3 @@ func TestGetModuleDescriptors(t *testing.T) {
|
||||
descriptors = make(map[string]ModuleDescriptor)
|
||||
descriptorsMu.RUnlock()
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ Module = (*ProtoModule)(nil)
|
||||
_ Provisioner = (*ProtoProvisioner)(nil)
|
||||
_ Module = (*ProtoProvisioner)(nil)
|
||||
_ Validator = (*ProtoValidator)(nil)
|
||||
_ Module = (*ProtoValidator)(nil)
|
||||
)
|
||||
|
||||
@@ -8,45 +8,84 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrPDFEngineMethodNotAvailable happens if a PDFEngine method is not
|
||||
// available in the implementation.
|
||||
ErrPDFEngineMethodNotAvailable = errors.New("method not available")
|
||||
// ErrPdfEngineMethodNotSupported is returned when a specific method of the
|
||||
// PdfEngine interface is not supported by its current implementation.
|
||||
ErrPdfEngineMethodNotSupported = errors.New("method not supported")
|
||||
|
||||
// ErrPDFFormatNotAvailable happens if a PDFEngine Convert's method does
|
||||
// not handle a specific format.
|
||||
ErrPDFFormatNotAvailable = errors.New("PDF format not available")
|
||||
// ErrPdfFormatNotSupported is returned when the Convert method of the
|
||||
// PdfEngine interface does not support a requested PDF format conversion.
|
||||
ErrPdfFormatNotSupported = errors.New("PDF format not supported")
|
||||
|
||||
// ErrPdfEngineMetadataValueNotSupported is returned when a metadata value
|
||||
// is not supported.
|
||||
ErrPdfEngineMetadataValueNotSupported = errors.New("metadata value not supported")
|
||||
)
|
||||
|
||||
const (
|
||||
FormatPDFA1a string = "PDF/A-1a"
|
||||
FormatPDFA1b string = "PDF/A-1b"
|
||||
FormatPDFA2a string = "PDF/A-2a"
|
||||
FormatPDFA2b string = "PDF/A-2b"
|
||||
FormatPDFA2u string = "PDF/A-2u"
|
||||
FormatPDFA3a string = "PDF/A-3a"
|
||||
FormatPDFA3b string = "PDF/A-3b"
|
||||
FormatPDFA3u string = "PDF/A-3u"
|
||||
// PdfA1a represents the PDF/A-1a format.
|
||||
PdfA1a string = "PDF/A-1a"
|
||||
|
||||
// PdfA1b represents the PDF/A-1b format.
|
||||
PdfA1b string = "PDF/A-1b"
|
||||
|
||||
// PdfA2a represents the PDF/A-2a format.
|
||||
PdfA2a string = "PDF/A-2a"
|
||||
|
||||
// PdfA2b represents the PDF/A-2b format.
|
||||
PdfA2b string = "PDF/A-2b"
|
||||
|
||||
// PdfA2u represents the PDF/A-2u format.
|
||||
PdfA2u string = "PDF/A-2u"
|
||||
|
||||
// PdfA3a represents the PDF/A-3a format.
|
||||
PdfA3a string = "PDF/A-3a"
|
||||
|
||||
// PdfA3b represents the PDF/A-3b format.
|
||||
PdfA3b string = "PDF/A-3b"
|
||||
|
||||
// PdfA3u represents the PDF/A-3u format.
|
||||
PdfA3u string = "PDF/A-3u"
|
||||
)
|
||||
|
||||
// PDFEngine is a module interface which exposes methods for manipulating one
|
||||
// or more PDFs. Implementations may abstract powerful tools like PDFtk, or
|
||||
// fulfill those methods contracts in Golang directly.
|
||||
type PDFEngine interface {
|
||||
// Merge merges the given PDFs into a unique PDF. The pages' order reflects
|
||||
// order of the given files.
|
||||
// PdfFormats specifies the target formats for a PDF conversion.
|
||||
type PdfFormats struct {
|
||||
// PdfA denotes the PDF/A standard format (e.g., PDF/A-1a).
|
||||
PdfA string
|
||||
|
||||
// PdfUa indicates whether the PDF should comply
|
||||
// with the PDF/UA (Universal Accessibility) standard.
|
||||
PdfUa bool
|
||||
}
|
||||
|
||||
// PdfEngine provides an interface for operations on PDFs. Implementations
|
||||
// can utilize various tools like PDFtk, or implement functionality directly in
|
||||
// Go.
|
||||
type PdfEngine interface {
|
||||
// Merge combines multiple PDFs into a single PDF. The resulting page order
|
||||
// is determined by the order of files provided in inputPaths.
|
||||
Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error
|
||||
|
||||
// Convert converts the given PDF to a specific PDF format.
|
||||
Convert(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error
|
||||
// Convert transforms a given PDF to the specified formats defined in
|
||||
// PdfFormats. If no format, it does nothing.
|
||||
Convert(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error
|
||||
|
||||
// ReadMetadata extracts the metadata of a given PDF file.
|
||||
ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error)
|
||||
|
||||
// WriteMetadata writes the metadata into a given PDF file.
|
||||
WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error
|
||||
}
|
||||
|
||||
// PDFEngineProvider is a module interface which exposes a method for creating a
|
||||
// PDFEngine for other modules.
|
||||
// PdfEngineProvider offers an interface to instantiate a [PdfEngine].
|
||||
// This is used to decouple the creation of a [PdfEngine] from its consumers.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// func (m *YourModule) Provision(ctx *gotenberg.Context) error {
|
||||
// provider, _ := ctx.Module(new(gotenberg.PDFEngineProvider))
|
||||
// pdfengines, _ := provider.(gotenberg.PDFEngineProvider).PDFEngine()
|
||||
// provider, _ := ctx.Module(new(gotenberg.PdfEngineProvider))
|
||||
// engine, _ := provider.(gotenberg.PdfEngineProvider).PdfEngine()
|
||||
// }
|
||||
type PDFEngineProvider interface {
|
||||
PDFEngine() (PDFEngine, error)
|
||||
type PdfEngineProvider interface {
|
||||
// PdfEngine returns an instance of the [PdfEngine] interface for PDF operations.
|
||||
PdfEngine() (PdfEngine, error)
|
||||
}
|
||||
|
||||
69
pkg/gotenberg/sort.go
Normal file
69
pkg/gotenberg/sort.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// AlphanumericSort implements sort.Interface and helps to sort strings
|
||||
// alphanumerically.
|
||||
//
|
||||
// See: https://github.com/gotenberg/gotenberg/issues/805.
|
||||
type AlphanumericSort []string
|
||||
|
||||
func (s AlphanumericSort) Len() int {
|
||||
return len(s)
|
||||
}
|
||||
|
||||
func (s AlphanumericSort) Swap(i, j int) {
|
||||
s[i], s[j] = s[j], s[i]
|
||||
}
|
||||
|
||||
func (s AlphanumericSort) Less(i, j int) bool {
|
||||
numI, restI := extractPrefix(s[i])
|
||||
numJ, restJ := extractPrefix(s[j])
|
||||
|
||||
// Compares numerical prefixes if they exist.
|
||||
if numI != -1 && numJ != -1 {
|
||||
if numI != numJ {
|
||||
return numI < numJ
|
||||
}
|
||||
// If numbers are equal, falls back to string comparison of the rest.
|
||||
return restI < restJ
|
||||
}
|
||||
|
||||
// If one has a numerical prefix and the other doesn't, the one with the
|
||||
// number comes first.
|
||||
if numI != -1 {
|
||||
return true
|
||||
}
|
||||
if numJ != -1 {
|
||||
return false
|
||||
}
|
||||
|
||||
// If neither has a numerical prefix, compare as strings
|
||||
return s[i] < s[j]
|
||||
}
|
||||
|
||||
// extractPrefix attempts to extract a numerical prefix and the rest of the filename
|
||||
func extractPrefix(filename string) (int, string) {
|
||||
matches := numPrefixRegexp.FindStringSubmatch(filename)
|
||||
if len(matches) > 2 {
|
||||
prefix, err := strconv.Atoi(matches[1])
|
||||
if err == nil {
|
||||
return prefix, matches[2]
|
||||
}
|
||||
}
|
||||
|
||||
// Returns -1 if no numerical prefix is found, indicating to just compare
|
||||
// as strings.
|
||||
return -1, filename
|
||||
}
|
||||
|
||||
var numPrefixRegexp = regexp.MustCompile(`^(\d+)(.*)$`)
|
||||
|
||||
// Interface guard.
|
||||
var (
|
||||
_ sort.Interface = (*AlphanumericSort)(nil)
|
||||
)
|
||||
34
pkg/gotenberg/sort_test.go
Normal file
34
pkg/gotenberg/sort_test.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"sort"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAlphanumericSort(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
values []string
|
||||
expectedSort []string
|
||||
}{
|
||||
{
|
||||
scenario: "numeric and letters",
|
||||
values: []string{"10qux.pdf", "2_baz.txt", "2_aza.txt", "1bar.pdf", "Afoo.txt", "Bbar.docx", "25zeta.txt", "3.pdf", "4_foo.pdf"},
|
||||
expectedSort: []string{"1bar.pdf", "2_aza.txt", "2_baz.txt", "3.pdf", "4_foo.pdf", "10qux.pdf", "25zeta.txt", "Afoo.txt", "Bbar.docx"},
|
||||
},
|
||||
{
|
||||
scenario: "hrtime (PHP library)",
|
||||
values: []string{"245654773395259", "245654773395039", "245654773395149", "245654773394919", "245654773394369"},
|
||||
expectedSort: []string{"245654773394369", "245654773394919", "245654773395039", "245654773395149", "245654773395259"},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
sort.Sort(AlphanumericSort(tc.values))
|
||||
|
||||
if !reflect.DeepEqual(tc.values, tc.expectedSort) {
|
||||
t.Fatalf("expected %+v but got: %+v", tc.expectedSort, tc.values)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
298
pkg/gotenberg/supervisor.go
Normal file
298
pkg/gotenberg/supervisor.go
Normal file
@@ -0,0 +1,298 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// ErrProcessAlreadyRestarting happens if the [ProcessSupervisor] is trying
|
||||
// to restart an already restarting [Process].
|
||||
var ErrProcessAlreadyRestarting = errors.New("process already restarting")
|
||||
|
||||
// ErrMaximumQueueSizeExceeded happens if Run() is called but the maximum queue
|
||||
// size is already used.
|
||||
var ErrMaximumQueueSizeExceeded = errors.New("maximum queue size exceeded")
|
||||
|
||||
// Process is an interface that represents an abstract process
|
||||
// and provides methods for starting, stopping, and checking the health of the
|
||||
// process.
|
||||
//
|
||||
// Implementations of this interface should handle the actual logic for
|
||||
// starting, stopping, and ensuring the process's health.
|
||||
type Process interface {
|
||||
// Start initiates the process and returns an error if the process cannot
|
||||
// be started.
|
||||
Start(logger *zap.Logger) error
|
||||
|
||||
// Stop terminates the process and returns an error if the process cannot
|
||||
// be stopped.
|
||||
Stop(logger *zap.Logger) error
|
||||
|
||||
// Healthy checks the health of the process. It returns true if the process
|
||||
// is healthy; otherwise, it returns false.
|
||||
Healthy(logger *zap.Logger) bool
|
||||
}
|
||||
|
||||
// ProcessSupervisor provides methods to manage a [Process], including
|
||||
// starting, stopping, and ensuring its health.
|
||||
//
|
||||
// Additionally, it allows for the execution of tasks while managing the
|
||||
// process's state and provides functionality for limiting the number of
|
||||
// requests that can be handled by the process, as well as managing a request
|
||||
// queue.
|
||||
type ProcessSupervisor interface {
|
||||
// Launch starts the managed [Process].
|
||||
Launch() error
|
||||
|
||||
// Shutdown stops the managed [Process].
|
||||
Shutdown() error
|
||||
|
||||
// Healthy checks and returns the health status of the managed [Process].
|
||||
//
|
||||
// If the process has not been started or is restarting, it is considered
|
||||
// healthy and true is returned. Otherwise, it returns the health status of
|
||||
// the actual process.
|
||||
Healthy() bool
|
||||
|
||||
// Run executes a provided task while managing the state of the [Process].
|
||||
//
|
||||
// Run manages the request queue and may restart the process if it is not
|
||||
// healthy or if the number of handled requests exceeds the maximum limit.
|
||||
//
|
||||
// It returns an error if the task cannot be run or if the process state
|
||||
// cannot be managed properly.
|
||||
Run(ctx context.Context, logger *zap.Logger, task func() error) error
|
||||
|
||||
// ReqQueueSize returns the current size of the request queue.
|
||||
ReqQueueSize() int64
|
||||
|
||||
// RestartsCount returns the current number of restart.
|
||||
RestartsCount() int64
|
||||
}
|
||||
|
||||
type processSupervisor struct {
|
||||
logger *zap.Logger
|
||||
process Process
|
||||
maxReqLimit int64
|
||||
maxQueueSize int64
|
||||
mutexChan chan struct{}
|
||||
firstStart atomic.Bool
|
||||
reqCounter atomic.Int64
|
||||
reqQueueSize atomic.Int64
|
||||
restartsCounter atomic.Int64
|
||||
isRestarting atomic.Bool
|
||||
}
|
||||
|
||||
// NewProcessSupervisor initializes a new [ProcessSupervisor].
|
||||
func NewProcessSupervisor(logger *zap.Logger, process Process, maxReqLimit, maxQueueSize int64) ProcessSupervisor {
|
||||
b := &processSupervisor{
|
||||
logger: logger,
|
||||
process: process,
|
||||
mutexChan: make(chan struct{}, 1),
|
||||
maxReqLimit: maxReqLimit,
|
||||
maxQueueSize: maxQueueSize,
|
||||
}
|
||||
b.reqCounter.Store(0)
|
||||
b.reqQueueSize.Store(0)
|
||||
b.restartsCounter.Store(0)
|
||||
b.isRestarting.Store(false)
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
func (s *processSupervisor) Launch() error {
|
||||
s.logger.Debug("start process")
|
||||
err := s.process.Start(s.logger)
|
||||
if err != nil {
|
||||
return fmt.Errorf("start process: %w", err)
|
||||
}
|
||||
|
||||
s.firstStart.Store(true)
|
||||
s.logger.Debug("process successfully started")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *processSupervisor) Shutdown() error {
|
||||
s.logger.Debug("shutdown process")
|
||||
err := s.process.Stop(s.logger)
|
||||
if err != nil {
|
||||
return fmt.Errorf("shutdown process: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Debug("process successfully shutdown")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *processSupervisor) restart() error {
|
||||
if s.isRestarting.Load() {
|
||||
s.logger.Debug("process already restarting, skip restart")
|
||||
|
||||
return ErrProcessAlreadyRestarting
|
||||
}
|
||||
|
||||
s.logger.Debug("restart process")
|
||||
s.isRestarting.Store(true)
|
||||
defer s.isRestarting.Store(false)
|
||||
|
||||
err := s.Shutdown()
|
||||
if err != nil {
|
||||
// No big deal? Chances are it's already stopped.
|
||||
s.logger.Debug(fmt.Sprintf("stop process before restart: %s", err))
|
||||
}
|
||||
|
||||
err = s.Launch()
|
||||
if err != nil {
|
||||
return fmt.Errorf("restart process: %w", err)
|
||||
}
|
||||
|
||||
s.reqCounter.Store(0)
|
||||
s.restartsCounter.Add(1)
|
||||
s.logger.Debug("process successfully restarted")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *processSupervisor) Healthy() bool {
|
||||
if !s.firstStart.Load() {
|
||||
// A non-started process is always healthy.
|
||||
return true
|
||||
}
|
||||
|
||||
if s.isRestarting.Load() {
|
||||
// A restarting process is always healthy.
|
||||
return true
|
||||
}
|
||||
|
||||
return s.process.Healthy(s.logger)
|
||||
}
|
||||
|
||||
func (s *processSupervisor) Run(ctx context.Context, logger *zap.Logger, task func() error) error {
|
||||
// A user reported a potential issue:
|
||||
//
|
||||
// "Although the counting operation is atomic, nothing prevent 2 concurrent
|
||||
// goroutines to retrieve the same 'currentQueueSize' and to compare its
|
||||
// value against the max limit. Then, resulting queue size would be 1 above
|
||||
// the allowed limit."
|
||||
//
|
||||
// However, he was unable to actually trigger this issue, even when sending
|
||||
// a lot of requests.
|
||||
//
|
||||
// For now, the best option is to consider this issue to be unlikely to
|
||||
// happen, and keep the code as it is because it is more readable this way.
|
||||
//
|
||||
// See https://github.com/gotenberg/gotenberg/issues/951.
|
||||
currentQueueSize := s.reqQueueSize.Load()
|
||||
if s.maxQueueSize > 0 && currentQueueSize >= s.maxQueueSize {
|
||||
return ErrMaximumQueueSizeExceeded
|
||||
}
|
||||
|
||||
s.reqQueueSize.Add(1)
|
||||
|
||||
for {
|
||||
err := func() error {
|
||||
select {
|
||||
case s.mutexChan <- struct{}{}:
|
||||
logger.Debug("process lock acquired")
|
||||
s.reqQueueSize.Add(-1)
|
||||
s.reqCounter.Add(1)
|
||||
releaseMutexChan := true
|
||||
|
||||
defer func() {
|
||||
if releaseMutexChan {
|
||||
logger.Debug("process lock released")
|
||||
<-s.mutexChan
|
||||
}
|
||||
}()
|
||||
|
||||
if !s.firstStart.Load() {
|
||||
err := s.runWithDeadline(ctx, func() error {
|
||||
return s.Launch()
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("process first start: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if !s.Healthy() {
|
||||
s.logger.Debug("process is unhealthy, cannot handle task, restarting...")
|
||||
err := s.runWithDeadline(ctx, func() error {
|
||||
return s.restart()
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("process restart before task: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
err := s.runWithDeadline(ctx, task)
|
||||
|
||||
if s.maxReqLimit > 0 && s.reqCounter.Load() >= s.maxReqLimit {
|
||||
s.logger.Debug("max request limit reached, restarting eagerly...")
|
||||
releaseMutexChan = false
|
||||
|
||||
go func() {
|
||||
err := s.runWithDeadline(context.Background(), func() error {
|
||||
return s.restart()
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Error(fmt.Sprintf("process restart after task: %v", err))
|
||||
}
|
||||
logger.Debug("process lock released")
|
||||
<-s.mutexChan
|
||||
}()
|
||||
}
|
||||
|
||||
// Note: no error wrapping because it leaks on Chromium console exceptions output.
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
logger.Debug("failed to acquire process lock before deadline")
|
||||
s.reqQueueSize.Add(-1)
|
||||
|
||||
return fmt.Errorf("acquire process lock: %w", ctx.Err())
|
||||
}
|
||||
}()
|
||||
|
||||
if errors.Is(err, ErrProcessAlreadyRestarting) {
|
||||
logger.Debug("process is already restarting, trying to acquire process lock again...")
|
||||
s.reqQueueSize.Add(1)
|
||||
continue
|
||||
}
|
||||
|
||||
// Note: no error wrapping because it leaks on Chromium console exceptions output.
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func (s *processSupervisor) runWithDeadline(ctx context.Context, task func() error) error {
|
||||
runChan := make(chan error, 1)
|
||||
go func() {
|
||||
runChan <- task()
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case err := <-runChan:
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *processSupervisor) ReqQueueSize() int64 {
|
||||
return s.reqQueueSize.Load()
|
||||
}
|
||||
|
||||
func (s *processSupervisor) RestartsCount() int64 {
|
||||
return s.restartsCounter.Load()
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ ProcessSupervisor = (*processSupervisor)(nil)
|
||||
)
|
||||
639
pkg/gotenberg/supervisor_test.go
Normal file
639
pkg/gotenberg/supervisor_test.go
Normal file
@@ -0,0 +1,639 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestProcessSupervisor_Launch(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
startError error
|
||||
expectError bool
|
||||
firstStartSet bool
|
||||
}{
|
||||
{
|
||||
scenario: "successful launch",
|
||||
startError: nil,
|
||||
expectError: false,
|
||||
firstStartSet: true,
|
||||
},
|
||||
{
|
||||
scenario: "failed launch",
|
||||
startError: errors.New("start error"),
|
||||
expectError: true,
|
||||
firstStartSet: false,
|
||||
},
|
||||
{
|
||||
scenario: "process already started",
|
||||
startError: nil,
|
||||
expectError: false,
|
||||
firstStartSet: true,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
logger := zap.NewNop()
|
||||
|
||||
process := &ProcessMock{
|
||||
StartMock: func(logger *zap.Logger) error {
|
||||
return tc.startError
|
||||
},
|
||||
}
|
||||
|
||||
ps := NewProcessSupervisor(logger, process, 5, 0).(*processSupervisor)
|
||||
if tc.firstStartSet {
|
||||
ps.firstStart.Store(true)
|
||||
}
|
||||
|
||||
err := ps.Launch()
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
|
||||
if tc.firstStartSet && !ps.firstStart.Load() {
|
||||
t.Error("expected firstStart to be set but it was not")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessSupervisor_Shutdown(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
stopError error
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
scenario: "successful shutdown",
|
||||
stopError: nil,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "failed shutdown",
|
||||
stopError: errors.New("stop error"),
|
||||
expectError: true,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
logger := zap.NewNop()
|
||||
|
||||
process := &ProcessMock{
|
||||
StopMock: func(logger *zap.Logger) error {
|
||||
return tc.stopError
|
||||
},
|
||||
}
|
||||
|
||||
ps := NewProcessSupervisor(logger, process, 5, 0)
|
||||
err := ps.Shutdown()
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessSupervisor_restart(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
initiallyRestarting bool
|
||||
startError error
|
||||
stopError error
|
||||
expectError bool
|
||||
expectedError error
|
||||
}{
|
||||
{
|
||||
scenario: "already restarting",
|
||||
initiallyRestarting: true,
|
||||
expectError: true,
|
||||
expectedError: ErrProcessAlreadyRestarting,
|
||||
},
|
||||
{
|
||||
scenario: "successful restart",
|
||||
startError: nil,
|
||||
stopError: nil,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "failed to stop during restart",
|
||||
startError: nil,
|
||||
stopError: errors.New("stop error"),
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "failed to start during restart",
|
||||
startError: errors.New("start error"),
|
||||
stopError: nil,
|
||||
expectError: true,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
logger := zap.NewNop()
|
||||
|
||||
process := &ProcessMock{
|
||||
StartMock: func(logger *zap.Logger) error {
|
||||
return tc.startError
|
||||
},
|
||||
StopMock: func(logger *zap.Logger) error {
|
||||
return tc.stopError
|
||||
},
|
||||
}
|
||||
|
||||
ps := NewProcessSupervisor(logger, process, 5, 0).(*processSupervisor)
|
||||
if tc.initiallyRestarting {
|
||||
ps.isRestarting.Store(true)
|
||||
}
|
||||
|
||||
err := ps.restart()
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
|
||||
if tc.expectedError != nil && !errors.Is(err, tc.expectedError) {
|
||||
t.Fatalf("expected error %v but got: %v", tc.expectedError, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessSupervisor_Healthy(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
initiallyStarted bool
|
||||
initiallyRestarting bool
|
||||
processHealthy bool
|
||||
expectHealthy bool
|
||||
}{
|
||||
{
|
||||
scenario: "non-started process is always healthy",
|
||||
initiallyStarted: false,
|
||||
expectHealthy: true,
|
||||
},
|
||||
{
|
||||
scenario: "restarting process is always healthy",
|
||||
initiallyStarted: true,
|
||||
initiallyRestarting: true,
|
||||
expectHealthy: true,
|
||||
},
|
||||
{
|
||||
scenario: "process reports as healthy",
|
||||
initiallyStarted: true,
|
||||
processHealthy: true,
|
||||
expectHealthy: true,
|
||||
},
|
||||
{
|
||||
scenario: "process reports as unhealthy",
|
||||
initiallyStarted: true,
|
||||
processHealthy: false,
|
||||
expectHealthy: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
logger := zap.NewNop()
|
||||
|
||||
process := &ProcessMock{
|
||||
HealthyMock: func(logger *zap.Logger) bool {
|
||||
return tc.processHealthy
|
||||
},
|
||||
}
|
||||
|
||||
ps := NewProcessSupervisor(logger, process, 5, 0).(*processSupervisor)
|
||||
if tc.initiallyStarted {
|
||||
ps.firstStart.Store(true)
|
||||
}
|
||||
if tc.initiallyRestarting {
|
||||
ps.isRestarting.Store(true)
|
||||
}
|
||||
|
||||
healthy := ps.Healthy()
|
||||
|
||||
if healthy != tc.expectHealthy {
|
||||
t.Fatalf("expected healthy to be %v but got %v", tc.expectHealthy, healthy)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessSupervisor_Run(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
initiallyStarted bool
|
||||
isRestarting bool
|
||||
startError error
|
||||
processHealthy bool
|
||||
maxReqLimit int64
|
||||
tasksToRun int
|
||||
taskError error
|
||||
expectError bool
|
||||
skipCallsCheck bool
|
||||
expectedStartCalls int64
|
||||
expectedHealthyCalls int64
|
||||
expectedStopCalls int64
|
||||
currentQueueSize int64
|
||||
maxQueueSize int64
|
||||
}{
|
||||
{
|
||||
scenario: "successfully run task on non-started process",
|
||||
initiallyStarted: false,
|
||||
isRestarting: false,
|
||||
processHealthy: true,
|
||||
maxReqLimit: 2,
|
||||
tasksToRun: 1,
|
||||
expectError: false,
|
||||
expectedStartCalls: 1,
|
||||
expectedHealthyCalls: 1,
|
||||
expectedStopCalls: 0,
|
||||
},
|
||||
{
|
||||
scenario: "cannot launch non-started process",
|
||||
initiallyStarted: false,
|
||||
isRestarting: false,
|
||||
startError: errors.New("launch error"),
|
||||
processHealthy: true,
|
||||
maxReqLimit: 2,
|
||||
tasksToRun: 1,
|
||||
expectError: true,
|
||||
expectedStartCalls: 1,
|
||||
expectedHealthyCalls: 0,
|
||||
expectedStopCalls: 0,
|
||||
},
|
||||
{
|
||||
scenario: "run task with unhealthy process causing restart",
|
||||
initiallyStarted: true,
|
||||
isRestarting: false,
|
||||
processHealthy: false,
|
||||
maxReqLimit: 2,
|
||||
tasksToRun: 1,
|
||||
expectError: false,
|
||||
expectedStartCalls: 1,
|
||||
expectedHealthyCalls: 1,
|
||||
expectedStopCalls: 1,
|
||||
},
|
||||
{
|
||||
scenario: "cannot restart unhealthy process",
|
||||
startError: errors.New("start error"),
|
||||
initiallyStarted: true,
|
||||
isRestarting: false,
|
||||
processHealthy: false,
|
||||
maxReqLimit: 2,
|
||||
tasksToRun: 1,
|
||||
expectError: true,
|
||||
expectedStartCalls: 1,
|
||||
expectedHealthyCalls: 1,
|
||||
expectedStopCalls: 1,
|
||||
},
|
||||
{
|
||||
scenario: "ErrProcessAlreadyRestarting",
|
||||
initiallyStarted: true,
|
||||
isRestarting: true,
|
||||
processHealthy: false,
|
||||
maxReqLimit: 1,
|
||||
tasksToRun: 1,
|
||||
expectError: true,
|
||||
skipCallsCheck: true,
|
||||
},
|
||||
{
|
||||
scenario: "run tasks reaching max request limit causing restart",
|
||||
initiallyStarted: true,
|
||||
isRestarting: false,
|
||||
processHealthy: true,
|
||||
maxReqLimit: 2,
|
||||
tasksToRun: 3,
|
||||
expectError: false,
|
||||
expectedStartCalls: 1,
|
||||
expectedHealthyCalls: 3,
|
||||
expectedStopCalls: 1,
|
||||
},
|
||||
{
|
||||
scenario: "auto-restart after reaching max request limit",
|
||||
startError: errors.New("start error"),
|
||||
initiallyStarted: true,
|
||||
isRestarting: false,
|
||||
processHealthy: true,
|
||||
maxReqLimit: 2,
|
||||
tasksToRun: 2,
|
||||
expectError: true,
|
||||
expectedStartCalls: 1,
|
||||
expectedHealthyCalls: 2,
|
||||
expectedStopCalls: 1,
|
||||
},
|
||||
{
|
||||
scenario: "task error",
|
||||
initiallyStarted: true,
|
||||
isRestarting: false,
|
||||
processHealthy: true,
|
||||
maxReqLimit: 0,
|
||||
tasksToRun: 1,
|
||||
taskError: errors.New("task error"),
|
||||
expectError: true,
|
||||
expectedStartCalls: 0,
|
||||
expectedHealthyCalls: 1,
|
||||
expectedStopCalls: 0,
|
||||
},
|
||||
{
|
||||
scenario: "queue size exceeded",
|
||||
initiallyStarted: false,
|
||||
isRestarting: false,
|
||||
processHealthy: true,
|
||||
maxReqLimit: 2,
|
||||
tasksToRun: 1,
|
||||
expectError: true,
|
||||
expectedStartCalls: 0,
|
||||
expectedHealthyCalls: 0,
|
||||
expectedStopCalls: 0,
|
||||
currentQueueSize: 1,
|
||||
maxQueueSize: 1,
|
||||
},
|
||||
{
|
||||
scenario: "queue size not exceeded",
|
||||
initiallyStarted: false,
|
||||
isRestarting: false,
|
||||
processHealthy: true,
|
||||
maxReqLimit: 2,
|
||||
tasksToRun: 1,
|
||||
expectError: true,
|
||||
expectedStartCalls: 1,
|
||||
expectedHealthyCalls: 1,
|
||||
expectedStopCalls: 0,
|
||||
currentQueueSize: 1,
|
||||
maxQueueSize: 2,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
logger := zap.NewNop()
|
||||
|
||||
var startCalls, healthyCalls, stopCalls atomic.Int64
|
||||
startCalls.Store(0)
|
||||
healthyCalls.Store(0)
|
||||
stopCalls.Store(0)
|
||||
|
||||
process := &ProcessMock{
|
||||
StartMock: func(logger *zap.Logger) error {
|
||||
startCalls.Add(1)
|
||||
return tc.startError
|
||||
},
|
||||
StopMock: func(logger *zap.Logger) error {
|
||||
stopCalls.Add(1)
|
||||
return nil
|
||||
},
|
||||
HealthyMock: func(logger *zap.Logger) bool {
|
||||
healthyCalls.Add(1)
|
||||
return tc.processHealthy
|
||||
},
|
||||
}
|
||||
|
||||
ps := NewProcessSupervisor(logger, process, tc.maxReqLimit, tc.maxQueueSize).(*processSupervisor)
|
||||
if tc.initiallyStarted {
|
||||
ps.firstStart.Store(true)
|
||||
}
|
||||
if tc.isRestarting {
|
||||
ps.isRestarting.Store(true)
|
||||
}
|
||||
if tc.currentQueueSize > 0 {
|
||||
ps.reqQueueSize.Store(tc.currentQueueSize)
|
||||
}
|
||||
|
||||
task := func() error {
|
||||
return tc.taskError
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errorChan := make(chan error, tc.tasksToRun)
|
||||
|
||||
for i := 0; i < tc.tasksToRun; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
err := ps.Run(ctx, logger, task)
|
||||
if err != nil {
|
||||
errorChan <- err
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(errorChan)
|
||||
|
||||
for err := range errorChan {
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected an error but got none")
|
||||
}
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if tc.skipCallsCheck {
|
||||
return
|
||||
}
|
||||
|
||||
// Making sure restarts are finished.
|
||||
ps.mutexChan <- struct{}{}
|
||||
<-ps.mutexChan
|
||||
|
||||
if startCalls.Load() != tc.expectedStartCalls {
|
||||
t.Errorf("expected %d process.Start calls, got %d", tc.expectedStartCalls, startCalls.Load())
|
||||
}
|
||||
|
||||
if healthyCalls.Load() != tc.expectedHealthyCalls {
|
||||
t.Errorf("expected %d process.Healthy calls, got %d", tc.expectedHealthyCalls, healthyCalls.Load())
|
||||
}
|
||||
|
||||
if stopCalls.Load() != tc.expectedStopCalls {
|
||||
t.Errorf("expected %d process.Stop calls, got %d", tc.expectedStopCalls, stopCalls.Load())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessSupervisor_runWithDeadline(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
ctxDone bool
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
scenario: "task finished",
|
||||
ctxDone: false,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "context expired",
|
||||
ctxDone: true,
|
||||
expectError: true,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
ps := NewProcessSupervisor(zap.NewNop(), new(ProcessMock), 0, 0).(*processSupervisor)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||
if tc.ctxDone {
|
||||
cancel()
|
||||
} else {
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
err := ps.runWithDeadline(ctx, func() error {
|
||||
return nil
|
||||
})
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected an error but got none")
|
||||
}
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessSupervisor_ReqQueueSize(t *testing.T) {
|
||||
logger := zap.NewNop()
|
||||
process := &ProcessMock{
|
||||
StartMock: func(logger *zap.Logger) error {
|
||||
return nil
|
||||
},
|
||||
HealthyMock: func(logger *zap.Logger) bool {
|
||||
return true
|
||||
},
|
||||
}
|
||||
ps := NewProcessSupervisor(logger, process, 0, 0).(*processSupervisor)
|
||||
|
||||
// Simulating a lock.
|
||||
ps.mutexChan <- struct{}{}
|
||||
|
||||
if ps.ReqQueueSize() != 0 {
|
||||
t.Fatalf("expected queue size to be 0 but got %d", ps.ReqQueueSize())
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errorChan := make(chan error, 10)
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
err := ps.Run(ctx, logger, func() error {
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
errorChan <- err
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// We have to wait a little bit so that the request queue size may change.
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
if ps.ReqQueueSize() != 10 {
|
||||
t.Fatalf("expected queue size to be 10 but got %d", ps.ReqQueueSize())
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(errorChan)
|
||||
|
||||
for err := range errorChan {
|
||||
if err == nil {
|
||||
t.Error("expected a lock error but got none")
|
||||
}
|
||||
}
|
||||
|
||||
if ps.ReqQueueSize() != 0 {
|
||||
t.Errorf("expected queue size to be 0 but got %d", ps.ReqQueueSize())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessSupervisor_RestartsCount(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
initialRestartsCount int64
|
||||
restartAttempts int
|
||||
startError error
|
||||
stopError error
|
||||
expectedRestartsCount int64
|
||||
}{
|
||||
{
|
||||
scenario: "no restarts, counter remains 0",
|
||||
initialRestartsCount: 0,
|
||||
restartAttempts: 0,
|
||||
expectedRestartsCount: 0,
|
||||
},
|
||||
{
|
||||
scenario: "successful restart increases counter",
|
||||
initialRestartsCount: 0,
|
||||
restartAttempts: 1,
|
||||
startError: nil,
|
||||
stopError: nil,
|
||||
expectedRestartsCount: 1,
|
||||
},
|
||||
{
|
||||
scenario: "failed to stop during restart, no impact",
|
||||
initialRestartsCount: 0,
|
||||
restartAttempts: 1,
|
||||
startError: nil,
|
||||
stopError: errors.New("stop error"),
|
||||
expectedRestartsCount: 1,
|
||||
},
|
||||
{
|
||||
scenario: "multiple successful restarts",
|
||||
initialRestartsCount: 0,
|
||||
restartAttempts: 3,
|
||||
startError: nil,
|
||||
stopError: nil,
|
||||
expectedRestartsCount: 3,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
logger := zap.NewNop()
|
||||
|
||||
process := &ProcessMock{
|
||||
StartMock: func(logger *zap.Logger) error {
|
||||
return tc.startError
|
||||
},
|
||||
StopMock: func(logger *zap.Logger) error {
|
||||
return tc.stopError
|
||||
},
|
||||
}
|
||||
|
||||
ps := NewProcessSupervisor(logger, process, 0, 0).(*processSupervisor)
|
||||
ps.restartsCounter.Store(tc.initialRestartsCount)
|
||||
|
||||
for i := 0; i < tc.restartAttempts; i++ {
|
||||
_ = ps.restart()
|
||||
}
|
||||
|
||||
actualRestartsCount := ps.RestartsCount()
|
||||
if actualRestartsCount != tc.expectedRestartsCount {
|
||||
t.Fatalf("expected restarts count to be %d, but got %d", tc.expectedRestartsCount, actualRestartsCount)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
4
pkg/gotenberg/version.go
Normal file
4
pkg/gotenberg/version.go
Normal file
@@ -0,0 +1,4 @@
|
||||
package gotenberg
|
||||
|
||||
// Version is the... version of the Gotenberg application.
|
||||
var Version = "snapshot"
|
||||
@@ -4,52 +4,67 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/alexliesenfeld/health"
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/modules/gc"
|
||||
"github.com/dlclark/regexp2"
|
||||
"github.com/labstack/echo/v4"
|
||||
flag "github.com/spf13/pflag"
|
||||
"go.uber.org/multierr"
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/net/http2"
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
|
||||
func init() {
|
||||
gotenberg.MustRegisterModule(API{})
|
||||
gotenberg.MustRegisterModule(new(Api))
|
||||
}
|
||||
|
||||
// API is a module which provides an HTTP server. Other modules may add routes,
|
||||
// Api is a module which provides an HTTP server. Other modules may add routes,
|
||||
// middlewares or health checks.
|
||||
type API struct {
|
||||
type Api struct {
|
||||
port int
|
||||
readTimeout time.Duration
|
||||
writeTimeout time.Duration
|
||||
bindIp string
|
||||
tlsCertFile string
|
||||
tlsKeyFile string
|
||||
startTimeout time.Duration
|
||||
bodyLimit int64
|
||||
timeout time.Duration
|
||||
rootPath string
|
||||
traceHeader string
|
||||
basicAuthUsername string
|
||||
basicAuthPassword string
|
||||
downloadFromCfg downloadFromConfig
|
||||
disableHealthCheckLogging bool
|
||||
|
||||
routes []Route
|
||||
externalMiddlewares []Middleware
|
||||
healthChecks []health.CheckerOption
|
||||
gcGraceDuration time.Duration
|
||||
readyFn []func() error
|
||||
fs *gotenberg.FileSystem
|
||||
logger *zap.Logger
|
||||
srv *echo.Echo
|
||||
}
|
||||
|
||||
// Router is a module interface which adds routes to the API.
|
||||
type downloadFromConfig struct {
|
||||
allowList *regexp2.Regexp
|
||||
denyList *regexp2.Regexp
|
||||
maxRetry int
|
||||
disable bool
|
||||
}
|
||||
|
||||
// Router is a module interface which adds routes to the [Api].
|
||||
type Router interface {
|
||||
Routes() ([]Route, error)
|
||||
}
|
||||
|
||||
// Route represents a route from a Router.
|
||||
// Route represents a route from a [Router].
|
||||
type Route struct {
|
||||
// Method is the HTTP method of the route (i.e., GET, POST, etc.).
|
||||
// Required.
|
||||
@@ -72,13 +87,13 @@ type Route struct {
|
||||
Handler echo.HandlerFunc
|
||||
}
|
||||
|
||||
// MiddlewareProvider is a module interface which adds middlewares to the API.
|
||||
// MiddlewareProvider is a module interface which adds middlewares to the [Api].
|
||||
type MiddlewareProvider interface {
|
||||
Middlewares() ([]Middleware, error)
|
||||
}
|
||||
|
||||
// MiddlewareStack is a type which helps to determine in which stack the
|
||||
// middlewares provided by the MiddlewareProvider modules should be located.
|
||||
// middlewares provided by the [MiddlewareProvider] modules should be located.
|
||||
type MiddlewareStack uint32
|
||||
|
||||
const (
|
||||
@@ -88,7 +103,7 @@ const (
|
||||
)
|
||||
|
||||
// MiddlewarePriority is a type which helps to determine the execution order of
|
||||
// middlewares provided by the MiddlewareProvider modules in a stack.
|
||||
// middlewares provided by the [MiddlewareProvider] modules in a stack.
|
||||
type MiddlewarePriority uint32
|
||||
|
||||
const (
|
||||
@@ -99,7 +114,7 @@ const (
|
||||
VeryHighPriority
|
||||
)
|
||||
|
||||
// Middleware is a middleware which can be added to the API's middlewares
|
||||
// Middleware is a middleware which can be added to the [Api]'s middlewares
|
||||
// chain.
|
||||
//
|
||||
// middleware := Middleware{
|
||||
@@ -126,13 +141,13 @@ const (
|
||||
// }
|
||||
type Middleware struct {
|
||||
// Stack tells in which stack the middleware should be located.
|
||||
// Default to DefaultStack.
|
||||
// Default to [DefaultStack].
|
||||
// Optional.
|
||||
Stack MiddlewareStack
|
||||
|
||||
// Priority tells if the middleware should be positioned high or not in
|
||||
// its stack.
|
||||
// Default to VeryLowPriority.
|
||||
// Default to [VeryLowPriority].
|
||||
// Optional.
|
||||
Priority MiddlewarePriority
|
||||
|
||||
@@ -147,77 +162,82 @@ type Middleware struct {
|
||||
// See https://github.com/alexliesenfeld/health for more details.
|
||||
type HealthChecker interface {
|
||||
Checks() ([]health.CheckerOption, error)
|
||||
Ready() error
|
||||
}
|
||||
|
||||
// GarbageCollectorGraceDurationIncrementer is a module interface for
|
||||
// increasing the grace duration provided by the API for the garbage collector.
|
||||
type GarbageCollectorGraceDurationIncrementer interface {
|
||||
AddGraceDuration() time.Duration
|
||||
}
|
||||
|
||||
// Descriptor returns an API's module descriptor.
|
||||
func (API) Descriptor() gotenberg.ModuleDescriptor {
|
||||
// Descriptor returns an [Api]'s module descriptor.
|
||||
func (a *Api) Descriptor() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{
|
||||
ID: "api",
|
||||
FlagSet: func() *flag.FlagSet {
|
||||
fs := flag.NewFlagSet("api", flag.ExitOnError)
|
||||
fs.Int("api-port", 3000, "Set the port on which the API should listen")
|
||||
fs.String("api-port-from-env", "", "Set the environment variable with the port on which the API should listen - override the default port")
|
||||
fs.Duration("api-read-timeout", time.Duration(30)*time.Second, "Set the maximum duration allowed to read a complete request, including the body")
|
||||
fs.Duration("api-process-timeout", time.Duration(30)*time.Second, "Set the maximum duration allowed to process a request")
|
||||
fs.Duration("api-write-timeout", time.Duration(30)*time.Second, "Set the maximum duration before timing out writes of the response")
|
||||
fs.String("api-bind-ip", "", "Set the IP address the API should bind to for incoming connections")
|
||||
fs.String("api-tls-cert-file", "", "Path to the TLS/SSL certificate file - for HTTPS support")
|
||||
fs.String("api-tls-key-file", "", "Path to the TLS/SSL key file - for HTTPS support")
|
||||
fs.Duration("api-start-timeout", time.Duration(30)*time.Second, "Set the time limit for the API to start")
|
||||
fs.Duration("api-timeout", time.Duration(30)*time.Second, "Set the time limit for requests")
|
||||
fs.String("api-body-limit", "", "Set the body limit for multipart/form-data requests - it accepts values like 5MB, 1GB, etc")
|
||||
fs.String("api-root-path", "/", "Set the root path of the API - for service discovery via URL paths")
|
||||
fs.String("api-trace-header", "Gotenberg-Trace", "Set the header name to use for identifying requests")
|
||||
fs.Bool("api-enable-basic-auth", false, "Enable basic authentication - will look for the GOTENBERG_API_BASIC_AUTH_USERNAME and GOTENBERG_API_BASIC_AUTH_PASSWORD environment variables")
|
||||
fs.String("api-download-from-allow-list", "", "Set the allowed URLs for the download from feature using a regular expression")
|
||||
fs.String("api-download-from-deny-list", "", "Set the denied URLs for the download from feature using a regular expression")
|
||||
fs.Int("api-download-from-max-retry", 4, "Set the maximum number of retries for the download from feature")
|
||||
fs.Bool("api-disable-download-from", false, "Disable the download from feature")
|
||||
fs.Bool("api-disable-health-check-logging", false, "Disable health check logging")
|
||||
|
||||
var err error
|
||||
err = multierr.Append(err, fs.MarkDeprecated("api-read-timeout", "use api-timeout instead"))
|
||||
err = multierr.Append(err, fs.MarkDeprecated("api-process-timeout", "use api-timeout instead"))
|
||||
err = multierr.Append(err, fs.MarkDeprecated("api-write-timeout", "use api-timeout instead"))
|
||||
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("create deprecated flags for the api module: %v", err))
|
||||
}
|
||||
|
||||
return fs
|
||||
}(),
|
||||
New: func() gotenberg.Module { return new(API) },
|
||||
New: func() gotenberg.Module { return new(Api) },
|
||||
}
|
||||
}
|
||||
|
||||
// Provision sets the module properties.
|
||||
func (a *API) Provision(ctx *gotenberg.Context) error {
|
||||
func (a *Api) Provision(ctx *gotenberg.Context) error {
|
||||
flags := ctx.ParsedFlags()
|
||||
a.port = flags.MustInt("api-port")
|
||||
a.readTimeout = flags.MustDeprecatedDuration("api-read-timeout", "api-timeout")
|
||||
a.writeTimeout = flags.MustDeprecatedDuration("api-write-timeout", "api-timeout")
|
||||
a.timeout = flags.MustDeprecatedDuration("api-process-timeout", "api-timeout")
|
||||
a.bindIp = flags.MustString("api-bind-ip")
|
||||
a.tlsCertFile = flags.MustString("api-tls-cert-file")
|
||||
a.tlsKeyFile = flags.MustString("api-tls-key-file")
|
||||
a.startTimeout = flags.MustDuration("api-start-timeout")
|
||||
a.timeout = flags.MustDuration("api-timeout")
|
||||
a.bodyLimit = flags.MustHumanReadableBytes("api-body-limit")
|
||||
a.rootPath = flags.MustString("api-root-path")
|
||||
a.traceHeader = flags.MustString("api-trace-header")
|
||||
a.downloadFromCfg = downloadFromConfig{
|
||||
allowList: flags.MustRegexp("api-download-from-allow-list"),
|
||||
denyList: flags.MustRegexp("api-download-from-deny-list"),
|
||||
maxRetry: flags.MustInt("api-download-from-max-retry"),
|
||||
disable: flags.MustBool("api-disable-download-from"),
|
||||
}
|
||||
a.disableHealthCheckLogging = flags.MustBool("api-disable-health-check-logging")
|
||||
|
||||
// Port from env?
|
||||
portEnvVar := flags.MustString("api-port-from-env")
|
||||
if portEnvVar != "" {
|
||||
val, ok := os.LookupEnv(portEnvVar)
|
||||
|
||||
if !ok {
|
||||
return fmt.Errorf("environment variable '%s' does not exist", portEnvVar)
|
||||
}
|
||||
|
||||
if val == "" {
|
||||
return fmt.Errorf("environment variable '%s' is empty", portEnvVar)
|
||||
}
|
||||
|
||||
port, err := strconv.Atoi(val)
|
||||
port, err := gotenberg.IntEnv(portEnvVar)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get int value of environment variable '%s': %w", portEnvVar, err)
|
||||
return fmt.Errorf("get API port from env: %w", err)
|
||||
}
|
||||
|
||||
a.port = port
|
||||
}
|
||||
|
||||
// Enable basic auth?
|
||||
enableBasicAuth := flags.MustBool("api-enable-basic-auth")
|
||||
if enableBasicAuth {
|
||||
basicAuthUsername, err := gotenberg.StringEnv("GOTENBERG_API_BASIC_AUTH_USERNAME")
|
||||
if err != nil {
|
||||
return fmt.Errorf("get basic auth username from env: %w", err)
|
||||
}
|
||||
basicAuthPassword, err := gotenberg.StringEnv("GOTENBERG_API_BASIC_AUTH_PASSWORD")
|
||||
if err != nil {
|
||||
return fmt.Errorf("get basic auth password from env: %w", err)
|
||||
}
|
||||
a.basicAuthUsername = basicAuthUsername
|
||||
a.basicAuthPassword = basicAuthPassword
|
||||
}
|
||||
|
||||
// Get routes from modules.
|
||||
mods, err := ctx.Modules(new(Router))
|
||||
if err != nil {
|
||||
@@ -281,20 +301,10 @@ func (a *API) Provision(ctx *gotenberg.Context) error {
|
||||
}
|
||||
|
||||
a.healthChecks = append(a.healthChecks, checks...)
|
||||
a.readyFn = append(a.readyFn, healthChecker.Ready)
|
||||
}
|
||||
|
||||
// Grace duration.
|
||||
a.gcGraceDuration = a.timeout
|
||||
|
||||
mods, err = ctx.Modules(new(GarbageCollectorGraceDurationIncrementer))
|
||||
if err != nil {
|
||||
return fmt.Errorf("get garbage collector grace duration increments: %w", err)
|
||||
}
|
||||
|
||||
for _, incrementer := range mods {
|
||||
a.gcGraceDuration += incrementer.(GarbageCollectorGraceDurationIncrementer).AddGraceDuration()
|
||||
}
|
||||
|
||||
// Logger.
|
||||
loggerProvider, err := ctx.Module(new(gotenberg.LoggerProvider))
|
||||
if err != nil {
|
||||
return fmt.Errorf("get logger provider: %w", err)
|
||||
@@ -307,11 +317,14 @@ func (a *API) Provision(ctx *gotenberg.Context) error {
|
||||
|
||||
a.logger = logger
|
||||
|
||||
// File system.
|
||||
a.fs = gotenberg.NewFileSystem()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate validates the module properties.
|
||||
func (a API) Validate() error {
|
||||
func (a *Api) Validate() error {
|
||||
var err error
|
||||
|
||||
if a.port < 1 || a.port > 65535 {
|
||||
@@ -320,6 +333,16 @@ func (a API) Validate() error {
|
||||
)
|
||||
}
|
||||
|
||||
if a.bindIp != "" && net.ParseIP(a.bindIp) == nil {
|
||||
err = multierr.Append(err, errors.New("IP must be a valid IP address"))
|
||||
}
|
||||
|
||||
if (a.tlsCertFile != "" && a.tlsKeyFile == "") || (a.tlsCertFile == "" && a.tlsKeyFile != "") {
|
||||
err = multierr.Append(err,
|
||||
errors.New("both TLS certificate and key files must be set"),
|
||||
)
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(a.rootPath, "/") {
|
||||
err = multierr.Append(err,
|
||||
errors.New("root path must start with /"),
|
||||
@@ -342,8 +365,9 @@ func (a API) Validate() error {
|
||||
return err
|
||||
}
|
||||
|
||||
routesMap := make(map[string]string, len(a.routes)+1)
|
||||
routesMap := make(map[string]string, len(a.routes)+2)
|
||||
routesMap["/health"] = "/health"
|
||||
routesMap["/version"] = "/version"
|
||||
|
||||
for _, route := range a.routes {
|
||||
if route.Path == "" {
|
||||
@@ -383,14 +407,14 @@ func (a API) Validate() error {
|
||||
}
|
||||
|
||||
// Start starts the HTTP server.
|
||||
func (a *API) Start() error {
|
||||
func (a *Api) Start() error {
|
||||
a.srv = echo.New()
|
||||
a.srv.HideBanner = true
|
||||
a.srv.HidePort = true
|
||||
a.srv.Server.ReadTimeout = a.readTimeout
|
||||
a.srv.Server.ReadTimeout = a.timeout
|
||||
a.srv.Server.IdleTimeout = a.timeout
|
||||
// See https://github.com/gotenberg/gotenberg/issues/396.
|
||||
a.srv.Server.WriteTimeout = a.writeTimeout + a.writeTimeout
|
||||
a.srv.Server.WriteTimeout = a.timeout + a.timeout
|
||||
a.srv.HTTPErrorHandler = httpErrorHandler()
|
||||
|
||||
// Let's prepare the modules' routes.
|
||||
@@ -425,7 +449,7 @@ func (a *API) Start() error {
|
||||
a.srv.Pre(externalMiddleware.Handler)
|
||||
case MultipartStack:
|
||||
externalMultipartMiddlewares = append(externalMultipartMiddlewares, externalMiddleware)
|
||||
default:
|
||||
case DefaultStack:
|
||||
a.srv.Use(externalMiddleware.Handler)
|
||||
}
|
||||
}
|
||||
@@ -436,8 +460,13 @@ func (a *API) Start() error {
|
||||
for _, route := range a.routes {
|
||||
var middlewares []echo.MiddlewareFunc
|
||||
|
||||
// Basic auth?
|
||||
if a.basicAuthUsername != "" {
|
||||
middlewares = append(middlewares, basicAuthMiddleware(a.basicAuthUsername, a.basicAuthPassword))
|
||||
}
|
||||
|
||||
if route.IsMultipart {
|
||||
middlewares = append(middlewares, contextMiddleware(a.timeout))
|
||||
middlewares = append(middlewares, contextMiddleware(a.fs, a.timeout, a.bodyLimit, a.downloadFromCfg))
|
||||
|
||||
for _, externalMultipartMiddleware := range externalMultipartMiddlewares {
|
||||
middlewares = append(middlewares, externalMultipartMiddleware.Handler)
|
||||
@@ -454,22 +483,59 @@ func (a *API) Start() error {
|
||||
)
|
||||
}
|
||||
|
||||
// Let's not forget the health check route.
|
||||
// Let's not forget the health check routes...
|
||||
checks := append(a.healthChecks, health.WithTimeout(a.timeout))
|
||||
checker := health.NewChecker(checks...)
|
||||
healthCheckHandler := health.NewHandler(checker)
|
||||
|
||||
a.srv.GET(
|
||||
fmt.Sprintf("%s%s", a.rootPath, "health"),
|
||||
func() echo.HandlerFunc {
|
||||
checks := append(a.healthChecks, health.WithTimeout(a.timeout))
|
||||
checker := health.NewChecker(checks...)
|
||||
|
||||
return echo.WrapHandler(health.NewHandler(checker))
|
||||
return echo.WrapHandler(healthCheckHandler)
|
||||
}(),
|
||||
hardTimeoutMiddleware(hardTimeout),
|
||||
)
|
||||
a.srv.HEAD(
|
||||
fmt.Sprintf("%s%s", a.rootPath, "health"),
|
||||
func() echo.HandlerFunc {
|
||||
return echo.WrapHandler(healthCheckHandler)
|
||||
}(),
|
||||
hardTimeoutMiddleware(hardTimeout),
|
||||
)
|
||||
|
||||
// ...and the version route.
|
||||
a.srv.GET(
|
||||
fmt.Sprintf("%s%s", a.rootPath, "version"),
|
||||
func(c echo.Context) error {
|
||||
return c.String(http.StatusOK, gotenberg.Version)
|
||||
},
|
||||
)
|
||||
|
||||
// Wait for all modules to be ready.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), a.startTimeout)
|
||||
defer cancel()
|
||||
|
||||
eg, _ := errgroup.WithContext(ctx)
|
||||
for _, f := range a.readyFn {
|
||||
eg.Go(f)
|
||||
}
|
||||
|
||||
err := eg.Wait()
|
||||
if err != nil {
|
||||
return fmt.Errorf("waiting for modules readiness: %w", err)
|
||||
}
|
||||
|
||||
// As the following code is blocking, run it in a goroutine.
|
||||
go func() {
|
||||
server := &http2.Server{}
|
||||
err := a.srv.StartH2CServer(fmt.Sprintf(":%d", a.port), server)
|
||||
var err error
|
||||
if a.tlsCertFile != "" && a.tlsKeyFile != "" {
|
||||
// Start an HTTPS server (supports HTTP/2).
|
||||
err = a.srv.StartTLS(fmt.Sprintf("%s:%d", a.bindIp, a.port), a.tlsCertFile, a.tlsKeyFile)
|
||||
} else {
|
||||
// Start an HTTP/2 Cleartext (non-HTTPS) server.
|
||||
server := &http2.Server{}
|
||||
err = a.srv.StartH2CServer(fmt.Sprintf("%s:%d", a.bindIp, a.port), server)
|
||||
}
|
||||
if !errors.Is(err, http.ErrServerClosed) {
|
||||
a.logger.Fatal(err.Error())
|
||||
}
|
||||
@@ -479,26 +545,23 @@ func (a *API) Start() error {
|
||||
}
|
||||
|
||||
// StartupMessage returns a custom startup message.
|
||||
func (a API) StartupMessage() string {
|
||||
return fmt.Sprintf("server listening on port %d", a.port)
|
||||
func (a *Api) StartupMessage() string {
|
||||
ip := a.bindIp
|
||||
if a.bindIp == "" {
|
||||
ip = "[::]"
|
||||
}
|
||||
return fmt.Sprintf("server started on %s:%d", ip, a.port)
|
||||
}
|
||||
|
||||
// Stop stops the HTTP server.
|
||||
func (a API) Stop(ctx context.Context) error {
|
||||
func (a *Api) Stop(ctx context.Context) error {
|
||||
return a.srv.Shutdown(ctx)
|
||||
}
|
||||
|
||||
// GraceDuration updates the expiration time of files and directories parsed by
|
||||
// the gc.GarbageCollector.
|
||||
func (a API) GraceDuration() time.Duration {
|
||||
return a.gcGraceDuration
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ gotenberg.Module = (*API)(nil)
|
||||
_ gotenberg.Provisioner = (*API)(nil)
|
||||
_ gotenberg.Validator = (*API)(nil)
|
||||
_ gotenberg.App = (*API)(nil)
|
||||
_ gc.GarbageCollectorGraceDurationModifier = (*API)(nil)
|
||||
_ gotenberg.Module = (*Api)(nil)
|
||||
_ gotenberg.Provisioner = (*Api)(nil)
|
||||
_ gotenberg.Validator = (*Api)(nil)
|
||||
_ gotenberg.App = (*Api)(nil)
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,25 +3,28 @@ package api
|
||||
import (
|
||||
"compress/flate"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
||||
"github.com/hashicorp/go-retryablehttp"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/mholt/archiver/v3"
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/text/runes"
|
||||
"golang.org/x/text/transform"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -36,27 +39,80 @@ var (
|
||||
|
||||
// Context is the request context for a "multipart/form-data" requests.
|
||||
type Context struct {
|
||||
dirPath string
|
||||
values map[string][]string
|
||||
files map[string]string
|
||||
|
||||
dirPath string
|
||||
values map[string][]string
|
||||
files map[string]string
|
||||
outputPaths []string
|
||||
cancelled bool
|
||||
|
||||
cancelled bool
|
||||
logger *zap.Logger
|
||||
echoCtx echo.Context
|
||||
logger *zap.Logger
|
||||
echoCtx echo.Context
|
||||
pathRename gotenberg.PathRename
|
||||
context.Context
|
||||
}
|
||||
|
||||
// newContext returns a Context by parsing a "multipart/form-data" request.
|
||||
func newContext(echoCtx echo.Context, logger *zap.Logger, timeout time.Duration) (*Context, context.CancelFunc, error) {
|
||||
type trackingReader struct {
|
||||
R io.Reader
|
||||
AddReadBytes func(n int64) error
|
||||
}
|
||||
|
||||
func (t *trackingReader) Read(p []byte) (int, error) {
|
||||
n, err := t.R.Read(p)
|
||||
if n > 0 {
|
||||
errAddRead := t.AddReadBytes(int64(n))
|
||||
if errAddRead != nil {
|
||||
return n, fmt.Errorf("add read bytes: %w", errAddRead)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
// It's a common practice in Go to return io.EOF unwrapped to signal
|
||||
// the end of a data stream. Wrapping it can lead to unexpected
|
||||
// behavior in standard library functions.
|
||||
return n, err
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
type downloadFrom struct {
|
||||
// Url is the URL to download a file from.
|
||||
Url string `json:"url"`
|
||||
|
||||
// ExtraHttpHeaders are the HTTP headers to send alongside.
|
||||
ExtraHttpHeaders map[string]string `json:"extraHttpHeaders"`
|
||||
}
|
||||
|
||||
type osPathRename struct{}
|
||||
|
||||
func (o *osPathRename) Rename(oldpath, newpath string) error {
|
||||
return os.Rename(oldpath, newpath)
|
||||
}
|
||||
|
||||
// newContext returns a [Context] by parsing a "multipart/form-data" request.
|
||||
func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSystem, timeout time.Duration, bodyLimit int64, downloadFromCfg downloadFromConfig, traceHeader, trace string) (*Context, context.CancelFunc, error) {
|
||||
processCtx, processCancel := context.WithTimeout(context.Background(), timeout)
|
||||
|
||||
// We want to make sure the multipart/form-data does not exceed a given
|
||||
// limit. We consider: form fields (keys, values, files) and files
|
||||
// downloaded remotely ("download from" feature).
|
||||
var totalBytesRead atomic.Int64
|
||||
|
||||
addReadBytes := func(n int64) error {
|
||||
newTotal := totalBytesRead.Add(n)
|
||||
if bodyLimit != 0 && newTotal > bodyLimit {
|
||||
return WrapError(
|
||||
fmt.Errorf("body limit reached (> %d)", bodyLimit),
|
||||
NewSentinelHttpError(http.StatusRequestEntityTooLarge, http.StatusText(http.StatusRequestEntityTooLarge)),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := &Context{
|
||||
outputPaths: make([]string, 0),
|
||||
cancelled: false,
|
||||
logger: logger,
|
||||
echoCtx: echoCtx,
|
||||
pathRename: new(osPathRename),
|
||||
Context: processCtx,
|
||||
}
|
||||
|
||||
@@ -81,39 +137,51 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, timeout time.Duration)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.logger.Debug(fmt.Sprintf("'%s' removed", ctx.dirPath))
|
||||
ctx.logger.Debug(fmt.Sprintf("'%s' context's working directory removed", ctx.dirPath))
|
||||
ctx.cancelled = true
|
||||
}
|
||||
}()
|
||||
|
||||
form, err := echoCtx.MultipartForm()
|
||||
if err != nil {
|
||||
|
||||
if errors.Is(err, http.ErrNotMultipart) {
|
||||
return nil, cancel, WrapError(
|
||||
fmt.Errorf("get multipart form: %w", err),
|
||||
NewSentinelHTTPError(http.StatusUnsupportedMediaType, "Invalid 'Content-Type' header value: want 'multipart/form-data'"),
|
||||
NewSentinelHttpError(http.StatusUnsupportedMediaType, "Invalid 'Content-Type' header value: want 'multipart/form-data'"),
|
||||
)
|
||||
}
|
||||
|
||||
if errors.Is(err, http.ErrMissingBoundary) {
|
||||
return nil, cancel, WrapError(
|
||||
fmt.Errorf("get multipart form: %w", err),
|
||||
NewSentinelHTTPError(http.StatusUnsupportedMediaType, "Invalid 'Content-Type' header value: no boundary"),
|
||||
NewSentinelHttpError(http.StatusUnsupportedMediaType, "Invalid 'Content-Type' header value: no boundary"),
|
||||
)
|
||||
}
|
||||
|
||||
if strings.Contains(err.Error(), io.EOF.Error()) {
|
||||
return nil, cancel, WrapError(
|
||||
fmt.Errorf("get multipart form: %w", err),
|
||||
NewSentinelHTTPError(http.StatusBadRequest, "Malformed body: it does not match the 'Content-Type' header boundaries"),
|
||||
NewSentinelHttpError(http.StatusBadRequest, "Malformed body: it does not match the 'Content-Type' header boundaries"),
|
||||
)
|
||||
}
|
||||
|
||||
return nil, cancel, fmt.Errorf("get multipart form: %w", err)
|
||||
}
|
||||
|
||||
dirPath, err := gotenberg.MkdirAll()
|
||||
// This will ensure we do not exceed the body limit.
|
||||
var formValuesSize int64
|
||||
for key, valArray := range form.Value {
|
||||
formValuesSize += int64(len(key))
|
||||
for _, val := range valArray {
|
||||
formValuesSize += int64(len(val))
|
||||
}
|
||||
}
|
||||
err = addReadBytes(formValuesSize)
|
||||
if err != nil {
|
||||
return nil, cancel, fmt.Errorf("add read bytes: %w", err)
|
||||
}
|
||||
|
||||
dirPath, err := fs.MkdirAll()
|
||||
if err != nil {
|
||||
return nil, cancel, fmt.Errorf("create working directory: %w", err)
|
||||
}
|
||||
@@ -122,16 +190,151 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, timeout time.Duration)
|
||||
ctx.values = form.Value
|
||||
ctx.files = make(map[string]string)
|
||||
|
||||
copyToDisk := func(fh *multipart.FileHeader) error {
|
||||
// Avoid directory traversal and normalize filename.
|
||||
// See https://github.com/gotenberg/gotenberg/issues/104.
|
||||
t := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC)
|
||||
|
||||
filename, _, err := transform.String(t, filepath.Base(fh.Filename))
|
||||
// First, try to download files listed in the "downloadFrom" form field, if
|
||||
// any.
|
||||
raw, ok := ctx.values["downloadFrom"]
|
||||
if !downloadFromCfg.disable && ok {
|
||||
var dls []downloadFrom
|
||||
err = json.Unmarshal([]byte(raw[0]), &dls)
|
||||
if err != nil {
|
||||
return fmt.Errorf("transform filename: %w", err)
|
||||
return nil, cancel, WrapError(
|
||||
fmt.Errorf("unmarshal json: %w", err),
|
||||
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Invalid 'downloadFrom' form field value: %s", err)),
|
||||
)
|
||||
}
|
||||
|
||||
eg, _ := errgroup.WithContext(ctx)
|
||||
for i, dl := range dls {
|
||||
eg.Go(func() error {
|
||||
deadline, ok := ctx.Deadline()
|
||||
if !ok {
|
||||
// Should not happen, as context is created with a timeout.
|
||||
return errors.New("context has no deadline")
|
||||
}
|
||||
|
||||
if strings.TrimSpace(dl.Url) == "" {
|
||||
return WrapError(
|
||||
errors.New("empty download from URL"),
|
||||
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Invalid 'downloadFrom' form field entry %d: URL must be set", i)),
|
||||
)
|
||||
}
|
||||
|
||||
err := gotenberg.FilterDeadline(downloadFromCfg.allowList, downloadFromCfg.denyList, dl.Url, deadline)
|
||||
if err != nil {
|
||||
return fmt.Errorf("filter URL: %w", err)
|
||||
}
|
||||
|
||||
logger.Debug(fmt.Sprintf("download file from '%s'", dl.Url))
|
||||
|
||||
req, err := retryablehttp.NewRequest(http.MethodGet, dl.Url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create request to '%s': %w", dl.Url, err)
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", "Gotenberg")
|
||||
for key, value := range dl.ExtraHttpHeaders {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
req.Header.Set(traceHeader, trace)
|
||||
|
||||
client := &retryablehttp.Client{
|
||||
HTTPClient: &http.Client{
|
||||
Timeout: time.Until(deadline),
|
||||
},
|
||||
RetryMax: downloadFromCfg.maxRetry,
|
||||
RetryWaitMin: time.Duration(1) * time.Second,
|
||||
RetryWaitMax: time.Until(deadline),
|
||||
Logger: gotenberg.NewLeveledLogger(logger),
|
||||
CheckRetry: retryablehttp.DefaultRetryPolicy,
|
||||
Backoff: retryablehttp.DefaultBackoff,
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return WrapError(
|
||||
fmt.Errorf("download file from to '%s': %w", dl.Url, err),
|
||||
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Unable to download file from '%s': %s", dl.Url, err)),
|
||||
)
|
||||
}
|
||||
defer func() {
|
||||
err := resp.Body.Close()
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("close response body from '%s': %s", dl.Url, err))
|
||||
}
|
||||
}()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return WrapError(
|
||||
fmt.Errorf("download file from to '%s': got status: '%s'", dl.Url, resp.Status),
|
||||
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Unable to download file from '%s': got status: '%s'", dl.Url, resp.Status)),
|
||||
)
|
||||
}
|
||||
|
||||
contentDisposition := resp.Header.Get("Content-Disposition")
|
||||
if contentDisposition == "" {
|
||||
return WrapError(
|
||||
fmt.Errorf("no 'Content-Disposition' header from '%s'", dl.Url),
|
||||
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("No 'Content-Disposition' header from '%s'", dl.Url)),
|
||||
)
|
||||
}
|
||||
|
||||
// FIXME: the implementation of this method might not be
|
||||
// complete, as it fails to parse an empty mediatype.
|
||||
// See: https://github.com/golang/go/issues/69551.
|
||||
_, params, err := mime.ParseMediaType(contentDisposition)
|
||||
if err != nil {
|
||||
return WrapError(
|
||||
fmt.Errorf("parse 'Content-Disposition' header '%s' from '%s': %w", contentDisposition, dl.Url, err),
|
||||
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Invalid 'Content-Disposition' header '%s' from '%s': %s", contentDisposition, dl.Url, err)),
|
||||
)
|
||||
}
|
||||
|
||||
filename, ok := params["filename"]
|
||||
if !ok {
|
||||
return WrapError(
|
||||
fmt.Errorf("get filename from 'Content-Disposition' header '%s' from '%s'", contentDisposition, dl.Url),
|
||||
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Invalid 'Content-Disposition' header '%s' from '%s': no filename", contentDisposition, dl.Url)),
|
||||
)
|
||||
}
|
||||
|
||||
// Avoid directory traversal and make sure filename characters are
|
||||
// normalized.
|
||||
// See: https://github.com/gotenberg/gotenberg/issues/662.
|
||||
filename = norm.NFC.String(filepath.Base(filename))
|
||||
path := fmt.Sprintf("%s/%s", ctx.dirPath, filename)
|
||||
|
||||
out, err := os.Create(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create local file: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
err := out.Close()
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("close local file: %s", err))
|
||||
}
|
||||
}()
|
||||
|
||||
// This will ensure we do not exceed the body limit.
|
||||
reader := &trackingReader{R: resp.Body, AddReadBytes: addReadBytes}
|
||||
|
||||
_, err = io.Copy(out, reader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("copy downloaded file from '%s' to local file: %w", dl.Url, err)
|
||||
}
|
||||
|
||||
ctx.files[filename] = path
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
err = eg.Wait()
|
||||
if err != nil {
|
||||
return ctx, cancel, err
|
||||
}
|
||||
}
|
||||
|
||||
copyToDisk := func(fh *multipart.FileHeader) error {
|
||||
in, err := fh.Open()
|
||||
if err != nil {
|
||||
return fmt.Errorf("open multipart file: %w", err)
|
||||
@@ -144,13 +347,19 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, timeout time.Duration)
|
||||
}
|
||||
}()
|
||||
|
||||
// This will ensure we do not exceed the body limit.
|
||||
reader := &trackingReader{R: in, AddReadBytes: addReadBytes}
|
||||
|
||||
// Avoid directory traversal and make sure filename characters are
|
||||
// normalized.
|
||||
// See: https://github.com/gotenberg/gotenberg/issues/662.
|
||||
filename := norm.NFC.String(filepath.Base(fh.Filename))
|
||||
path := fmt.Sprintf("%s/%s", ctx.dirPath, filename)
|
||||
|
||||
out, err := os.Create(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create local file: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err := out.Close()
|
||||
if err != nil {
|
||||
@@ -158,7 +367,7 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, timeout time.Duration)
|
||||
}
|
||||
}()
|
||||
|
||||
_, err = io.Copy(out, in)
|
||||
_, err = io.Copy(out, reader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("copy multipart file to local file: %w", err)
|
||||
}
|
||||
@@ -168,29 +377,30 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, timeout time.Duration)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Then, copy the form files, if any.
|
||||
for _, files := range form.File {
|
||||
for _, fh := range files {
|
||||
err = copyToDisk(fh)
|
||||
|
||||
if err != nil {
|
||||
return ctx, cancel, fmt.Errorf("copy to disk: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.Log().Debug(fmt.Sprintf("form data values: %+v", ctx.values))
|
||||
ctx.Log().Debug(fmt.Sprintf("form data files: %+v", ctx.files))
|
||||
ctx.Log().Debug(fmt.Sprintf("form fields: %+v", ctx.values))
|
||||
ctx.Log().Debug(fmt.Sprintf("form files: %+v", ctx.files))
|
||||
ctx.Log().Debug(fmt.Sprintf("total bytes: %d", totalBytesRead.Load()))
|
||||
|
||||
return ctx, cancel, err
|
||||
}
|
||||
|
||||
// Request returns the http.Request.
|
||||
func (ctx Context) Request() *http.Request {
|
||||
// Request returns the [http.Request].
|
||||
func (ctx *Context) Request() *http.Request {
|
||||
return ctx.echoCtx.Request()
|
||||
}
|
||||
|
||||
// FormData return a FormData.
|
||||
func (ctx Context) FormData() *FormData {
|
||||
// FormData return a [FormData].
|
||||
func (ctx *Context) FormData() *FormData {
|
||||
return &FormData{
|
||||
values: ctx.values,
|
||||
files: ctx.files,
|
||||
@@ -198,10 +408,20 @@ func (ctx Context) FormData() *FormData {
|
||||
}
|
||||
}
|
||||
|
||||
// GeneratePath generates a path within the context's working directory. It
|
||||
// does not create a file.
|
||||
func (ctx Context) GeneratePath(extension string) string {
|
||||
return fmt.Sprintf("%s/%s%s", ctx.dirPath, uuid.New(), extension)
|
||||
// GeneratePath generates a path within the context's working directory.
|
||||
// It generates a new UUID-based filename. It does not create a file.
|
||||
func (ctx *Context) GeneratePath(extension string) string {
|
||||
return fmt.Sprintf("%s/%s%s", ctx.dirPath, uuid.New().String(), extension)
|
||||
}
|
||||
|
||||
// Rename is just a wrapper around [os.Rename], as we need to mock this
|
||||
// behavior in our tests.
|
||||
func (ctx *Context) Rename(oldpath, newpath string) error {
|
||||
err := ctx.pathRename.Rename(oldpath, newpath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("rename path: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddOutputPaths adds the given paths. Those paths will be used later to build
|
||||
@@ -222,14 +442,14 @@ func (ctx *Context) AddOutputPaths(paths ...string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Log returns the context zap.Logger.
|
||||
func (ctx Context) Log() *zap.Logger {
|
||||
// Log returns the context [zap.Logger].
|
||||
func (ctx *Context) Log() *zap.Logger {
|
||||
return ctx.logger
|
||||
}
|
||||
|
||||
// BuildOutputFile builds the output file according to the output paths
|
||||
// registered in the context. If many output paths, an archive is created.
|
||||
func (ctx Context) BuildOutputFile() (string, error) {
|
||||
func (ctx *Context) BuildOutputFile() (string, error) {
|
||||
if ctx.cancelled {
|
||||
return "", ErrContextAlreadyClosed
|
||||
}
|
||||
@@ -267,7 +487,7 @@ func (ctx Context) BuildOutputFile() (string, error) {
|
||||
|
||||
// OutputFilename returns the filename based on the given output path or the
|
||||
// "Gotenberg-Output-Filename" header's value.
|
||||
func (ctx Context) OutputFilename(outputPath string) string {
|
||||
func (ctx *Context) OutputFilename(outputPath string) string {
|
||||
filename := ctx.echoCtx.Request().Header.Get("Gotenberg-Output-Filename")
|
||||
|
||||
if filename == "" {
|
||||
@@ -276,3 +496,8 @@ func (ctx Context) OutputFilename(outputPath string) string {
|
||||
|
||||
return fmt.Sprintf("%s%s", filename, filepath.Ext(outputPath))
|
||||
}
|
||||
|
||||
// Interface guard.
|
||||
var (
|
||||
_ gotenberg.PathRename = (*osPathRename)(nil)
|
||||
)
|
||||
|
||||
@@ -2,154 +2,613 @@ package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
||||
"github.com/dlclark/regexp2"
|
||||
"github.com/google/uuid"
|
||||
"github.com/labstack/echo/v4"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
|
||||
func TestOsPathRename_Rename(t *testing.T) {
|
||||
dirPath, err := gotenberg.NewFileSystem().MkdirAll()
|
||||
if err != nil {
|
||||
t.Fatalf("create working directory: %v", err)
|
||||
}
|
||||
|
||||
path := "/tests/test/testdata/api/sample1.txt"
|
||||
copyPath := filepath.Join(dirPath, fmt.Sprintf("%s.txt", uuid.NewString()))
|
||||
|
||||
in, err := os.Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("open file: %v", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err := in.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("close file: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
out, err := os.Create(copyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("create new file: %v", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err := out.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("close new file: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
_, err = io.Copy(out, in)
|
||||
if err != nil {
|
||||
t.Fatalf("copy file to new file: %v", err)
|
||||
}
|
||||
|
||||
rename := new(osPathRename)
|
||||
newPath := filepath.Join(dirPath, fmt.Sprintf("%s.txt", uuid.NewString()))
|
||||
|
||||
err = rename.Rename(copyPath, newPath)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
err = os.RemoveAll(dirPath)
|
||||
if err != nil {
|
||||
t.Fatalf("remove working directory: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewContext(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
defaultAllowList, err := regexp2.Compile("", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
defaultDenyList, err := regexp2.Compile("", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
defaultDownloadFromCfg := downloadFromConfig{
|
||||
allowList: defaultAllowList,
|
||||
denyList: defaultDenyList,
|
||||
maxRetry: 1,
|
||||
disable: false,
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
request *http.Request
|
||||
expectErr bool
|
||||
expectHTTPErr bool
|
||||
expectHTTPStatus int
|
||||
bodyLimit int64
|
||||
downloadFromCfg downloadFromConfig
|
||||
downloadFromSrv *echo.Echo
|
||||
expectContext *Context
|
||||
expectError bool
|
||||
expectHttpError bool
|
||||
expectHttpStatus int
|
||||
}{
|
||||
{
|
||||
scenario: "http.ErrNotMultipart",
|
||||
request: httptest.NewRequest(http.MethodPost, "/", nil),
|
||||
expectErr: true,
|
||||
expectHTTPErr: true,
|
||||
expectHTTPStatus: http.StatusUnsupportedMediaType,
|
||||
expectError: true,
|
||||
expectHttpError: true,
|
||||
expectHttpStatus: http.StatusUnsupportedMediaType,
|
||||
},
|
||||
{
|
||||
scenario: "http.ErrMissingBoundary",
|
||||
request: func() *http.Request {
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
req.Header.Set(echo.HeaderContentType, echo.MIMEMultipartForm)
|
||||
|
||||
return req
|
||||
}(),
|
||||
expectErr: true,
|
||||
expectHTTPErr: true,
|
||||
expectHTTPStatus: http.StatusUnsupportedMediaType,
|
||||
expectError: true,
|
||||
expectHttpError: true,
|
||||
expectHttpStatus: http.StatusUnsupportedMediaType,
|
||||
},
|
||||
{
|
||||
scenario: "malformed body",
|
||||
request: func() *http.Request {
|
||||
body := &bytes.Buffer{}
|
||||
|
||||
writer := multipart.NewWriter(body)
|
||||
|
||||
defer func() {
|
||||
err := writer.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
err := writer.WriteField("foo", "foo")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
|
||||
|
||||
return req
|
||||
}(),
|
||||
expectErr: true,
|
||||
expectHTTPErr: true,
|
||||
expectHTTPStatus: http.StatusBadRequest,
|
||||
expectError: true,
|
||||
expectHttpError: true,
|
||||
expectHttpStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
scenario: "request entity too large: form values",
|
||||
request: func() *http.Request {
|
||||
body := &bytes.Buffer{}
|
||||
|
||||
writer := multipart.NewWriter(body)
|
||||
|
||||
defer func() {
|
||||
err := writer.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
err := writer.WriteField("foo", "foo")
|
||||
err := writer.WriteField("key", "value")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/", body)
|
||||
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
|
||||
return req
|
||||
}(),
|
||||
bodyLimit: 1,
|
||||
downloadFromCfg: defaultDownloadFromCfg,
|
||||
expectError: true,
|
||||
expectHttpError: true,
|
||||
expectHttpStatus: http.StatusRequestEntityTooLarge,
|
||||
},
|
||||
{
|
||||
scenario: "request entity too large: downloadFrom",
|
||||
request: func() *http.Request {
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
defer func() {
|
||||
err := writer.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
}()
|
||||
err := writer.WriteField("downloadFrom", `[{"url":"http://localhost:80/"}]`)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/", body)
|
||||
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
|
||||
return req
|
||||
}(),
|
||||
bodyLimit: 45, // form values = 44 bytes.
|
||||
downloadFromSrv: func() *echo.Echo {
|
||||
srv := echo.New()
|
||||
srv.HideBanner = true
|
||||
srv.GET("/", func(c echo.Context) error {
|
||||
c.Response().Header().Set(echo.HeaderContentDisposition, `attachment; filename="bar.txt"`)
|
||||
c.Response().Header().Set(echo.HeaderContentType, "text/plain")
|
||||
return c.String(http.StatusOK, http.StatusText(http.StatusOK))
|
||||
})
|
||||
return srv
|
||||
}(),
|
||||
downloadFromCfg: defaultDownloadFromCfg,
|
||||
expectError: true,
|
||||
expectHttpError: true,
|
||||
expectHttpStatus: http.StatusRequestEntityTooLarge,
|
||||
},
|
||||
{
|
||||
scenario: "request entity too large: form files",
|
||||
request: func() *http.Request {
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
defer func() {
|
||||
err := writer.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
}()
|
||||
part, err := writer.CreateFormFile("foo.txt", "foo.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
_, err = part.Write([]byte("foo"))
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/", body)
|
||||
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
|
||||
|
||||
return req
|
||||
}(),
|
||||
bodyLimit: 1,
|
||||
downloadFromCfg: defaultDownloadFromCfg,
|
||||
expectError: true,
|
||||
expectHttpError: true,
|
||||
expectHttpStatus: http.StatusRequestEntityTooLarge,
|
||||
},
|
||||
{
|
||||
scenario: "invalid downloadFrom form field: cannot unmarshal",
|
||||
request: func() *http.Request {
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
defer func() {
|
||||
err := writer.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
}()
|
||||
err := writer.WriteField("downloadFrom", "foo")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/", body)
|
||||
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
|
||||
return req
|
||||
}(),
|
||||
downloadFromCfg: defaultDownloadFromCfg,
|
||||
expectError: true,
|
||||
expectHttpError: true,
|
||||
expectHttpStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
scenario: "invalid downloadFrom form field: no URL",
|
||||
request: func() *http.Request {
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
defer func() {
|
||||
err := writer.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
}()
|
||||
err := writer.WriteField("downloadFrom", `[{}]`)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/", body)
|
||||
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
|
||||
return req
|
||||
}(),
|
||||
downloadFromCfg: defaultDownloadFromCfg,
|
||||
expectError: true,
|
||||
expectHttpError: true,
|
||||
expectHttpStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
scenario: "invalid downloadFrom form field: filtered URL",
|
||||
request: func() *http.Request {
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
defer func() {
|
||||
err := writer.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
}()
|
||||
err := writer.WriteField("downloadFrom", `[{"url":"https://foo.bar"}]`)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/", body)
|
||||
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
|
||||
return req
|
||||
}(),
|
||||
downloadFromCfg: func() downloadFromConfig {
|
||||
denyList, err := regexp2.Compile("https://foo.bar", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
return downloadFromConfig{allowList: defaultAllowList, denyList: denyList, maxRetry: 1, disable: false}
|
||||
}(),
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "invalid downloadFrom form field: unreachable URL",
|
||||
request: func() *http.Request {
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
defer func() {
|
||||
err := writer.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
}()
|
||||
err := writer.WriteField("downloadFrom", `[{"url":"http://localhost:80/"}]`)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/", body)
|
||||
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
|
||||
return req
|
||||
}(),
|
||||
downloadFromCfg: defaultDownloadFromCfg,
|
||||
expectError: true,
|
||||
expectHttpError: true,
|
||||
expectHttpStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
scenario: "invalid downloadFrom form field: invalid status code",
|
||||
request: func() *http.Request {
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
defer func() {
|
||||
err := writer.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
}()
|
||||
err := writer.WriteField("downloadFrom", `[{"url":"http://localhost:80/"}]`)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/", body)
|
||||
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
|
||||
return req
|
||||
}(),
|
||||
downloadFromSrv: func() *echo.Echo {
|
||||
srv := echo.New()
|
||||
srv.HideBanner = true
|
||||
srv.GET("/", func(c echo.Context) error {
|
||||
return c.String(http.StatusNotFound, http.StatusText(http.StatusNotFound))
|
||||
})
|
||||
return srv
|
||||
}(),
|
||||
downloadFromCfg: defaultDownloadFromCfg,
|
||||
expectError: true,
|
||||
expectHttpError: true,
|
||||
expectHttpStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
scenario: "invalid downloadFrom form field: no 'Content-Disposition' header",
|
||||
request: func() *http.Request {
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
defer func() {
|
||||
err := writer.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
}()
|
||||
err := writer.WriteField("downloadFrom", `[{"url":"http://localhost:80/"}]`)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/", body)
|
||||
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
|
||||
return req
|
||||
}(),
|
||||
downloadFromSrv: func() *echo.Echo {
|
||||
srv := echo.New()
|
||||
srv.HideBanner = true
|
||||
srv.GET("/", func(c echo.Context) error {
|
||||
return c.String(http.StatusOK, http.StatusText(http.StatusOK))
|
||||
})
|
||||
return srv
|
||||
}(),
|
||||
downloadFromCfg: defaultDownloadFromCfg,
|
||||
expectError: true,
|
||||
expectHttpError: true,
|
||||
expectHttpStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
scenario: "invalid downloadFrom form field: malformed 'Content-Disposition' header",
|
||||
request: func() *http.Request {
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
defer func() {
|
||||
err := writer.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
}()
|
||||
err := writer.WriteField("downloadFrom", `[{"url":"http://localhost:80/"}]`)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/", body)
|
||||
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
|
||||
return req
|
||||
}(),
|
||||
downloadFromSrv: func() *echo.Echo {
|
||||
srv := echo.New()
|
||||
srv.HideBanner = true
|
||||
srv.GET("/", func(c echo.Context) error {
|
||||
c.Response().Header().Set(echo.HeaderContentDisposition, ";;")
|
||||
return c.String(http.StatusOK, http.StatusText(http.StatusOK))
|
||||
})
|
||||
return srv
|
||||
}(),
|
||||
downloadFromCfg: defaultDownloadFromCfg,
|
||||
expectError: true,
|
||||
expectHttpError: true,
|
||||
expectHttpStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
scenario: "invalid downloadFrom form field: no filename parameter in 'Content-Disposition' header",
|
||||
request: func() *http.Request {
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
defer func() {
|
||||
err := writer.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
}()
|
||||
err := writer.WriteField("downloadFrom", `[{"url":"http://localhost:80/"}]`)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/", body)
|
||||
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
|
||||
return req
|
||||
}(),
|
||||
downloadFromSrv: func() *echo.Echo {
|
||||
srv := echo.New()
|
||||
srv.HideBanner = true
|
||||
srv.GET("/", func(c echo.Context) error {
|
||||
c.Response().Header().Set(echo.HeaderContentDisposition, "inline;")
|
||||
return c.String(http.StatusOK, http.StatusText(http.StatusOK))
|
||||
})
|
||||
return srv
|
||||
}(),
|
||||
downloadFromCfg: defaultDownloadFromCfg,
|
||||
expectError: true,
|
||||
expectHttpError: true,
|
||||
expectHttpStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
scenario: "success",
|
||||
request: func() *http.Request {
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
defer func() {
|
||||
err := writer.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
}()
|
||||
err := writer.WriteField("foo", "foo")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
part, err := writer.CreateFormFile("foo.txt", "foo.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
_, err = part.Write([]byte("foo"))
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
err = writer.WriteField("downloadFrom", `[{"url":"http://localhost:80/","extraHttpHeaders":{"X-Foo":"Bar"}}]`)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/", body)
|
||||
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
|
||||
return req
|
||||
}(),
|
||||
downloadFromSrv: func() *echo.Echo {
|
||||
srv := echo.New()
|
||||
srv.HideBanner = true
|
||||
srv.GET("/", func(c echo.Context) error {
|
||||
if c.Request().Header.Get("User-Agent") != "Gotenberg" {
|
||||
t.Fatalf("expected 'Gotenberg' from header 'User-Agent', but got '%s'", c.Request().Header.Get("User-Agent"))
|
||||
}
|
||||
if c.Request().Header.Get("X-Foo") != "Bar" {
|
||||
t.Fatalf("expected 'Bar' from header 'X-Foo', but got '%s'", c.Request().Header.Get("X-Foo"))
|
||||
}
|
||||
if c.Request().Header.Get("Gotenberg-Trace") != "123" {
|
||||
t.Fatalf("expected '123' from header 'Gotenberg-Trace', but got '%s'", c.Request().Header.Get("Gotenberg-Trace"))
|
||||
}
|
||||
c.Response().Header().Set(echo.HeaderContentDisposition, `attachment; filename="bar.txt"`)
|
||||
c.Response().Header().Set(echo.HeaderContentType, "text/plain")
|
||||
return c.String(http.StatusOK, http.StatusText(http.StatusOK))
|
||||
})
|
||||
return srv
|
||||
}(),
|
||||
downloadFromCfg: defaultDownloadFromCfg,
|
||||
expectContext: &Context{
|
||||
values: map[string][]string{
|
||||
"foo": {"foo"},
|
||||
"downloadFrom": {
|
||||
`[{"url":"http://localhost:80/","extraHttpHeaders":{"X-Foo":"Bar"}}]`,
|
||||
},
|
||||
},
|
||||
files: map[string]string{
|
||||
"foo.txt": "foo.txt",
|
||||
"bar.txt": "bar.txt", // downloadFrom.
|
||||
},
|
||||
},
|
||||
expectError: false,
|
||||
expectHttpError: false,
|
||||
},
|
||||
} {
|
||||
handler := func(c echo.Context) error {
|
||||
_, cancel, err := newContext(c, zap.NewNop(), time.Duration(10)*time.Second)
|
||||
defer cancel()
|
||||
// Context already cancelled.
|
||||
defer cancel()
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
if tc.downloadFromSrv != nil {
|
||||
go func() {
|
||||
err := tc.downloadFromSrv.Start(":80")
|
||||
if !errors.Is(err, http.ErrServerClosed) {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
}()
|
||||
defer func() {
|
||||
err := tc.downloadFromSrv.Shutdown(context.TODO())
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
handler := func(c echo.Context) error {
|
||||
ctx, cancel, err := newContext(c, zap.NewNop(), gotenberg.NewFileSystem(), time.Duration(10)*time.Second, tc.bodyLimit, tc.downloadFromCfg, "Gotenberg-Trace", "123")
|
||||
defer cancel()
|
||||
// Context already cancelled.
|
||||
defer cancel()
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
srv := echo.New()
|
||||
srv.HideBanner = true
|
||||
srv.HidePort = true
|
||||
if tc.expectContext != nil {
|
||||
if !reflect.DeepEqual(tc.expectContext.values, ctx.values) {
|
||||
t.Fatalf("expected context.values to be %v but got %v", tc.expectContext.values, ctx.values)
|
||||
}
|
||||
if len(tc.expectContext.files) != len(ctx.files) {
|
||||
t.Fatalf("expected context.files to contain %d items but got %d", len(tc.expectContext.files), len(ctx.files))
|
||||
}
|
||||
for key, value := range tc.expectContext.files {
|
||||
if !strings.HasSuffix(ctx.files[key], value) {
|
||||
t.Fatalf("expected context.files to contain '%s' but got '%s'", value, ctx.files[key])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c := srv.NewContext(tc.request, recorder)
|
||||
err := handler(c)
|
||||
|
||||
if tc.expectErr && err == nil {
|
||||
t.Errorf("test %d: expected error but got: %v", i, err)
|
||||
}
|
||||
|
||||
if !tc.expectErr && err != nil {
|
||||
t.Errorf("test %d: expected no error but got: %v", i, err)
|
||||
}
|
||||
|
||||
var httpErr HTTPError
|
||||
isHTTPErr := errors.As(err, &httpErr)
|
||||
|
||||
if tc.expectHTTPErr && !isHTTPErr {
|
||||
t.Errorf("test %d: expected HTTP error but got: %v", i, err)
|
||||
}
|
||||
|
||||
if !tc.expectHTTPErr && isHTTPErr {
|
||||
t.Errorf("test %d: expected no HTTP error but got one: %v", i, httpErr)
|
||||
}
|
||||
|
||||
if err != nil && tc.expectHTTPErr && isHTTPErr {
|
||||
status, _ := httpErr.HTTPError()
|
||||
if status != tc.expectHTTPStatus {
|
||||
t.Errorf("test %d: expected %d HTTP status code but got %d", i, tc.expectHTTPStatus, status)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
srv := echo.New()
|
||||
srv.HideBanner = true
|
||||
srv.HidePort = true
|
||||
|
||||
c := srv.NewContext(tc.request, recorder)
|
||||
err := handler(c)
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none", err)
|
||||
}
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
var httpErr HttpError
|
||||
isHttpError := errors.As(err, &httpErr)
|
||||
|
||||
if tc.expectHttpError && !isHttpError {
|
||||
t.Errorf("expected an HTTP error but got: %v", err)
|
||||
}
|
||||
|
||||
if !tc.expectHttpError && isHttpError {
|
||||
t.Errorf("expected no HTTP error but got one: %v", httpErr)
|
||||
}
|
||||
|
||||
if err != nil && tc.expectHttpError && isHttpError {
|
||||
status, _ := httpErr.HttpError()
|
||||
if status != tc.expectHttpStatus {
|
||||
t.Errorf("expected %d as HTTP status code but got %d", tc.expectHttpStatus, status)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,7 +617,7 @@ func TestContext_Request(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
c := echo.New().NewContext(request, recorder)
|
||||
|
||||
ctx := Context{
|
||||
ctx := &Context{
|
||||
echoCtx: c,
|
||||
}
|
||||
|
||||
@@ -168,7 +627,7 @@ func TestContext_Request(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestContext_FormData(t *testing.T) {
|
||||
ctx := Context{
|
||||
ctx := &Context{
|
||||
values: map[string][]string{
|
||||
"foo": {"foo"},
|
||||
},
|
||||
@@ -189,52 +648,95 @@ func TestContext_FormData(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestContext_GeneratePath(t *testing.T) {
|
||||
ctx := Context{
|
||||
ctx := &Context{
|
||||
dirPath: "/foo",
|
||||
}
|
||||
|
||||
path := ctx.GeneratePath(".pdf")
|
||||
|
||||
if !strings.HasPrefix(path, ctx.dirPath) {
|
||||
t.Errorf("expected '%s' to start with '%s'", path, ctx.dirPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContext_Rename(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
ctx *Context
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
scenario: "failure",
|
||||
ctx: &Context{pathRename: &gotenberg.PathRenameMock{RenameMock: func(oldpath, newpath string) error {
|
||||
return errors.New("cannot rename")
|
||||
}}},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "success",
|
||||
ctx: &Context{pathRename: &gotenberg.PathRenameMock{RenameMock: func(oldpath, newpath string) error {
|
||||
return nil
|
||||
}}},
|
||||
expectError: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
err := tc.ctx.Rename("", "")
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none", err)
|
||||
}
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestContext_AddOutputPaths(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
ctx *Context
|
||||
path string
|
||||
expectCount int
|
||||
expectErr bool
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
ctx: &Context{cancelled: true},
|
||||
expectErr: true,
|
||||
scenario: "ErrContextAlreadyClosed",
|
||||
ctx: &Context{cancelled: true},
|
||||
expectCount: 0,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
ctx: &Context{dirPath: "/foo"},
|
||||
path: "/bar/foo.txt",
|
||||
expectErr: true,
|
||||
scenario: "ErrOutOfBoundsOutputPath",
|
||||
ctx: &Context{dirPath: "/foo"},
|
||||
path: "/bar/foo.txt",
|
||||
expectCount: 0,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "success",
|
||||
ctx: &Context{dirPath: "/foo"},
|
||||
path: "/foo/foo.txt",
|
||||
expectCount: 1,
|
||||
expectError: false,
|
||||
},
|
||||
} {
|
||||
err := tc.ctx.AddOutputPaths(tc.path)
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
err := tc.ctx.AddOutputPaths(tc.path)
|
||||
|
||||
if tc.expectErr && err == nil {
|
||||
t.Errorf("test %d: expected error but got: %v", i, err)
|
||||
}
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none", err)
|
||||
}
|
||||
|
||||
if !tc.expectErr && err != nil {
|
||||
t.Errorf("test %d: expected no error but got: %v", i, err)
|
||||
}
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if len(tc.ctx.outputPaths) != tc.expectCount {
|
||||
t.Errorf("test %d: expected %d output paths but got %d", i, tc.expectCount, len(tc.ctx.outputPaths))
|
||||
}
|
||||
if len(tc.ctx.outputPaths) != tc.expectCount {
|
||||
t.Errorf("expected %d output paths but got %d", tc.expectCount, len(tc.ctx.outputPaths))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,76 +751,91 @@ func TestContext_Log(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestContext_BuildOutputFile(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
ctx *Context
|
||||
expectErr bool
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
ctx *Context
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
ctx: &Context{cancelled: true},
|
||||
expectErr: true,
|
||||
scenario: "ErrContextAlreadyClosed",
|
||||
ctx: &Context{cancelled: true},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
ctx: &Context{},
|
||||
expectErr: true,
|
||||
scenario: "no output path",
|
||||
ctx: &Context{},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
ctx: &Context{outputPaths: []string{"foo.txt"}},
|
||||
scenario: "success: one output path",
|
||||
ctx: &Context{outputPaths: []string{"foo.txt"}},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
ctx: &Context{outputPaths: []string{"foo.txt", "foo.pdf"}},
|
||||
expectErr: true,
|
||||
scenario: "cannot archive: invalid output paths",
|
||||
ctx: &Context{outputPaths: []string{"foo.txt", "foo.pdf"}},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "success: many output paths",
|
||||
ctx: &Context{
|
||||
outputPaths: []string{
|
||||
"/tests/test/testdata/api/sample1.txt",
|
||||
"/tests/test/testdata/api/sample1.txt",
|
||||
},
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
} {
|
||||
dirPath, err := gotenberg.MkdirAll()
|
||||
if err != nil {
|
||||
t.Fatalf("%d: expected no erro but got: %v", i, err)
|
||||
}
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
fs := gotenberg.NewFileSystem()
|
||||
dirPath, err := fs.MkdirAll()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no erro but got: %v", err)
|
||||
}
|
||||
|
||||
tc.ctx.dirPath = dirPath
|
||||
tc.ctx.logger = zap.NewNop()
|
||||
defer func() {
|
||||
err := os.RemoveAll(fs.WorkingDirPath())
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error while cleaning up but got: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
_, err = tc.ctx.BuildOutputFile()
|
||||
tc.ctx.dirPath = dirPath
|
||||
tc.ctx.logger = zap.NewNop()
|
||||
|
||||
if tc.expectErr && err == nil {
|
||||
t.Errorf("test %d: expected error but got: %v", i, err)
|
||||
}
|
||||
_, err = tc.ctx.BuildOutputFile()
|
||||
|
||||
if !tc.expectErr && err != nil {
|
||||
t.Errorf("test %d: expected no error but got: %v", i, err)
|
||||
}
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none", err)
|
||||
}
|
||||
|
||||
err = os.RemoveAll(dirPath)
|
||||
if err != nil {
|
||||
t.Fatalf("%d: expected no erro but got: %v", i, err)
|
||||
}
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestContext_OutputFilename(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
ctx *Context
|
||||
outputPath string
|
||||
expectOutputFilename string
|
||||
}{
|
||||
{
|
||||
scenario: "with Gotenberg-Output-Filename header",
|
||||
ctx: func() *Context {
|
||||
c := echo.New().NewContext(httptest.NewRequest(http.MethodGet, "/foo", nil), nil)
|
||||
c.Request().Header.Set("Gotenberg-Output-Filename", "foo")
|
||||
|
||||
return &Context{echoCtx: c}
|
||||
}(),
|
||||
outputPath: "/foo/bar.txt",
|
||||
expectOutputFilename: "foo.txt",
|
||||
},
|
||||
{
|
||||
scenario: "without custom filename",
|
||||
ctx: func() *Context {
|
||||
c := echo.New().NewContext(httptest.NewRequest(http.MethodGet, "/foo", nil), nil)
|
||||
return &Context{echoCtx: c}
|
||||
@@ -327,10 +844,12 @@ func TestContext_OutputFilename(t *testing.T) {
|
||||
expectOutputFilename: "foo.txt",
|
||||
},
|
||||
} {
|
||||
actual := tc.ctx.OutputFilename(tc.outputPath)
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
actual := tc.ctx.OutputFilename(tc.outputPath)
|
||||
|
||||
if actual != tc.expectOutputFilename {
|
||||
t.Errorf("test %d: expected '%s' but got '%s'", i, tc.expectOutputFilename, actual)
|
||||
}
|
||||
if actual != tc.expectOutputFilename {
|
||||
t.Errorf("expected '%s' but got '%s'", tc.expectOutputFilename, actual)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,66 +2,66 @@ package api
|
||||
|
||||
// Credits: https://www.joeshaw.org/error-handling-in-go-http-applications.
|
||||
|
||||
// HTTPError is an interface allowing to retrieve the HTTP details of an error.
|
||||
type HTTPError interface {
|
||||
HTTPError() (int, string)
|
||||
// HttpError is an interface allowing to retrieve the HTTP details of an error.
|
||||
type HttpError interface {
|
||||
HttpError() (int, string)
|
||||
}
|
||||
|
||||
// SentinelHTTPError is the HTTP sidekick of an error.
|
||||
type SentinelHTTPError struct {
|
||||
// SentinelHttpError is the HTTP sidekick of an error.
|
||||
type SentinelHttpError struct {
|
||||
status int
|
||||
message string
|
||||
}
|
||||
|
||||
// NewSentinelHTTPError creates a SentinelHTTPError. The message will be sent
|
||||
// NewSentinelHttpError creates a [SentinelHttpError]. The message will be sent
|
||||
// as the response's body if returned from a handler, so make sure to not leak
|
||||
// sensible information.
|
||||
func NewSentinelHTTPError(status int, message string) SentinelHTTPError {
|
||||
return SentinelHTTPError{
|
||||
func NewSentinelHttpError(status int, message string) SentinelHttpError {
|
||||
return SentinelHttpError{
|
||||
status: status,
|
||||
message: message,
|
||||
}
|
||||
}
|
||||
|
||||
// Error returns the message.
|
||||
func (err SentinelHTTPError) Error() string {
|
||||
func (err SentinelHttpError) Error() string {
|
||||
return err.message
|
||||
}
|
||||
|
||||
// HTTPError returns the status and message.
|
||||
func (err SentinelHTTPError) HTTPError() (int, string) {
|
||||
// HttpError returns the status and message.
|
||||
func (err SentinelHttpError) HttpError() (int, string) {
|
||||
return err.status, err.message
|
||||
}
|
||||
|
||||
// sentinelWrappedError contains both the error which will logged and the
|
||||
// sidekick SentinelHTTPError.
|
||||
// sidekick [SentinelHttpError].
|
||||
type sentinelWrappedError struct {
|
||||
error
|
||||
sentinel SentinelHTTPError
|
||||
sentinel SentinelHttpError
|
||||
}
|
||||
|
||||
func (w sentinelWrappedError) Is(err error) bool {
|
||||
return w.sentinel == err
|
||||
}
|
||||
|
||||
func (w sentinelWrappedError) HTTPError() (int, string) {
|
||||
return w.sentinel.HTTPError()
|
||||
func (w sentinelWrappedError) HttpError() (int, string) {
|
||||
return w.sentinel.HttpError()
|
||||
}
|
||||
|
||||
// WrapError wraps the given error with a SentinelHTTPError. The wrapped error
|
||||
// will be displayed in a log, while the SentinelHTTPError will be sent in the
|
||||
// response.
|
||||
// WrapError wraps the given error with a [SentinelHttpError]. The wrapped
|
||||
// error will be displayed in a log, while the [SentinelHttpError] will be sent
|
||||
// in the response.
|
||||
//
|
||||
// return api.WrapError(
|
||||
// // This first error will be logged.
|
||||
// fmt.Errorf("my action: %w", err),
|
||||
// // The HTTP error will be sent as a response.
|
||||
// api.NewSentinelHTTPError(
|
||||
// api.NewSentinelHttpError(
|
||||
// http.StatusForbidden,
|
||||
// "Hey, you did something wrong!"
|
||||
// ),
|
||||
// )
|
||||
func WrapError(err error, sentinel SentinelHTTPError) error {
|
||||
func WrapError(err error, sentinel SentinelHttpError) error {
|
||||
return sentinelWrappedError{
|
||||
error: err,
|
||||
sentinel: sentinel,
|
||||
@@ -70,8 +70,8 @@ func WrapError(err error, sentinel SentinelHTTPError) error {
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ error = (*SentinelHTTPError)(nil)
|
||||
_ HTTPError = (*SentinelHTTPError)(nil)
|
||||
_ error = (*SentinelHttpError)(nil)
|
||||
_ HttpError = (*SentinelHttpError)(nil)
|
||||
_ error = (*sentinelWrappedError)(nil)
|
||||
_ HTTPError = (*sentinelWrappedError)(nil)
|
||||
_ HttpError = (*sentinelWrappedError)(nil)
|
||||
)
|
||||
|
||||
@@ -7,9 +7,9 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewSentinelHTTPError(t *testing.T) {
|
||||
actual := NewSentinelHTTPError(http.StatusInternalServerError, "foo")
|
||||
expect := SentinelHTTPError{
|
||||
func TestNewSentinelHttpError(t *testing.T) {
|
||||
actual := NewSentinelHttpError(http.StatusInternalServerError, "foo")
|
||||
expect := SentinelHttpError{
|
||||
status: http.StatusInternalServerError,
|
||||
message: "foo",
|
||||
}
|
||||
@@ -19,8 +19,8 @@ func TestNewSentinelHTTPError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSentinelHTTPError_Error(t *testing.T) {
|
||||
err := SentinelHTTPError{
|
||||
func TestSentinelHttpError_Error(t *testing.T) {
|
||||
err := SentinelHttpError{
|
||||
message: "foo",
|
||||
}
|
||||
|
||||
@@ -32,11 +32,11 @@ func TestSentinelHTTPError_Error(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSentinelHTTPError_HTTPError(t *testing.T) {
|
||||
actualStatus, actualMessage := SentinelHTTPError{
|
||||
func TestSentinelHttpError_HttpError(t *testing.T) {
|
||||
actualStatus, actualMessage := SentinelHttpError{
|
||||
status: http.StatusInternalServerError,
|
||||
message: "foo",
|
||||
}.HTTPError()
|
||||
}.HttpError()
|
||||
|
||||
expectStatus := http.StatusInternalServerError
|
||||
expectMessage := "foo"
|
||||
@@ -51,7 +51,7 @@ func TestSentinelHTTPError_HTTPError(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSentinelWrappedError_Is(t *testing.T) {
|
||||
errSentinel := SentinelHTTPError{}
|
||||
errSentinel := SentinelHttpError{}
|
||||
|
||||
err := sentinelWrappedError{
|
||||
error: errors.New("foo"),
|
||||
@@ -63,19 +63,19 @@ func TestSentinelWrappedError_Is(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSentinelWrappedError_HTTPError(t *testing.T) {
|
||||
expectStatus, expectMessage := SentinelHTTPError{
|
||||
func TestSentinelWrappedError_HttpError(t *testing.T) {
|
||||
expectStatus, expectMessage := SentinelHttpError{
|
||||
status: http.StatusInternalServerError,
|
||||
message: "foo",
|
||||
}.HTTPError()
|
||||
}.HttpError()
|
||||
|
||||
actualStatus, actualMessage := sentinelWrappedError{
|
||||
error: errors.New("foo"),
|
||||
sentinel: SentinelHTTPError{
|
||||
sentinel: SentinelHttpError{
|
||||
status: http.StatusInternalServerError,
|
||||
message: "foo",
|
||||
},
|
||||
}.HTTPError()
|
||||
}.HttpError()
|
||||
|
||||
if actualStatus != expectStatus {
|
||||
t.Errorf("expected %d but got %d", expectStatus, actualStatus)
|
||||
@@ -91,13 +91,13 @@ func TestWrapError(t *testing.T) {
|
||||
|
||||
expect := sentinelWrappedError{
|
||||
error: errFoo,
|
||||
sentinel: SentinelHTTPError{
|
||||
sentinel: SentinelHttpError{
|
||||
status: http.StatusInternalServerError,
|
||||
message: "foo",
|
||||
},
|
||||
}
|
||||
|
||||
actual := WrapError(errFoo, SentinelHTTPError{
|
||||
actual := WrapError(errFoo, SentinelHttpError{
|
||||
status: http.StatusInternalServerError,
|
||||
message: "foo",
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -11,6 +12,8 @@ import (
|
||||
"time"
|
||||
|
||||
"go.uber.org/multierr"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
|
||||
// FormData is a helper for validating and hydrating values from a
|
||||
@@ -23,8 +26,8 @@ type FormData struct {
|
||||
errors error
|
||||
}
|
||||
|
||||
// Validate returns nil or an error related to the FormData values, with a
|
||||
// SentinelHTTPError (status code 400, errors' details as message) wrapped
|
||||
// Validate returns nil or an error related to the [FormData] values, with a
|
||||
// [SentinelHttpError] (status code 400, errors' details as message) wrapped
|
||||
// inside.
|
||||
//
|
||||
// var foo string
|
||||
@@ -32,18 +35,18 @@ type FormData struct {
|
||||
// err := ctx.FormData().
|
||||
// MandatoryString("foo", &foo, "bar").
|
||||
// Validate()
|
||||
func (form FormData) Validate() error {
|
||||
func (form *FormData) Validate() error {
|
||||
if form.errors == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return WrapError(
|
||||
form.errors,
|
||||
NewSentinelHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid form data: %s", form.errors)),
|
||||
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Invalid form data: %s", form.errors)),
|
||||
)
|
||||
}
|
||||
|
||||
// String binds a form data value to a string variable.
|
||||
// String binds a form field to a string variable.
|
||||
//
|
||||
// var foo string
|
||||
//
|
||||
@@ -52,17 +55,17 @@ func (form *FormData) String(key string, target *string, defaultValue string) *F
|
||||
return form.mustValue(key, target, defaultValue)
|
||||
}
|
||||
|
||||
// MandatoryString binds a form data value to a string variable. It populates
|
||||
// MandatoryString binds a form field to a string variable. It populates
|
||||
// an error if the value is empty or the "key" does not exist.
|
||||
//
|
||||
// var foo string
|
||||
//
|
||||
// ctx.FormData().MandatoryString("foo", &foo)
|
||||
func (form *FormData) MandatoryString(key string, target *string) *FormData {
|
||||
return form.mustMandatoryValue(key, target)
|
||||
return form.mustMandatoryField(key, target)
|
||||
}
|
||||
|
||||
// Bool binds a form data value to a bool variable. It populates an error if
|
||||
// Bool binds a form field to a bool variable. It populates an error if
|
||||
// the value is not bool.
|
||||
//
|
||||
// var foo bool
|
||||
@@ -72,17 +75,17 @@ func (form *FormData) Bool(key string, target *bool, defaultValue bool) *FormDat
|
||||
return form.mustValue(key, target, defaultValue)
|
||||
}
|
||||
|
||||
// MandatoryBool binds a form data value to a bool variable. It populates an
|
||||
// MandatoryBool binds a form field to a bool variable. It populates an
|
||||
// error if the value is not bool, is empty, or the "key" does not exist.
|
||||
//
|
||||
// var foo bool
|
||||
//
|
||||
// ctx.FormData().MandatoryBool("foo", &foo)
|
||||
func (form *FormData) MandatoryBool(key string, target *bool) *FormData {
|
||||
return form.mustMandatoryValue(key, target)
|
||||
return form.mustMandatoryField(key, target)
|
||||
}
|
||||
|
||||
// Int binds a form data value to an int variable. It populates an error if the
|
||||
// Int binds a form field to an int variable. It populates an error if the
|
||||
// value is not int.
|
||||
//
|
||||
// var foo int
|
||||
@@ -92,17 +95,17 @@ func (form *FormData) Int(key string, target *int, defaultValue int) *FormData {
|
||||
return form.mustValue(key, target, defaultValue)
|
||||
}
|
||||
|
||||
// MandatoryInt binds a form data value to an int variable. It populates an
|
||||
// MandatoryInt binds a form field to an int variable. It populates an
|
||||
// error if the value is not int, is empty, or the "key" does not exist.
|
||||
//
|
||||
// var foo int
|
||||
//
|
||||
// ctx.FormData().MandatoryInt("foo", &foo)
|
||||
func (form *FormData) MandatoryInt(key string, target *int) *FormData {
|
||||
return form.mustMandatoryValue(key, target)
|
||||
return form.mustMandatoryField(key, target)
|
||||
}
|
||||
|
||||
// Float64 binds a form data value to a float64 variable. It populates an error
|
||||
// Float64 binds a form field to a float64 variable. It populates an error
|
||||
// if the value is not float64.
|
||||
//
|
||||
// var foo float64
|
||||
@@ -112,18 +115,18 @@ func (form *FormData) Float64(key string, target *float64, defaultValue float64)
|
||||
return form.mustValue(key, target, defaultValue)
|
||||
}
|
||||
|
||||
// MandatoryFloat64 binds a form data value to a float64 variable. It populates
|
||||
// MandatoryFloat64 binds a form field to a float64 variable. It populates
|
||||
// an error if the is not float64, is empty, or the "key" does not exist.
|
||||
//
|
||||
// var foo float64
|
||||
//
|
||||
// ctx.FormData().MandatoryFloat64("foo", &foo)
|
||||
func (form *FormData) MandatoryFloat64(key string, target *float64) *FormData {
|
||||
return form.mustMandatoryValue(key, target)
|
||||
return form.mustMandatoryField(key, target)
|
||||
}
|
||||
|
||||
// Duration binds a form data value to a time.Duration variable. It populates
|
||||
// an error if the form data value is not time.Duration.
|
||||
// Duration binds a form field to a time.Duration variable. It populates
|
||||
// an error if the form field is not time.Duration.
|
||||
//
|
||||
// var foo time.Duration
|
||||
//
|
||||
@@ -132,7 +135,7 @@ func (form *FormData) Duration(key string, target *time.Duration, defaultValue t
|
||||
return form.mustValue(key, target, defaultValue)
|
||||
}
|
||||
|
||||
// MandatoryDuration binds a form data value to a time.Duration variable. It
|
||||
// MandatoryDuration binds a form field to a time.Duration variable. It
|
||||
// populates an error if the value is not time.Duration, is empty, or the "key"
|
||||
// does not exist.
|
||||
//
|
||||
@@ -140,10 +143,95 @@ func (form *FormData) Duration(key string, target *time.Duration, defaultValue t
|
||||
//
|
||||
// ctx.FormData().MandatoryDuration("foo", &foo)
|
||||
func (form *FormData) MandatoryDuration(key string, target *time.Duration) *FormData {
|
||||
return form.mustMandatoryValue(key, target)
|
||||
return form.mustMandatoryField(key, target)
|
||||
}
|
||||
|
||||
// Custom helps to define a custom binding function for a form data value.
|
||||
// Inches binds a form field to a float64 variable. It populates an error
|
||||
// if the value cannot be computed back to inches.
|
||||
//
|
||||
// var foo float64
|
||||
//
|
||||
// ctx.FormData().Inches("foo", &foo, 2.0)
|
||||
func (form *FormData) Inches(key string, target *float64, defaultValue float64) *FormData {
|
||||
form.inches(key, target)
|
||||
if *target == -math.MaxFloat64 {
|
||||
*target = defaultValue
|
||||
}
|
||||
return form
|
||||
}
|
||||
|
||||
// MandatoryInches binds a form field to a float64 variable. It populates
|
||||
// an error if the value cannot be computed back to inches, is empty, or the
|
||||
// "key" does not exist.
|
||||
//
|
||||
// var foo float64
|
||||
//
|
||||
// ctx.FormData().MandatoryInches("foo", &foo)
|
||||
func (form *FormData) MandatoryInches(key string, target *float64) *FormData {
|
||||
val, ok := form.values[key]
|
||||
if !ok || val[0] == "" {
|
||||
form.append(
|
||||
fmt.Errorf("form field '%s' is required", key),
|
||||
)
|
||||
return form
|
||||
}
|
||||
return form.inches(key, target)
|
||||
}
|
||||
|
||||
// inches tries to compute a string value to inches.
|
||||
func (form *FormData) inches(key string, target *float64) *FormData {
|
||||
var value string
|
||||
form.mustValue(key, &value, "")
|
||||
|
||||
if value == "" {
|
||||
*target = -math.MaxFloat64
|
||||
return form
|
||||
}
|
||||
|
||||
for _, unit := range []string{"pt", "px", "in", "mm", "cm", "pc"} {
|
||||
if !strings.HasSuffix(value, unit) {
|
||||
continue
|
||||
}
|
||||
|
||||
val, err := strconv.ParseFloat(strings.TrimSuffix(value, unit), 64)
|
||||
if err != nil {
|
||||
form.append(
|
||||
fmt.Errorf("form field '%s' is invalid (got '%s', resulting to %w)", key, value, err),
|
||||
)
|
||||
return form
|
||||
}
|
||||
|
||||
switch unit {
|
||||
case "pt":
|
||||
*target = val * (1.0 / 72.0)
|
||||
case "px":
|
||||
*target = val * (1.0 / 96.0)
|
||||
case "in":
|
||||
*target = val
|
||||
case "mm":
|
||||
*target = val * (1.0 / 25.4)
|
||||
case "cm":
|
||||
*target = val * (1.0 / 2.54)
|
||||
case "pc":
|
||||
*target = val * (1.0 / 6.0)
|
||||
}
|
||||
|
||||
return form
|
||||
}
|
||||
|
||||
val, err := strconv.ParseFloat(value, 64)
|
||||
if err != nil {
|
||||
form.append(
|
||||
fmt.Errorf("form field '%s' is invalid (got '%s', resulting to %w)", key, value, err),
|
||||
)
|
||||
return form
|
||||
}
|
||||
|
||||
*target = val
|
||||
return form
|
||||
}
|
||||
|
||||
// Custom helps to define a custom binding function for a form field.
|
||||
//
|
||||
// var foo map[string]string
|
||||
//
|
||||
@@ -168,16 +256,15 @@ func (form *FormData) Custom(key string, assign func(value string) error) *FormD
|
||||
err := assign(value)
|
||||
if err != nil {
|
||||
form.append(
|
||||
fmt.Errorf("form value '%s' is invalid (got '%s', resulting to %w)", key, value, err),
|
||||
fmt.Errorf("form field '%s' is invalid (got '%s', resulting to %w)", key, value, err),
|
||||
)
|
||||
}
|
||||
|
||||
return form
|
||||
}
|
||||
|
||||
// MandatoryCustom helps to define a custom binding function for a form data
|
||||
// value. It populates an error if the value is empty or the "key" does not
|
||||
// exist.
|
||||
// MandatoryCustom helps to define a custom binding function for a form field.
|
||||
// It populates an error if the value is empty or the "key" does not exist.
|
||||
//
|
||||
// var foo map[string]string
|
||||
//
|
||||
@@ -191,7 +278,7 @@ func (form *FormData) Custom(key string, assign func(value string) error) *FormD
|
||||
// })
|
||||
func (form *FormData) MandatoryCustom(key string, assign func(value string) error) *FormData {
|
||||
var value string
|
||||
form.mustMandatoryValue(key, &value)
|
||||
form.mustMandatoryField(key, &value)
|
||||
|
||||
if value == "" {
|
||||
return form
|
||||
@@ -200,7 +287,7 @@ func (form *FormData) MandatoryCustom(key string, assign func(value string) erro
|
||||
err := assign(value)
|
||||
if err != nil {
|
||||
form.append(
|
||||
fmt.Errorf("form value '%s' is invalid (got '%s', resulting to %w)", key, value, err),
|
||||
fmt.Errorf("form field '%s' is invalid (got '%s', resulting to %w)", key, value, err),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -305,7 +392,7 @@ func (form *FormData) paths(extensions []string, target *[]string) *FormData {
|
||||
}
|
||||
|
||||
// See https://github.com/gotenberg/gotenberg/issues/139.
|
||||
sort.Strings(*target)
|
||||
sort.Sort(gotenberg.AlphanumericSort(*target))
|
||||
|
||||
return form
|
||||
}
|
||||
@@ -315,7 +402,7 @@ func (form *FormData) append(err error) {
|
||||
form.errors = multierr.Append(form.errors, err)
|
||||
}
|
||||
|
||||
// mustValue binds the target interface with a form data value. If the value is
|
||||
// mustValue binds the target interface with a form field. If the value is
|
||||
// empty or the "key" does not exist, it binds the default value. Currently,
|
||||
// only the string, bool, int, float64 and time.Duration types are bindable.
|
||||
func (form *FormData) mustValue(key string, target interface{}, defaultValue interface{}) *FormData {
|
||||
@@ -343,16 +430,16 @@ func (form *FormData) mustValue(key string, target interface{}, defaultValue int
|
||||
return form.mustAssign(key, val[0], target)
|
||||
}
|
||||
|
||||
// mustMandatoryValue binds the target interface with a form data value. It
|
||||
// mustMandatoryField binds the target interface with a form field. It
|
||||
// populates an error if the value is empty or the "key" does not exist.
|
||||
// Currently, only the string, bool, int, float64 and time.Duration types are
|
||||
// bindable.
|
||||
func (form *FormData) mustMandatoryValue(key string, target interface{}) *FormData {
|
||||
func (form *FormData) mustMandatoryField(key string, target interface{}) *FormData {
|
||||
val, ok := form.values[key]
|
||||
|
||||
if !ok || val[0] == "" {
|
||||
form.append(
|
||||
fmt.Errorf("form value '%s' is required", key),
|
||||
fmt.Errorf("form field '%s' is required", key),
|
||||
)
|
||||
|
||||
return form
|
||||
@@ -386,7 +473,7 @@ func (form *FormData) mustAssign(key, value string, target interface{}) *FormDat
|
||||
|
||||
if err != nil {
|
||||
form.append(
|
||||
fmt.Errorf("form value '%s' is invalid (got '%s', resulting to %w)", key, value, err),
|
||||
fmt.Errorf("form field '%s' is invalid (got '%s', resulting to %w)", key, value, err),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -10,17 +11,27 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v4/middleware"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
|
||||
// ErrAsyncProcess happens when a handler or middleware handles a request in an
|
||||
// asynchronous fashion.
|
||||
var ErrAsyncProcess = errors.New("async process")
|
||||
var (
|
||||
// ErrAsyncProcess happens when a handler or middleware handles a request
|
||||
// in an asynchronous fashion.
|
||||
ErrAsyncProcess = errors.New("async process")
|
||||
|
||||
// ErrNoOutputFile happens when a handler or middleware handles a request
|
||||
// without sending any output file.
|
||||
ErrNoOutputFile = errors.New("no output file")
|
||||
)
|
||||
|
||||
// ParseError parses an error and returns the corresponding HTTP status and
|
||||
// HTTP message.
|
||||
func ParseError(err error) (int, string) {
|
||||
echoErr, ok := err.(*echo.HTTPError)
|
||||
var echoErr *echo.HTTPError
|
||||
ok := errors.As(err, &echoErr)
|
||||
if ok {
|
||||
return echoErr.Code, http.StatusText(echoErr.Code)
|
||||
}
|
||||
@@ -29,9 +40,25 @@ func ParseError(err error) (int, string) {
|
||||
return http.StatusServiceUnavailable, http.StatusText(http.StatusServiceUnavailable)
|
||||
}
|
||||
|
||||
var httpErr HTTPError
|
||||
if errors.Is(err, gotenberg.ErrFiltered) {
|
||||
return http.StatusForbidden, http.StatusText(http.StatusForbidden)
|
||||
}
|
||||
|
||||
if errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded) {
|
||||
return http.StatusTooManyRequests, http.StatusText(http.StatusTooManyRequests)
|
||||
}
|
||||
|
||||
if errors.Is(err, gotenberg.ErrPdfFormatNotSupported) {
|
||||
return http.StatusBadRequest, "At least one PDF engine cannot process the requested PDF format, while others may have failed to convert due to different issues"
|
||||
}
|
||||
|
||||
if errors.Is(err, gotenberg.ErrPdfEngineMetadataValueNotSupported) {
|
||||
return http.StatusBadRequest, "At least one PDF engine cannot process the requested metadata, while others may have failed to convert due to different issues"
|
||||
}
|
||||
|
||||
var httpErr HttpError
|
||||
if errors.As(err, &httpErr) {
|
||||
return httpErr.HTTPError()
|
||||
return httpErr.HttpError()
|
||||
}
|
||||
|
||||
// Default 500 status code.
|
||||
@@ -54,8 +81,8 @@ func httpErrorHandler() echo.HTTPErrorHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// latencyMiddleware sets the start time in the echo.Context under "startTime".
|
||||
// Its value will be used later to calculate a request latency.
|
||||
// latencyMiddleware sets the start time in the [echo.Context] under
|
||||
// "startTime". Its value will be used later to calculate a request latency.
|
||||
//
|
||||
// startTime := c.Get("startTime").(time.Time)
|
||||
func latencyMiddleware() echo.MiddlewareFunc {
|
||||
@@ -71,9 +98,9 @@ func latencyMiddleware() echo.MiddlewareFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// rootPathMiddleware sets the root path in the echo.Context under "rootPath".
|
||||
// Its value may be used to skip a middleware execution based on a request
|
||||
// URI.
|
||||
// rootPathMiddleware sets the root path in the [echo.Context] under
|
||||
// "rootPath". Its value may be used to skip a middleware execution based on a
|
||||
// request URI.
|
||||
//
|
||||
// rootPath := c.Get("rootPath").(string)
|
||||
// healthURI := fmt.Sprintf("%s/health", rootPath)
|
||||
@@ -87,14 +114,13 @@ func rootPathMiddleware(rootPath string) echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
c.Set("rootPath", rootPath)
|
||||
|
||||
// Call the next middleware in the chain.
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// traceMiddleware sets the request identifier in the echo.Context under
|
||||
// traceMiddleware sets the request identifier in the [echo.Context] under
|
||||
// "trace". Its value is either retrieved from the trace header or generated if
|
||||
// the header is not present / its value is empty.
|
||||
//
|
||||
@@ -120,8 +146,8 @@ func traceMiddleware(header string) echo.MiddlewareFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// loggerMiddleware sets the logger in the echo.Context under "logger" and logs
|
||||
// a synchronous request result.
|
||||
// loggerMiddleware sets the logger in the [echo.Context] under "logger" and
|
||||
// logs a synchronous request result.
|
||||
//
|
||||
// logger := c.Get("logger").(*zap.Logger)
|
||||
func loggerMiddleware(logger *zap.Logger, disableLoggingForPaths []string) echo.MiddlewareFunc {
|
||||
@@ -192,21 +218,34 @@ func loggerMiddleware(logger *zap.Logger, disableLoggingForPaths []string) echo.
|
||||
}
|
||||
}
|
||||
|
||||
// basicAuthMiddleware manages basic authentication.
|
||||
func basicAuthMiddleware(username, password string) echo.MiddlewareFunc {
|
||||
return middleware.BasicAuth(func(u string, p string, e echo.Context) (bool, error) {
|
||||
if subtle.ConstantTimeCompare([]byte(u), []byte(username)) == 1 &&
|
||||
subtle.ConstantTimeCompare([]byte(p), []byte(password)) == 1 {
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
})
|
||||
}
|
||||
|
||||
// contextMiddleware, a middleware for "multipart/form-data" requests, sets the
|
||||
// Context and related context.CancelFunc in the echo.Context under "context"
|
||||
// and "cancel". If the process is synchronous, it also handles the result of a
|
||||
// "multipart/form-data" request.
|
||||
// [Context] and related context.CancelFunc in the [echo.Context] under
|
||||
// "context" and "cancel". If the process is synchronous, it also handles the
|
||||
// result of a "multipart/form-data" request.
|
||||
//
|
||||
// ctx := c.Get("context").(*api.Context)
|
||||
// cancel := c.Get("cancel").(context.CancelFunc)
|
||||
func contextMiddleware(timeout time.Duration) echo.MiddlewareFunc {
|
||||
func contextMiddleware(fs *gotenberg.FileSystem, timeout time.Duration, bodyLimit int64, downloadFromCfg downloadFromConfig) echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
logger := c.Get("logger").(*zap.Logger)
|
||||
traceHeader := c.Get("traceHeader").(string)
|
||||
trace := c.Get("trace").(string)
|
||||
|
||||
// We create a context with a timeout so that underlying processes are
|
||||
// able to stop early and handle correctly a timeout scenario.
|
||||
ctx, cancel, err := newContext(c, logger, timeout)
|
||||
ctx, cancel, err := newContext(c, logger, fs, timeout, bodyLimit, downloadFromCfg, traceHeader, trace)
|
||||
if err != nil {
|
||||
cancel()
|
||||
|
||||
@@ -227,6 +266,13 @@ func contextMiddleware(timeout time.Duration) echo.MiddlewareFunc {
|
||||
|
||||
defer cancel()
|
||||
|
||||
if errors.Is(err, ErrNoOutputFile) {
|
||||
// A middleware/handler tells us that it's handling the process
|
||||
// in an asynchronous fashion. Therefore, we must not cancel
|
||||
// the context nor send an output file.
|
||||
return nil
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
|
||||
func TestParseError(t *testing.T) {
|
||||
@@ -27,14 +29,29 @@ func TestParseError(t *testing.T) {
|
||||
expectMessage: http.StatusText(http.StatusInternalServerError),
|
||||
},
|
||||
{
|
||||
err: context.DeadlineExceeded,
|
||||
expectStatus: http.StatusServiceUnavailable,
|
||||
expectMessage: http.StatusText(http.StatusServiceUnavailable),
|
||||
err: gotenberg.ErrFiltered,
|
||||
expectStatus: http.StatusForbidden,
|
||||
expectMessage: http.StatusText(http.StatusForbidden),
|
||||
},
|
||||
{
|
||||
err: gotenberg.ErrMaximumQueueSizeExceeded,
|
||||
expectStatus: http.StatusTooManyRequests,
|
||||
expectMessage: http.StatusText(http.StatusTooManyRequests),
|
||||
},
|
||||
{
|
||||
err: gotenberg.ErrPdfFormatNotSupported,
|
||||
expectStatus: http.StatusBadRequest,
|
||||
expectMessage: "At least one PDF engine cannot process the requested PDF format, while others may have failed to convert due to different issues",
|
||||
},
|
||||
{
|
||||
err: gotenberg.ErrPdfEngineMetadataValueNotSupported,
|
||||
expectStatus: http.StatusBadRequest,
|
||||
expectMessage: "At least one PDF engine cannot process the requested metadata, while others may have failed to convert due to different issues",
|
||||
},
|
||||
{
|
||||
err: WrapError(
|
||||
errors.New("foo"),
|
||||
NewSentinelHTTPError(http.StatusBadRequest, "foo"),
|
||||
NewSentinelHttpError(http.StatusBadRequest, "foo"),
|
||||
),
|
||||
expectStatus: http.StatusBadRequest,
|
||||
expectMessage: "foo",
|
||||
@@ -71,7 +88,7 @@ func TestHttpErrorHandler(t *testing.T) {
|
||||
{
|
||||
err: WrapError(
|
||||
errors.New("foo"),
|
||||
NewSentinelHTTPError(http.StatusBadRequest, "foo"),
|
||||
NewSentinelHttpError(http.StatusBadRequest, "foo"),
|
||||
),
|
||||
expectStatus: http.StatusBadRequest,
|
||||
expectMessage: "foo",
|
||||
@@ -122,7 +139,6 @@ func TestLatencyMiddleware(t *testing.T) {
|
||||
return nil
|
||||
},
|
||||
)(c)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
@@ -150,7 +166,6 @@ func TestRootPathMiddleware(t *testing.T) {
|
||||
return nil
|
||||
},
|
||||
)(c)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
@@ -191,7 +206,6 @@ func TestTraceMiddleware(t *testing.T) {
|
||||
return nil
|
||||
},
|
||||
)(c)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("test %d: expected no error but got: %v", i, err)
|
||||
}
|
||||
@@ -222,6 +236,56 @@ func TestTraceMiddleware(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBasicAuthMiddleware(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
request *http.Request
|
||||
username string
|
||||
password string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
scenario: "invalid basic auth",
|
||||
request: func() *http.Request {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.SetBasicAuth("invalid", "invalid")
|
||||
return req
|
||||
}(),
|
||||
username: "foo",
|
||||
password: "bar",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "valid basic auth",
|
||||
request: func() *http.Request {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.SetBasicAuth("foo", "bar")
|
||||
return req
|
||||
}(),
|
||||
username: "foo",
|
||||
password: "bar",
|
||||
expectError: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
srv := echo.New()
|
||||
srv.HideBanner = true
|
||||
srv.HidePort = true
|
||||
c := srv.NewContext(tc.request, recorder)
|
||||
err := basicAuthMiddleware(tc.username, tc.password)(func(c echo.Context) error {
|
||||
return nil
|
||||
})(c)
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoggerMiddleware(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
request *http.Request
|
||||
@@ -271,7 +335,6 @@ func TestLoggerMiddleware(t *testing.T) {
|
||||
}
|
||||
|
||||
err := loggerMiddleware(zap.NewNop(), disableLoggingForPaths)(tc.next)(c)
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("test %d: expected no error but got: %v", i, err)
|
||||
}
|
||||
@@ -322,6 +385,15 @@ func TestContextMiddleware(t *testing.T) {
|
||||
}(),
|
||||
expectStatus: http.StatusNoContent,
|
||||
},
|
||||
{
|
||||
request: buildMultipartFormDataRequest(),
|
||||
next: func() echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
return ErrNoOutputFile
|
||||
}
|
||||
}(),
|
||||
expectStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
request: buildMultipartFormDataRequest(),
|
||||
next: func() echo.HandlerFunc {
|
||||
@@ -386,10 +458,11 @@ func TestContextMiddleware(t *testing.T) {
|
||||
|
||||
c := srv.NewContext(tc.request, recorder)
|
||||
c.Set("logger", zap.NewNop())
|
||||
c.Set("traceHeader", "Gotenberg-Trace")
|
||||
c.Set("trace", "foo")
|
||||
c.Set("startTime", time.Now())
|
||||
|
||||
err := contextMiddleware(time.Duration(10) * time.Second)(tc.next)(c)
|
||||
err := contextMiddleware(gotenberg.NewFileSystem(), time.Duration(10)*time.Second, 0, downloadFromConfig{})(tc.next)(c)
|
||||
|
||||
if tc.expectErr && err == nil {
|
||||
t.Errorf("test %d: expected error but got: %v", i, err)
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/alexliesenfeld/health"
|
||||
"github.com/labstack/echo/v4"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
|
||||
// ContextMock is a helper for tests.
|
||||
@@ -20,6 +23,15 @@ func (ctx *ContextMock) SetDirPath(path string) {
|
||||
ctx.dirPath = path
|
||||
}
|
||||
|
||||
// DirPath returns the context's working directory path.
|
||||
//
|
||||
// ctx := &api.ContextMock{Context: &api.Context{}}
|
||||
// ctx.SetDirPath("/foo")
|
||||
// dirPath := ctx.DirPath()
|
||||
func (ctx *ContextMock) DirPath() string {
|
||||
return ctx.dirPath
|
||||
}
|
||||
|
||||
// SetValues sets the values.
|
||||
//
|
||||
// ctx := &api.ContextMock{Context: &api.Context{}}
|
||||
@@ -54,7 +66,7 @@ func (ctx *ContextMock) SetCancelled(cancelled bool) {
|
||||
//
|
||||
// ctx := &api.ContextMock{Context: &api.Context{}}
|
||||
// outputPaths := ctx.OutputPaths()
|
||||
func (ctx ContextMock) OutputPaths() []string {
|
||||
func (ctx *ContextMock) OutputPaths() []string {
|
||||
return ctx.outputPaths
|
||||
}
|
||||
|
||||
@@ -66,10 +78,57 @@ func (ctx *ContextMock) SetLogger(logger *zap.Logger) {
|
||||
ctx.logger = logger
|
||||
}
|
||||
|
||||
// SetEchoContext sets the echo.Context.
|
||||
// SetEchoContext sets the [echo.Context].
|
||||
//
|
||||
// ctx := &api.ContextMock{Context: &api.Context{}}
|
||||
// ctx.setEchoContext(c)
|
||||
func (ctx *ContextMock) SetEchoContext(c echo.Context) {
|
||||
ctx.Context.echoCtx = c
|
||||
}
|
||||
|
||||
// SetPathRename sets the [gotenberg.PathRename].
|
||||
//
|
||||
// ctx := &api.ContextMock{Context: &api.Context{}}
|
||||
// ctx.setPathRename(rename)
|
||||
func (ctx *ContextMock) SetPathRename(rename gotenberg.PathRename) {
|
||||
ctx.Context.pathRename = rename
|
||||
}
|
||||
|
||||
// RouterMock is a mock for the [Router] interface.
|
||||
type RouterMock struct {
|
||||
RoutesMock func() ([]Route, error)
|
||||
}
|
||||
|
||||
func (router *RouterMock) Routes() ([]Route, error) {
|
||||
return router.RoutesMock()
|
||||
}
|
||||
|
||||
// MiddlewareProviderMock is a mock for the [MiddlewareProvider] interface.
|
||||
type MiddlewareProviderMock struct {
|
||||
MiddlewaresMock func() ([]Middleware, error)
|
||||
}
|
||||
|
||||
func (provider *MiddlewareProviderMock) Middlewares() ([]Middleware, error) {
|
||||
return provider.MiddlewaresMock()
|
||||
}
|
||||
|
||||
// HealthCheckerMock is mock for the [HealthChecker] interface.
|
||||
type HealthCheckerMock struct {
|
||||
ChecksMock func() ([]health.CheckerOption, error)
|
||||
ReadyMock func() error
|
||||
}
|
||||
|
||||
func (mod *HealthCheckerMock) Checks() ([]health.CheckerOption, error) {
|
||||
return mod.ChecksMock()
|
||||
}
|
||||
|
||||
func (mod *HealthCheckerMock) Ready() error {
|
||||
return mod.ReadyMock()
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ Router = (*RouterMock)(nil)
|
||||
_ MiddlewareProvider = (*MiddlewareProviderMock)(nil)
|
||||
_ HealthChecker = (*HealthCheckerMock)(nil)
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/alexliesenfeld/health"
|
||||
"github.com/labstack/echo/v4"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
@@ -20,6 +21,18 @@ func TestContextMock_SetDirPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextMock_DirPath(t *testing.T) {
|
||||
mock := &ContextMock{&Context{}}
|
||||
mock.SetDirPath("/foo")
|
||||
|
||||
actual := mock.DirPath()
|
||||
expect := "/foo"
|
||||
|
||||
if actual != expect {
|
||||
t.Errorf("expected '%s' but got '%s'", expect, actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextMock_SetValues(t *testing.T) {
|
||||
mock := &ContextMock{&Context{}}
|
||||
mock.SetValues(map[string][]string{
|
||||
@@ -103,3 +116,63 @@ func TestContextMock_SetEchoContext(t *testing.T) {
|
||||
t.Errorf("expected %v but got %v", expect, actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextMock_SetPathRename(t *testing.T) {
|
||||
mock := ContextMock{&Context{}}
|
||||
|
||||
expect := new(osPathRename)
|
||||
mock.SetPathRename(expect)
|
||||
|
||||
actual := mock.pathRename
|
||||
|
||||
if actual != expect {
|
||||
t.Errorf("expected %v but got %v", expect, actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterMock(t *testing.T) {
|
||||
mock := &RouterMock{
|
||||
RoutesMock: func() ([]Route, error) {
|
||||
return nil, nil
|
||||
},
|
||||
}
|
||||
|
||||
_, err := mock.Routes()
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from RouterMock.Routes, but got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiddlewareProviderMock(t *testing.T) {
|
||||
mock := &MiddlewareProviderMock{
|
||||
MiddlewaresMock: func() ([]Middleware, error) {
|
||||
return nil, nil
|
||||
},
|
||||
}
|
||||
|
||||
_, err := mock.Middlewares()
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from MiddlewareProviderMock.Middlewares, but got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthCheckerMock(t *testing.T) {
|
||||
mock := &HealthCheckerMock{
|
||||
ChecksMock: func() ([]health.CheckerOption, error) {
|
||||
return nil, nil
|
||||
},
|
||||
ReadyMock: func() error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
_, err := mock.Checks()
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from HealthCheckerMock.Checks, but got: %v", err)
|
||||
}
|
||||
|
||||
err = mock.Ready()
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from HealthCheckerMock.Ready, but got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
418
pkg/modules/chromium/browser.go
Normal file
418
pkg/modules/chromium/browser.go
Normal file
@@ -0,0 +1,418 @@
|
||||
package chromium
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/chromedp/cdproto/fetch"
|
||||
"github.com/chromedp/cdproto/network"
|
||||
"github.com/chromedp/cdproto/runtime"
|
||||
"github.com/chromedp/chromedp"
|
||||
"github.com/dlclark/regexp2"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
|
||||
type browser interface {
|
||||
gotenberg.Process
|
||||
pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error
|
||||
screenshot(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error
|
||||
}
|
||||
|
||||
type browserArguments struct {
|
||||
// Executor args.
|
||||
binPath string
|
||||
incognito bool
|
||||
allowInsecureLocalhost bool
|
||||
ignoreCertificateErrors bool
|
||||
disableWebSecurity bool
|
||||
allowFileAccessFromFiles bool
|
||||
hostResolverRules string
|
||||
proxyServer string
|
||||
wsUrlReadTimeout time.Duration
|
||||
|
||||
// Tasks specific.
|
||||
allowList *regexp2.Regexp
|
||||
denyList *regexp2.Regexp
|
||||
clearCache bool
|
||||
clearCookies bool
|
||||
disableJavaScript bool
|
||||
}
|
||||
|
||||
type chromiumBrowser struct {
|
||||
initialCtx context.Context
|
||||
ctx context.Context
|
||||
cancelFunc context.CancelFunc
|
||||
userProfileDirPath string
|
||||
ctxMu sync.RWMutex
|
||||
isStarted atomic.Bool
|
||||
|
||||
arguments browserArguments
|
||||
fs *gotenberg.FileSystem
|
||||
}
|
||||
|
||||
func newChromiumBrowser(arguments browserArguments) browser {
|
||||
b := &chromiumBrowser{
|
||||
initialCtx: context.Background(),
|
||||
arguments: arguments,
|
||||
fs: gotenberg.NewFileSystem(),
|
||||
}
|
||||
b.isStarted.Store(false)
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *chromiumBrowser) Start(logger *zap.Logger) error {
|
||||
if b.isStarted.Load() {
|
||||
return errors.New("browser is already started")
|
||||
}
|
||||
|
||||
debug := &debugLogger{logger: logger}
|
||||
b.userProfileDirPath = b.fs.NewDirPath()
|
||||
|
||||
opts := append(chromedp.DefaultExecAllocatorOptions[:],
|
||||
chromedp.CombinedOutput(debug),
|
||||
chromedp.ExecPath(b.arguments.binPath),
|
||||
chromedp.NoSandbox,
|
||||
// See:
|
||||
// https://github.com/gotenberg/gotenberg/issues/327
|
||||
// https://github.com/chromedp/chromedp/issues/904
|
||||
chromedp.DisableGPU,
|
||||
// See:
|
||||
// https://github.com/puppeteer/puppeteer/issues/661
|
||||
// https://github.com/puppeteer/puppeteer/issues/2410
|
||||
chromedp.Flag("font-render-hinting", "none"),
|
||||
chromedp.UserDataDir(b.userProfileDirPath),
|
||||
// See https://github.com/gotenberg/gotenberg/issues/831.
|
||||
chromedp.Flag("disable-pdf-tagging", true),
|
||||
)
|
||||
|
||||
if b.arguments.incognito {
|
||||
opts = append(opts, chromedp.Flag("incognito", b.arguments.incognito))
|
||||
}
|
||||
|
||||
if b.arguments.allowInsecureLocalhost {
|
||||
// See https://github.com/gotenberg/gotenberg/issues/488.
|
||||
opts = append(opts, chromedp.Flag("allow-insecure-localhost", true))
|
||||
}
|
||||
|
||||
if b.arguments.ignoreCertificateErrors {
|
||||
opts = append(opts, chromedp.IgnoreCertErrors)
|
||||
}
|
||||
|
||||
if b.arguments.disableWebSecurity {
|
||||
opts = append(opts, chromedp.Flag("disable-web-security", true))
|
||||
}
|
||||
|
||||
if b.arguments.allowFileAccessFromFiles {
|
||||
// See https://github.com/gotenberg/gotenberg/issues/356.
|
||||
opts = append(opts, chromedp.Flag("allow-file-access-from-files", true))
|
||||
}
|
||||
|
||||
if b.arguments.hostResolverRules != "" {
|
||||
// See https://github.com/gotenberg/gotenberg/issues/488.
|
||||
opts = append(opts, chromedp.Flag("host-resolver-rules", b.arguments.hostResolverRules))
|
||||
}
|
||||
|
||||
if b.arguments.proxyServer != "" {
|
||||
// See https://github.com/gotenberg/gotenberg/issues/376.
|
||||
opts = append(opts, chromedp.ProxyServer(b.arguments.proxyServer))
|
||||
}
|
||||
|
||||
// See https://github.com/gotenberg/gotenberg/issues/524.
|
||||
opts = append(opts, chromedp.WSURLReadTimeout(b.arguments.wsUrlReadTimeout))
|
||||
|
||||
allocatorCtx, allocatorCancel := chromedp.NewExecAllocator(b.initialCtx, opts...)
|
||||
ctx, cancel := chromedp.NewContext(allocatorCtx, chromedp.WithDebugf(debug.Printf))
|
||||
|
||||
err := chromedp.Run(ctx)
|
||||
if err != nil {
|
||||
cancel()
|
||||
allocatorCancel()
|
||||
return fmt.Errorf("run exec allocator: %w", err)
|
||||
}
|
||||
|
||||
b.ctxMu.Lock()
|
||||
defer b.ctxMu.Unlock()
|
||||
|
||||
// We have to keep the context around, as we need it to create new tabs
|
||||
// later.
|
||||
b.ctx = ctx
|
||||
b.cancelFunc = func() {
|
||||
cancel()
|
||||
allocatorCancel()
|
||||
}
|
||||
b.isStarted.Store(true)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *chromiumBrowser) Stop(logger *zap.Logger) error {
|
||||
if !b.isStarted.Load() {
|
||||
// No big deal? Like calling cancel twice.
|
||||
return nil
|
||||
}
|
||||
|
||||
// Always remove the user profile directory created by Chromium.
|
||||
copyUserProfileDirPath := b.userProfileDirPath
|
||||
defer func(userProfileDirPath string) {
|
||||
go func() {
|
||||
// FIXME: Chromium seems to recreate the user profile directory
|
||||
// right after its deletion if we do not wait a certain amount
|
||||
// of time before deleting it.
|
||||
<-time.After(10 * time.Second)
|
||||
|
||||
err := os.RemoveAll(userProfileDirPath)
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("remove Chromium's user profile directory: %s", err))
|
||||
}
|
||||
|
||||
logger.Debug(fmt.Sprintf("'%s' Chromium's user profile directory removed", userProfileDirPath))
|
||||
}()
|
||||
}(copyUserProfileDirPath)
|
||||
|
||||
b.ctxMu.Lock()
|
||||
defer b.ctxMu.Unlock()
|
||||
|
||||
b.cancelFunc()
|
||||
b.ctx = nil
|
||||
b.userProfileDirPath = ""
|
||||
b.isStarted.Store(false)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *chromiumBrowser) Healthy(logger *zap.Logger) bool {
|
||||
// Good to know: the supervisor does not call this method if no first start
|
||||
// or if the process is restarting.
|
||||
|
||||
if !b.isStarted.Load() {
|
||||
// Non-started browser but not restarting?
|
||||
return false
|
||||
}
|
||||
|
||||
b.ctxMu.RLock()
|
||||
defer b.ctxMu.RUnlock()
|
||||
|
||||
timeoutCtx, timeoutCancel := context.WithTimeout(b.ctx, time.Duration(10)*time.Second)
|
||||
defer timeoutCancel()
|
||||
|
||||
taskCtx, taskCancel := chromedp.NewContext(timeoutCtx)
|
||||
defer taskCancel()
|
||||
|
||||
err := chromedp.Run(taskCtx, chromedp.Navigate("about:blank"))
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("browser health check failed: %s", err))
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (b *chromiumBrowser) pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
|
||||
// Note: no error wrapping because it leaks on errors we want to display to
|
||||
// the end user.
|
||||
return b.do(ctx, logger, url, options.Options, chromedp.Tasks{
|
||||
network.Enable(),
|
||||
fetch.Enable(),
|
||||
runtime.Enable(),
|
||||
clearCacheActionFunc(logger, b.arguments.clearCache),
|
||||
clearCookiesActionFunc(logger, b.arguments.clearCookies),
|
||||
disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript),
|
||||
setCookiesActionFunc(logger, options.Cookies),
|
||||
userAgentOverride(logger, options.UserAgent),
|
||||
navigateActionFunc(logger, url, options.SkipNetworkIdleEvent),
|
||||
hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, options.PrintBackground),
|
||||
forceExactColorsActionFunc(),
|
||||
emulateMediaTypeActionFunc(logger, options.EmulatedMediaType),
|
||||
waitDelayBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitDelay),
|
||||
waitForExpressionBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitForExpression),
|
||||
// PDF specific.
|
||||
printToPdfActionFunc(logger, outputPath, options),
|
||||
})
|
||||
}
|
||||
|
||||
func (b *chromiumBrowser) screenshot(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
|
||||
// Note: no error wrapping because it leaks on errors we want to display to
|
||||
// the end user.
|
||||
return b.do(ctx, logger, url, options.Options, chromedp.Tasks{
|
||||
network.Enable(),
|
||||
fetch.Enable(),
|
||||
runtime.Enable(),
|
||||
clearCacheActionFunc(logger, b.arguments.clearCache),
|
||||
clearCookiesActionFunc(logger, b.arguments.clearCookies),
|
||||
disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript),
|
||||
setCookiesActionFunc(logger, options.Cookies),
|
||||
userAgentOverride(logger, options.UserAgent),
|
||||
navigateActionFunc(logger, url, options.SkipNetworkIdleEvent),
|
||||
hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, true),
|
||||
forceExactColorsActionFunc(),
|
||||
emulateMediaTypeActionFunc(logger, options.EmulatedMediaType),
|
||||
waitDelayBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitDelay),
|
||||
waitForExpressionBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitForExpression),
|
||||
// Screenshot specific.
|
||||
setDeviceMetricsOverride(logger, options.Width, options.Height),
|
||||
captureScreenshotActionFunc(logger, outputPath, options),
|
||||
})
|
||||
}
|
||||
|
||||
func (b *chromiumBrowser) do(ctx context.Context, logger *zap.Logger, url string, options Options, tasks chromedp.Tasks) error {
|
||||
if !b.isStarted.Load() {
|
||||
return errors.New("browser not started, cannot handle tasks")
|
||||
}
|
||||
|
||||
deadline, ok := ctx.Deadline()
|
||||
if !ok {
|
||||
return errors.New("context has no deadline")
|
||||
}
|
||||
|
||||
// We validate the "main" URL against our allow / deny lists.
|
||||
err := gotenberg.FilterDeadline(b.arguments.allowList, b.arguments.denyList, url, deadline)
|
||||
if err != nil {
|
||||
return fmt.Errorf("filter URL: %w", err)
|
||||
}
|
||||
|
||||
b.ctxMu.RLock()
|
||||
defer b.ctxMu.RUnlock()
|
||||
|
||||
timeoutCtx, timeoutCancel := context.WithTimeout(b.ctx, time.Until(deadline))
|
||||
defer timeoutCancel()
|
||||
|
||||
taskCtx, taskCancel := chromedp.NewContext(timeoutCtx)
|
||||
defer taskCancel()
|
||||
|
||||
// We validate all others requests against our allow / deny lists.
|
||||
// If a request does not pass the validation, we make it fail. It also set
|
||||
// the extra HTTP headers, if any.
|
||||
// See https://github.com/gotenberg/gotenberg/issues/1011.
|
||||
listenForEventRequestPaused(taskCtx, logger, eventRequestPausedOptions{
|
||||
allowList: b.arguments.allowList,
|
||||
denyList: b.arguments.denyList,
|
||||
extraHttpHeaders: options.ExtraHttpHeaders,
|
||||
})
|
||||
|
||||
var (
|
||||
invalidHttpStatusCode error
|
||||
invalidHttpStatusCodeMu sync.RWMutex
|
||||
invalidResourceHttpStatusCode error
|
||||
invalidResourceHttpStatusCodeMu sync.RWMutex
|
||||
)
|
||||
|
||||
// See:
|
||||
// https://github.com/gotenberg/gotenberg/issues/613.
|
||||
// https://github.com/gotenberg/gotenberg/issues/1021.
|
||||
if len(options.FailOnHttpStatusCodes) != 0 || len(options.FailOnResourceHttpStatusCodes) != 0 {
|
||||
listenForEventResponseReceived(taskCtx, logger, eventResponseReceivedOptions{
|
||||
mainPageUrl: url,
|
||||
failOnHttpStatusCodes: options.FailOnHttpStatusCodes,
|
||||
invalidHttpStatusCode: &invalidHttpStatusCode,
|
||||
invalidHttpStatusCodeMu: &invalidHttpStatusCodeMu,
|
||||
failOnResourceOnHttpStatusCode: options.FailOnResourceHttpStatusCodes,
|
||||
invalidResourceHttpStatusCode: &invalidResourceHttpStatusCode,
|
||||
invalidResourceHttpStatusCodeMu: &invalidResourceHttpStatusCodeMu,
|
||||
})
|
||||
}
|
||||
|
||||
var (
|
||||
consoleExceptions error
|
||||
consoleExceptionsMu sync.RWMutex
|
||||
)
|
||||
|
||||
// See https://github.com/gotenberg/gotenberg/issues/262.
|
||||
if options.FailOnConsoleExceptions && !b.arguments.disableJavaScript {
|
||||
listenForEventExceptionThrown(taskCtx, logger, &consoleExceptions, &consoleExceptionsMu)
|
||||
}
|
||||
|
||||
var (
|
||||
loadingFailed error
|
||||
loadingFailedMu sync.RWMutex
|
||||
resourceLoadingFailed error
|
||||
resourceLoadingFailedMu sync.RWMutex
|
||||
)
|
||||
|
||||
// See:
|
||||
// https://github.com/gotenberg/gotenberg/issues/913.
|
||||
// https://github.com/gotenberg/gotenberg/issues/959.
|
||||
// https://github.com/gotenberg/gotenberg/issues/1021.
|
||||
listenForEventLoadingFailed(taskCtx, logger, eventLoadingFailedOptions{
|
||||
loadingFailed: &loadingFailed,
|
||||
loadingFailedMu: &loadingFailedMu,
|
||||
resourceLoadingFailed: &resourceLoadingFailed,
|
||||
resourceLoadingFailedMu: &resourceLoadingFailedMu,
|
||||
})
|
||||
|
||||
err = chromedp.Run(taskCtx, tasks...)
|
||||
if err != nil {
|
||||
errMessage := err.Error()
|
||||
|
||||
if strings.Contains(errMessage, "Show invalid printer settings error (-32000)") || strings.Contains(errMessage, "content area is empty (-32602)") {
|
||||
return ErrInvalidPrinterSettings
|
||||
}
|
||||
|
||||
if strings.Contains(errMessage, "Page range syntax error") {
|
||||
return ErrPageRangesSyntaxError
|
||||
}
|
||||
|
||||
if strings.Contains(errMessage, "rpcc: message too large") {
|
||||
return ErrRpccMessageTooLarge
|
||||
}
|
||||
|
||||
return fmt.Errorf("handle tasks: %w", err)
|
||||
}
|
||||
|
||||
// See https://github.com/gotenberg/gotenberg/issues/613.
|
||||
invalidHttpStatusCodeMu.RLock()
|
||||
defer invalidHttpStatusCodeMu.RUnlock()
|
||||
|
||||
if invalidHttpStatusCode != nil {
|
||||
return fmt.Errorf("%v: %w", invalidHttpStatusCode, ErrInvalidHttpStatusCode)
|
||||
}
|
||||
|
||||
// See https://github.com/gotenberg/gotenberg/issues/1021.
|
||||
invalidResourceHttpStatusCodeMu.RLock()
|
||||
defer invalidResourceHttpStatusCodeMu.RUnlock()
|
||||
|
||||
if invalidResourceHttpStatusCode != nil {
|
||||
return fmt.Errorf("%v: %w", invalidResourceHttpStatusCode, ErrInvalidResourceHttpStatusCode)
|
||||
}
|
||||
|
||||
// See https://github.com/gotenberg/gotenberg/issues/262.
|
||||
consoleExceptionsMu.RLock()
|
||||
defer consoleExceptionsMu.RUnlock()
|
||||
|
||||
if consoleExceptions != nil {
|
||||
return fmt.Errorf("%v: %w", consoleExceptions, ErrConsoleExceptions)
|
||||
}
|
||||
|
||||
// See:
|
||||
// https://github.com/gotenberg/gotenberg/issues/913.
|
||||
// https://github.com/gotenberg/gotenberg/issues/959.
|
||||
loadingFailedMu.RLock()
|
||||
defer loadingFailedMu.RUnlock()
|
||||
|
||||
if loadingFailed != nil {
|
||||
return fmt.Errorf("%v: %w", loadingFailed, ErrLoadingFailed)
|
||||
}
|
||||
|
||||
// See https://github.com/gotenberg/gotenberg/issues/1021.
|
||||
if options.FailOnResourceLoadingFailed {
|
||||
if resourceLoadingFailed != nil {
|
||||
return fmt.Errorf("%v: %w", resourceLoadingFailed, ErrResourceLoadingFailed)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ gotenberg.Process = (*chromiumBrowser)(nil)
|
||||
_ browser = (*chromiumBrowser)(nil)
|
||||
)
|
||||
2484
pkg/modules/chromium/browser_test.go
Normal file
2484
pkg/modules/chromium/browser_test.go
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -7,21 +7,21 @@ import (
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// debugLogger is wrapper around a zap.Logger which is used for debugging
|
||||
// debugLogger is wrapper around a [zap.Logger] which is used for debugging
|
||||
// Chromium.
|
||||
type debugLogger struct {
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// Write logs the bytes in a debug message.
|
||||
func (debug debugLogger) Write(p []byte) (n int, err error) {
|
||||
func (debug *debugLogger) Write(p []byte) (n int, err error) {
|
||||
debug.logger.Debug(string(p))
|
||||
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// Printf logs a debug message.
|
||||
func (debug debugLogger) Printf(format string, v ...interface{}) {
|
||||
func (debug *debugLogger) Printf(format string, v ...interface{}) {
|
||||
debug.logger.Debug(fmt.Sprintf(format, v...))
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
)
|
||||
|
||||
func TestDebugLogger_Write(t *testing.T) {
|
||||
actual, err := debugLogger{logger: zap.NewNop()}.Write([]byte("foo"))
|
||||
actual, err := (&debugLogger{logger: zap.NewNop()}).Write([]byte("foo"))
|
||||
expected := len([]byte("foo"))
|
||||
|
||||
if actual != expected {
|
||||
@@ -20,5 +20,5 @@ func TestDebugLogger_Write(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDebugLogger_Printf(t *testing.T) {
|
||||
debugLogger{logger: zap.NewNop()}.Printf("%s", "foo")
|
||||
(&debugLogger{logger: zap.NewNop()}).Printf("%s", "foo")
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Package chromium provides a module which adds routes for converting HTML
|
||||
// documents to PDF. Other modules may also retrieve the API provided by this
|
||||
// documents to PDF. Other modules may also retrieve the [Api] provided by this
|
||||
// module.
|
||||
package chromium
|
||||
|
||||
@@ -3,7 +3,8 @@ package chromium
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"net/http"
|
||||
"slices"
|
||||
"sync"
|
||||
|
||||
"github.com/chromedp/cdproto/cdp"
|
||||
@@ -12,14 +13,30 @@ import (
|
||||
"github.com/chromedp/cdproto/page"
|
||||
"github.com/chromedp/cdproto/runtime"
|
||||
"github.com/chromedp/chromedp"
|
||||
"github.com/dlclark/regexp2"
|
||||
"go.uber.org/multierr"
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
|
||||
type eventRequestPausedOptions struct {
|
||||
allowList, denyList *regexp2.Regexp
|
||||
extraHttpHeaders []ExtraHttpHeader
|
||||
}
|
||||
|
||||
// listenForEventRequestPaused listens for requests to check if they are
|
||||
// allowed or not.
|
||||
func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, allowList *regexp.Regexp, denyList *regexp.Regexp) {
|
||||
// allowed or not. It also set the extra HTTP headers, if any.
|
||||
// See https://github.com/gotenberg/gotenberg/issues/1011.
|
||||
// TODO: https://chromedevtools.github.io/devtools-protocol/tot/Network/#method-setBlockedURLs (experimental for now).
|
||||
func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, options eventRequestPausedOptions) {
|
||||
if len(options.extraHttpHeaders) == 0 {
|
||||
logger.Debug("no extra HTTP headers")
|
||||
} else {
|
||||
logger.Debug(fmt.Sprintf("extra HTTP headers: %+v", options.extraHttpHeaders))
|
||||
}
|
||||
|
||||
chromedp.ListenTarget(ctx, func(ev interface{}) {
|
||||
switch e := ev.(type) {
|
||||
case *fetch.EventRequestPaused:
|
||||
@@ -27,41 +44,229 @@ func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, allowL
|
||||
logger.Debug(fmt.Sprintf("event EventRequestPaused fired for '%s'", e.Request.URL))
|
||||
allow := true
|
||||
|
||||
if !allowList.MatchString(e.Request.URL) {
|
||||
logger.Warn(fmt.Sprintf("'%s' does not match the expression from the allowed list", e.Request.URL))
|
||||
allow = false
|
||||
deadline, ok := ctx.Deadline()
|
||||
if !ok {
|
||||
logger.Error("context has no deadline, cannot filter URL")
|
||||
return
|
||||
}
|
||||
|
||||
if denyList.String() != "" && denyList.MatchString(e.Request.URL) {
|
||||
logger.Warn(fmt.Sprintf("'%s' matches the expression from the denied list", e.Request.URL))
|
||||
err := gotenberg.FilterDeadline(options.allowList, options.denyList, e.Request.URL, deadline)
|
||||
if err != nil {
|
||||
logger.Warn(err.Error())
|
||||
allow = false
|
||||
}
|
||||
|
||||
cctx := chromedp.FromContext(ctx)
|
||||
executorCtx := cdp.WithExecutor(ctx, cctx.Target)
|
||||
|
||||
if allow {
|
||||
req := fetch.ContinueRequest(e.RequestID)
|
||||
err := req.Do(executorCtx)
|
||||
|
||||
if !allow {
|
||||
req := fetch.FailRequest(e.RequestID, network.ErrorReasonAccessDenied)
|
||||
err = req.Do(executorCtx)
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("continue request: %s", err))
|
||||
logger.Error(fmt.Sprintf("fail request: %s", err))
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
req := fetch.FailRequest(e.RequestID, network.ErrorReasonAccessDenied)
|
||||
err := req.Do(executorCtx)
|
||||
req := fetch.ContinueRequest(e.RequestID)
|
||||
|
||||
var extraHttpHeadersToSet []ExtraHttpHeader
|
||||
if len(options.extraHttpHeaders) > 0 {
|
||||
// The user want to set extra HTTP headers.
|
||||
|
||||
// First, we have to check if at least one header has to be
|
||||
// set for current request.
|
||||
for _, header := range options.extraHttpHeaders {
|
||||
if header.Scope == nil {
|
||||
// Non-scoped header.
|
||||
logger.Debug(fmt.Sprintf("extra HTTP header '%s' will be set for request URL '%s'", header.Name, e.Request.URL))
|
||||
extraHttpHeadersToSet = append(extraHttpHeadersToSet, header)
|
||||
continue
|
||||
}
|
||||
|
||||
ok, err := header.Scope.MatchString(e.Request.URL)
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("fail to match extra HTTP header '%s' scope with URL '%s': %s", header.Name, e.Request.URL, err))
|
||||
} else if ok {
|
||||
logger.Debug(fmt.Sprintf("extra HTTP header '%s' (scoped) will be set for request URL '%s'", header.Name, e.Request.URL))
|
||||
extraHttpHeadersToSet = append(extraHttpHeadersToSet, header)
|
||||
} else {
|
||||
logger.Debug(fmt.Sprintf("scoped extra HTTP header '%s' (scoped) will not be set for request URL '%s'", header.Name, e.Request.URL))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(extraHttpHeadersToSet) > 0 {
|
||||
logger.Debug(fmt.Sprintf("setting extra HTTP headers for request URL '%s': %+v", e.Request.URL, extraHttpHeadersToSet))
|
||||
|
||||
originalHeaders := e.Request.Headers
|
||||
headers := make(map[string]string)
|
||||
|
||||
for key, value := range originalHeaders {
|
||||
strValue, ok := value.(string)
|
||||
if ok {
|
||||
headers[key] = strValue
|
||||
} else {
|
||||
logger.Error(fmt.Sprintf("ignoring header '%s' for URL '%s' since it cannot be cast to a string", key, e.Request.URL))
|
||||
}
|
||||
}
|
||||
|
||||
var headersEntries []*fetch.HeaderEntry
|
||||
for key, value := range headers {
|
||||
headersEntries = append(headersEntries, &fetch.HeaderEntry{
|
||||
Name: key,
|
||||
Value: value,
|
||||
})
|
||||
}
|
||||
for _, header := range extraHttpHeadersToSet {
|
||||
headersEntries = append(headersEntries, &fetch.HeaderEntry{
|
||||
Name: header.Name,
|
||||
Value: header.Value,
|
||||
})
|
||||
}
|
||||
|
||||
req.Headers = headersEntries
|
||||
}
|
||||
|
||||
err = req.Do(executorCtx)
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("fail request: %s", err))
|
||||
logger.Error(fmt.Sprintf("continue request: %s", err))
|
||||
}
|
||||
}()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type eventResponseReceivedOptions struct {
|
||||
mainPageUrl string
|
||||
failOnHttpStatusCodes []int64
|
||||
invalidHttpStatusCode *error
|
||||
invalidHttpStatusCodeMu *sync.RWMutex
|
||||
failOnResourceOnHttpStatusCode []int64
|
||||
invalidResourceHttpStatusCode *error
|
||||
invalidResourceHttpStatusCodeMu *sync.RWMutex
|
||||
}
|
||||
|
||||
// listenForEventResponseReceived listens for an invalid HTTP status code that
|
||||
// is returned by the main page or by one or more resources.
|
||||
// See:
|
||||
// https://github.com/gotenberg/gotenberg/issues/613.
|
||||
// https://github.com/gotenberg/gotenberg/issues/1021.
|
||||
func listenForEventResponseReceived(
|
||||
ctx context.Context,
|
||||
logger *zap.Logger,
|
||||
options eventResponseReceivedOptions,
|
||||
) {
|
||||
for _, code := range []int64{199, 299, 399, 499, 599} {
|
||||
if slices.Contains(options.failOnHttpStatusCodes, code) {
|
||||
for i := code - 99; i <= code; i++ {
|
||||
options.failOnHttpStatusCodes = append(options.failOnHttpStatusCodes, i)
|
||||
}
|
||||
}
|
||||
|
||||
if slices.Contains(options.failOnResourceOnHttpStatusCode, code) {
|
||||
for i := code - 99; i <= code; i++ {
|
||||
options.failOnResourceOnHttpStatusCode = append(options.failOnResourceOnHttpStatusCode, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
chromedp.ListenTarget(ctx, func(ev interface{}) {
|
||||
switch ev := ev.(type) {
|
||||
case *network.EventResponseReceived:
|
||||
if ev.Response.URL == options.mainPageUrl {
|
||||
logger.Debug(fmt.Sprintf("event EventResponseReceived fired for main page: %+v", ev.Response))
|
||||
|
||||
if slices.Contains(options.failOnHttpStatusCodes, ev.Response.Status) {
|
||||
options.invalidHttpStatusCodeMu.Lock()
|
||||
defer options.invalidHttpStatusCodeMu.Unlock()
|
||||
|
||||
*options.invalidHttpStatusCode = fmt.Errorf("%d: %s", ev.Response.Status, ev.Response.StatusText)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
logger.Debug(fmt.Sprintf("event EventResponseReceived fired for a resource: %+v", ev.Response))
|
||||
|
||||
if slices.Contains(options.failOnResourceOnHttpStatusCode, ev.Response.Status) {
|
||||
options.invalidResourceHttpStatusCodeMu.Lock()
|
||||
defer options.invalidResourceHttpStatusCodeMu.Unlock()
|
||||
|
||||
*options.invalidResourceHttpStatusCode = multierr.Append(
|
||||
*options.invalidResourceHttpStatusCode,
|
||||
fmt.Errorf("%s - %d: %s", ev.Response.URL, ev.Response.Status, http.StatusText(int(ev.Response.Status))),
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type eventLoadingFailedOptions struct {
|
||||
loadingFailed *error
|
||||
loadingFailedMu *sync.RWMutex
|
||||
resourceLoadingFailed *error
|
||||
resourceLoadingFailedMu *sync.RWMutex
|
||||
}
|
||||
|
||||
// listenForEventLoadingFailed listens for an event indicating that the main
|
||||
// page or one or more resources failed to load.
|
||||
// See:
|
||||
// https://github.com/gotenberg/gotenberg/issues/913.
|
||||
// https://github.com/gotenberg/gotenberg/issues/959.
|
||||
// https://github.com/gotenberg/gotenberg/issues/1021.
|
||||
func listenForEventLoadingFailed(ctx context.Context, logger *zap.Logger, options eventLoadingFailedOptions) {
|
||||
chromedp.ListenTarget(ctx, func(ev interface{}) {
|
||||
switch ev := ev.(type) {
|
||||
case *network.EventLoadingFailed:
|
||||
logger.Debug(fmt.Sprintf("event EventLoadingFailed fired: %+v", ev.ErrorText))
|
||||
|
||||
// We are looking for common errors.
|
||||
// TODO: sufficient?
|
||||
errors := []string{
|
||||
"net::ERR_CONNECTION_CLOSED",
|
||||
"net::ERR_CONNECTION_RESET",
|
||||
"net::ERR_CONNECTION_REFUSED",
|
||||
"net::ERR_CONNECTION_ABORTED",
|
||||
"net::ERR_CONNECTION_FAILED",
|
||||
"net::ERR_NAME_NOT_RESOLVED",
|
||||
"net::ERR_INTERNET_DISCONNECTED",
|
||||
"net::ERR_ADDRESS_UNREACHABLE",
|
||||
"net::ERR_BLOCKED_BY_CLIENT",
|
||||
"net::ERR_BLOCKED_BY_RESPONSE",
|
||||
"net::ERR_FILE_NOT_FOUND",
|
||||
}
|
||||
if !slices.Contains(errors, ev.ErrorText) {
|
||||
logger.Debug(fmt.Sprintf("skip EventLoadingFailed: '%s' is not part of %+v", ev.ErrorText, errors))
|
||||
return
|
||||
}
|
||||
|
||||
if ev.Type == network.ResourceTypeDocument {
|
||||
// Supposition: except iframe, an event loading failed with a
|
||||
// resource type Document is about the main page.
|
||||
logger.Debug("event EventLoadingFailed fired for main page")
|
||||
|
||||
options.loadingFailedMu.Lock()
|
||||
defer options.loadingFailedMu.Unlock()
|
||||
|
||||
*options.loadingFailed = fmt.Errorf("%s", ev.ErrorText)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
logger.Debug("event EventLoadingFailed fired for a resource")
|
||||
|
||||
options.resourceLoadingFailedMu.Lock()
|
||||
defer options.resourceLoadingFailedMu.Unlock()
|
||||
|
||||
*options.resourceLoadingFailed = multierr.Append(
|
||||
*options.resourceLoadingFailed,
|
||||
fmt.Errorf("resource %s: %s", ev.Type, ev.ErrorText),
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// listenForEventExceptionThrown listens for exceptions in the console and
|
||||
// appends those exceptions to the given error pointer.
|
||||
// See https://github.com/gotenberg/gotenberg/issues/262.
|
||||
@@ -181,7 +386,6 @@ func waitForEventLoadingFinished(ctx context.Context, logger *zap.Logger) func()
|
||||
// completed or an error is encountered.
|
||||
func runBatch(ctx context.Context, fn ...func() error) error {
|
||||
eg, _ := errgroup.WithContext(ctx)
|
||||
|
||||
for _, f := range fn {
|
||||
eg.Go(f)
|
||||
}
|
||||
|
||||
44
pkg/modules/chromium/mocks.go
Normal file
44
pkg/modules/chromium/mocks.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package chromium
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
|
||||
// ApiMock is a mock for the [Api] interface.
|
||||
type ApiMock struct {
|
||||
PdfMock func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error
|
||||
ScreenshotMock func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error
|
||||
}
|
||||
|
||||
func (api *ApiMock) Pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
|
||||
return api.PdfMock(ctx, logger, url, outputPath, options)
|
||||
}
|
||||
|
||||
func (api *ApiMock) Screenshot(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
|
||||
return api.ScreenshotMock(ctx, logger, url, outputPath, options)
|
||||
}
|
||||
|
||||
// browserMock is a mock for the [browser] interface.
|
||||
type browserMock struct {
|
||||
gotenberg.ProcessMock
|
||||
pdfMock func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error
|
||||
screenshotMock func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error
|
||||
}
|
||||
|
||||
func (b *browserMock) pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
|
||||
return b.pdfMock(ctx, logger, url, outputPath, options)
|
||||
}
|
||||
|
||||
func (b *browserMock) screenshot(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
|
||||
return b.screenshotMock(ctx, logger, url, outputPath, options)
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ Api = (*ApiMock)(nil)
|
||||
_ browser = (*browserMock)(nil)
|
||||
)
|
||||
50
pkg/modules/chromium/mocks_test.go
Normal file
50
pkg/modules/chromium/mocks_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package chromium
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestApiMock(t *testing.T) {
|
||||
mock := &ApiMock{
|
||||
PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
|
||||
return nil
|
||||
},
|
||||
ScreenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
err := mock.Pdf(context.Background(), zap.NewNop(), "", "", PdfOptions{})
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from ApiMock.Pdf, but got: %v", err)
|
||||
}
|
||||
|
||||
err = mock.Screenshot(context.Background(), zap.NewNop(), "", "", ScreenshotOptions{})
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from ApiMock.Screenshot, but got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserMock(t *testing.T) {
|
||||
mock := &browserMock{
|
||||
pdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
|
||||
return nil
|
||||
},
|
||||
screenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
err := mock.pdf(context.Background(), zap.NewNop(), "", "", PdfOptions{})
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from browserMock.pdf, but got: %v", err)
|
||||
}
|
||||
|
||||
err = mock.screenshot(context.Background(), zap.NewNop(), "", "", ScreenshotOptions{})
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from browserMock.screenshot, but got: %v", err)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
511
pkg/modules/chromium/tasks.go
Normal file
511
pkg/modules/chromium/tasks.go
Normal file
@@ -0,0 +1,511 @@
|
||||
package chromium
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/chromedp/cdproto/cdp"
|
||||
"github.com/chromedp/cdproto/emulation"
|
||||
"github.com/chromedp/cdproto/network"
|
||||
"github.com/chromedp/cdproto/page"
|
||||
"github.com/chromedp/chromedp"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func printToPdfActionFunc(logger *zap.Logger, outputPath string, options PdfOptions) chromedp.ActionFunc {
|
||||
return func(ctx context.Context) error {
|
||||
paperHeight := options.PaperHeight
|
||||
pageRanges := options.PageRanges
|
||||
|
||||
if options.SinglePage {
|
||||
logger.Debug("single page PDF")
|
||||
|
||||
_, _, _, _, _, cssContentSize, err := page.GetLayoutMetrics().Do(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get layout metrics: %w", err)
|
||||
}
|
||||
|
||||
// There are 96 CSS pixels per inch.
|
||||
// See https://issues.chromium.org/issues/40267771#comment14.
|
||||
paperHeight = cssContentSize.Height / 96
|
||||
pageRanges = "1" // little dirty hack to avoid leftovers.
|
||||
}
|
||||
|
||||
printToPdf := page.PrintToPDF().
|
||||
WithTransferMode(page.PrintToPDFTransferModeReturnAsStream).
|
||||
WithLandscape(options.Landscape).
|
||||
WithPrintBackground(options.PrintBackground).
|
||||
WithScale(options.Scale).
|
||||
WithPaperWidth(options.PaperWidth).
|
||||
WithPaperHeight(paperHeight).
|
||||
WithMarginTop(options.MarginTop).
|
||||
WithMarginBottom(options.MarginBottom).
|
||||
WithMarginLeft(options.MarginLeft).
|
||||
WithMarginRight(options.MarginRight).
|
||||
WithPageRanges(pageRanges).
|
||||
WithPreferCSSPageSize(options.PreferCssPageSize).
|
||||
WithGenerateDocumentOutline(options.GenerateDocumentOutline).
|
||||
// Does not seem to work.
|
||||
// See https://github.com/gotenberg/gotenberg/issues/831.
|
||||
WithGenerateTaggedPDF(false)
|
||||
|
||||
hasCustomHeaderFooter := options.HeaderTemplate != DefaultPdfOptions().HeaderTemplate ||
|
||||
options.FooterTemplate != DefaultPdfOptions().FooterTemplate
|
||||
|
||||
if !hasCustomHeaderFooter {
|
||||
logger.Debug("no custom header nor footer")
|
||||
|
||||
printToPdf = printToPdf.WithDisplayHeaderFooter(false)
|
||||
} else {
|
||||
logger.Debug("with custom header and/or footer")
|
||||
|
||||
printToPdf = printToPdf.
|
||||
WithDisplayHeaderFooter(true).
|
||||
WithHeaderTemplate(options.HeaderTemplate).
|
||||
WithFooterTemplate(options.FooterTemplate)
|
||||
}
|
||||
|
||||
logger.Debug(fmt.Sprintf("print to PDF with: %+v", printToPdf))
|
||||
|
||||
_, stream, err := printToPdf.Do(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("print to PDF: %w", err)
|
||||
}
|
||||
|
||||
reader := &streamReader{
|
||||
ctx: ctx,
|
||||
handle: stream,
|
||||
r: nil,
|
||||
pos: 0,
|
||||
eof: false,
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err = reader.Close()
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("close reader: %s", err))
|
||||
}
|
||||
}()
|
||||
|
||||
file, err := os.OpenFile(outputPath, os.O_CREATE|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open output path: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err = file.Close()
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("close output path: %s", err))
|
||||
}
|
||||
}()
|
||||
|
||||
buffer := bufio.NewReader(reader)
|
||||
|
||||
_, err = buffer.WriteTo(file)
|
||||
if err != nil {
|
||||
return fmt.Errorf("write result to output path: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func captureScreenshotActionFunc(logger *zap.Logger, outputPath string, options ScreenshotOptions) chromedp.ActionFunc {
|
||||
return func(ctx context.Context) error {
|
||||
captureScreenshot := page.CaptureScreenshot().
|
||||
WithCaptureBeyondViewport(true).
|
||||
WithFromSurface(true).
|
||||
WithOptimizeForSpeed(options.OptimizeForSpeed).
|
||||
WithFormat(page.CaptureScreenshotFormat(options.Format))
|
||||
|
||||
if options.Clip {
|
||||
captureScreenshot = captureScreenshot.WithClip(&page.Viewport{
|
||||
Width: float64(options.Width),
|
||||
Height: float64(options.Height),
|
||||
Scale: 1,
|
||||
})
|
||||
}
|
||||
|
||||
if options.Format == "jpeg" {
|
||||
captureScreenshot = captureScreenshot.
|
||||
WithQuality(int64(options.Quality))
|
||||
}
|
||||
|
||||
logger.Debug(fmt.Sprintf("capture screenshot with: %+v", captureScreenshot))
|
||||
|
||||
buffer, err := captureScreenshot.Do(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("capture screenshot: %w", err)
|
||||
}
|
||||
|
||||
file, err := os.OpenFile(outputPath, os.O_CREATE|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open output path: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err = file.Close()
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("close output path: %s", err))
|
||||
}
|
||||
}()
|
||||
|
||||
_, err = file.Write(buffer)
|
||||
if err != nil {
|
||||
return fmt.Errorf("write result to output path: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func setDeviceMetricsOverride(logger *zap.Logger, width, height int) chromedp.ActionFunc {
|
||||
return func(ctx context.Context) error {
|
||||
logger.Debug("set device metrics override")
|
||||
|
||||
err := emulation.SetDeviceMetricsOverride(int64(width), int64(height), 1.0, false).Do(ctx)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("set device metrics override: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
func clearCacheActionFunc(logger *zap.Logger, clear bool) chromedp.ActionFunc {
|
||||
return func(ctx context.Context) error {
|
||||
// See https://github.com/gotenberg/gotenberg/issues/753.
|
||||
if !clear {
|
||||
logger.Debug("cache not cleared")
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.Debug("clear cache")
|
||||
|
||||
err := network.ClearBrowserCache().Do(ctx)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("clear cache: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
func clearCookiesActionFunc(logger *zap.Logger, clear bool) chromedp.ActionFunc {
|
||||
return func(ctx context.Context) error {
|
||||
// See https://github.com/gotenberg/gotenberg/issues/753.
|
||||
if !clear {
|
||||
logger.Debug("cookies not cleared")
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.Debug("clear cookies")
|
||||
|
||||
err := network.ClearBrowserCookies().Do(ctx)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("clear cookies: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
func disableJavaScriptActionFunc(logger *zap.Logger, disable bool) chromedp.ActionFunc {
|
||||
return func(ctx context.Context) error {
|
||||
// See https://github.com/gotenberg/gotenberg/issues/175.
|
||||
if !disable {
|
||||
logger.Debug("JavaScript not disabled")
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.Debug("disable JavaScript")
|
||||
|
||||
err := emulation.SetScriptExecutionDisabled(true).Do(ctx)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("disable JavaScript: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
func setCookiesActionFunc(logger *zap.Logger, cookies []Cookie) chromedp.ActionFunc {
|
||||
return func(ctx context.Context) error {
|
||||
if len(cookies) == 0 {
|
||||
logger.Debug("no cookies to set")
|
||||
return nil
|
||||
}
|
||||
|
||||
deadline, ok := ctx.Deadline()
|
||||
if !ok {
|
||||
return errors.New("context has no deadline, cannot set cookies")
|
||||
}
|
||||
epochTime := cdp.TimeSinceEpoch(deadline)
|
||||
|
||||
cookiePretty := func(c *network.SetCookieParams) string {
|
||||
return fmt.Sprintf(
|
||||
"Name: '%s', Value: '%s', Domain: '%s', Path: '%s', Secure: %t, HTTPOnly: %t, SameSite: '%s', Expires: %s",
|
||||
c.Name,
|
||||
c.Value,
|
||||
c.Domain,
|
||||
c.Path,
|
||||
c.Secure,
|
||||
c.HTTPOnly,
|
||||
c.SameSite.String(),
|
||||
c.Expires.Time().String(),
|
||||
)
|
||||
}
|
||||
|
||||
for _, cookie := range cookies {
|
||||
cookieParams := network.
|
||||
SetCookie(cookie.Name, cookie.Value).
|
||||
WithDomain(cookie.Domain).
|
||||
WithPath(cookie.Path).
|
||||
WithSecure(cookie.Secure).
|
||||
WithHTTPOnly(cookie.HttpOnly).
|
||||
WithSameSite(cookie.SameSite).
|
||||
WithExpires(&epochTime)
|
||||
|
||||
err := cookieParams.Do(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set cookie %s: %w", cookiePretty(cookieParams), err)
|
||||
}
|
||||
|
||||
logger.Debug(fmt.Sprintf("set cookie %s", cookiePretty(cookieParams)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func userAgentOverride(logger *zap.Logger, userAgent string) chromedp.ActionFunc {
|
||||
return func(ctx context.Context) error {
|
||||
if len(userAgent) == 0 {
|
||||
logger.Debug("no user agent override")
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.Debug(fmt.Sprintf("user agent override: %s", userAgent))
|
||||
err := emulation.SetUserAgentOverride(userAgent).Do(ctx)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("set user agent override: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// This code has been replaced with the listenForEventRequestPaused function.
|
||||
// Indeed, the user may want to scope the headers per domain, but using
|
||||
// network.SetExtraHTTPHeaders set the headers for ALL requests from the page.
|
||||
// See https://github.com/gotenberg/gotenberg/issues/1011.
|
||||
//
|
||||
//func extraHttpHeadersActionFunc(logger *zap.Logger, extraHttpHeaders map[string]string) chromedp.ActionFunc {
|
||||
// return func(ctx context.Context) error {
|
||||
// if len(extraHttpHeaders) == 0 {
|
||||
// logger.Debug("no extra HTTP headers")
|
||||
// return nil
|
||||
// }
|
||||
//
|
||||
// logger.Debug(fmt.Sprintf("extra HTTP headers: %+v", extraHttpHeaders))
|
||||
//
|
||||
// headers := make(network.Headers, len(extraHttpHeaders))
|
||||
// for key, value := range extraHttpHeaders {
|
||||
// headers[key] = value
|
||||
// }
|
||||
//
|
||||
// err := network.SetExtraHTTPHeaders(headers).Do(ctx)
|
||||
// if err == nil {
|
||||
// return nil
|
||||
// }
|
||||
//
|
||||
// return fmt.Errorf("set extra HTTP headers: %w", err)
|
||||
// }
|
||||
//}
|
||||
|
||||
func navigateActionFunc(logger *zap.Logger, url string, skipNetworkIdleEvent bool) chromedp.ActionFunc {
|
||||
return func(ctx context.Context) error {
|
||||
logger.Debug(fmt.Sprintf("navigate to '%s'", url))
|
||||
|
||||
_, _, _, err := page.Navigate(url).Do(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("navigate to '%s': %w", url, err)
|
||||
}
|
||||
|
||||
waitFunc := []func() error{
|
||||
waitForEventDomContentEventFired(ctx, logger),
|
||||
waitForEventLoadEventFired(ctx, logger),
|
||||
waitForEventLoadingFinished(ctx, logger),
|
||||
}
|
||||
|
||||
if !skipNetworkIdleEvent {
|
||||
waitFunc = append(waitFunc, waitForEventNetworkIdle(ctx, logger))
|
||||
} else {
|
||||
logger.Debug("skipping network idle event")
|
||||
}
|
||||
|
||||
err = runBatch(
|
||||
ctx,
|
||||
waitFunc...,
|
||||
)
|
||||
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("wait for events: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
func hideDefaultWhiteBackgroundActionFunc(logger *zap.Logger, omitBackground, printBackground bool) chromedp.ActionFunc {
|
||||
return func(ctx context.Context) error {
|
||||
// See https://github.com/gotenberg/gotenberg/issues/226.
|
||||
if !omitBackground {
|
||||
logger.Debug("default white background not hidden")
|
||||
return nil
|
||||
}
|
||||
|
||||
if !printBackground {
|
||||
// See https://github.com/chromedp/chromedp/issues/1179#issuecomment-1284794416.
|
||||
return fmt.Errorf("validate omit background: %w", ErrOmitBackgroundWithoutPrintBackground)
|
||||
}
|
||||
|
||||
logger.Debug("hide default white background")
|
||||
|
||||
err := emulation.SetDefaultBackgroundColorOverride().WithColor(
|
||||
&cdp.RGBA{
|
||||
R: 0,
|
||||
G: 0,
|
||||
B: 0,
|
||||
A: 0,
|
||||
}).Do(ctx)
|
||||
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("hide default white background: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
func forceExactColorsActionFunc() chromedp.ActionFunc {
|
||||
return func(ctx context.Context) error {
|
||||
// See:
|
||||
// https://github.com/gotenberg/gotenberg/issues/354
|
||||
// https://github.com/puppeteer/puppeteer/issues/2685
|
||||
// https://github.com/chromedp/chromedp/issues/520
|
||||
script := `
|
||||
(() => {
|
||||
const css = 'html { -webkit-print-color-adjust: exact !important; }';
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.type = 'text/css';
|
||||
style.appendChild(document.createTextNode(css));
|
||||
document.head.appendChild(style);
|
||||
})();
|
||||
`
|
||||
|
||||
evaluate := chromedp.Evaluate(script, nil)
|
||||
err := evaluate.Do(ctx)
|
||||
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("add CSS for exact colors: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
func emulateMediaTypeActionFunc(logger *zap.Logger, mediaType string) chromedp.ActionFunc {
|
||||
return func(ctx context.Context) error {
|
||||
if mediaType == "" {
|
||||
logger.Debug("no emulated media type")
|
||||
return nil
|
||||
}
|
||||
|
||||
if mediaType != "screen" && mediaType != "print" {
|
||||
return fmt.Errorf("validate emulated media type '%s': %w", mediaType, ErrInvalidEmulatedMediaType)
|
||||
}
|
||||
|
||||
logger.Debug(fmt.Sprintf("emulate media type '%s'", mediaType))
|
||||
|
||||
emulatedMedia := emulation.SetEmulatedMedia()
|
||||
err := emulatedMedia.WithMedia(mediaType).Do(ctx)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("emulate media type '%s': %w", mediaType, err)
|
||||
}
|
||||
}
|
||||
|
||||
func waitDelayBeforePrintActionFunc(logger *zap.Logger, disableJavaScript bool, delay time.Duration) chromedp.ActionFunc {
|
||||
return func(ctx context.Context) error {
|
||||
if disableJavaScript {
|
||||
logger.Debug("JavaScript disabled, skipping wait delay")
|
||||
return nil
|
||||
}
|
||||
|
||||
if delay <= 0 {
|
||||
logger.Debug("no wait delay")
|
||||
return nil
|
||||
}
|
||||
|
||||
// We wait for a given amount of time so that JavaScript
|
||||
// scripts have a chance to finish before printing the page.
|
||||
logger.Debug(fmt.Sprintf("wait '%s' before print", delay))
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("wait delay: %w", ctx.Err())
|
||||
case <-time.After(delay):
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func waitForExpressionBeforePrintActionFunc(logger *zap.Logger, disableJavaScript bool, expression string) chromedp.ActionFunc {
|
||||
return func(ctx context.Context) error {
|
||||
if disableJavaScript {
|
||||
logger.Debug("JavaScript disabled, skipping wait expression")
|
||||
return nil
|
||||
}
|
||||
|
||||
if expression == "" {
|
||||
logger.Debug("no wait expression")
|
||||
return nil
|
||||
}
|
||||
|
||||
// We wait until the evaluation of the expression is true or
|
||||
// until the context is done.
|
||||
logger.Debug(fmt.Sprintf("wait until '%s' is true before print", expression))
|
||||
ticker := time.NewTicker(time.Duration(100) * time.Millisecond)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
ticker.Stop()
|
||||
return fmt.Errorf("context done while evaluating '%s': %w", expression, ctx.Err())
|
||||
case <-ticker.C:
|
||||
var ok bool
|
||||
evaluate := chromedp.Evaluate(expression, &ok)
|
||||
|
||||
err := evaluate.Do(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("evaluate: %v: %w", err, ErrInvalidEvaluationExpression)
|
||||
}
|
||||
|
||||
if ok {
|
||||
ticker.Stop()
|
||||
return nil
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
pkg/modules/exiftool/doc.go
Normal file
11
pkg/modules/exiftool/doc.go
Normal file
@@ -0,0 +1,11 @@
|
||||
// Package exiftool provides an implementation of the gotenberg.PdfEngine
|
||||
// interface using the ExifTool command-line tool. This package allows for:
|
||||
//
|
||||
// 1. The reading of metadata.
|
||||
// 2. The writing of metadata.
|
||||
//
|
||||
// The path to the exiftool binary must be specified using the
|
||||
// EXIFTOOL_BIN_PATH environment variable.
|
||||
//
|
||||
// See: https://exiftool.org.
|
||||
package exiftool
|
||||
155
pkg/modules/exiftool/exiftool.go
Normal file
155
pkg/modules/exiftool/exiftool.go
Normal file
@@ -0,0 +1,155 @@
|
||||
package exiftool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
|
||||
"github.com/barasher/go-exiftool"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
|
||||
func init() {
|
||||
gotenberg.MustRegisterModule(new(ExifTool))
|
||||
}
|
||||
|
||||
// ExifTool abstracts the CLI tool ExifTool and implements the
|
||||
// [gotenberg.PdfEngine] interface.
|
||||
type ExifTool struct {
|
||||
binPath string
|
||||
}
|
||||
|
||||
// Descriptor returns [ExifTool]'s module descriptor.
|
||||
func (engine *ExifTool) Descriptor() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{
|
||||
ID: "exiftool",
|
||||
New: func() gotenberg.Module { return new(ExifTool) },
|
||||
}
|
||||
}
|
||||
|
||||
// Provision sets the module properties.
|
||||
func (engine *ExifTool) Provision(ctx *gotenberg.Context) error {
|
||||
binPath, ok := os.LookupEnv("EXIFTOOL_BIN_PATH")
|
||||
if !ok {
|
||||
return errors.New("EXIFTOOL_BIN_PATH environment variable is not set")
|
||||
}
|
||||
|
||||
engine.binPath = binPath
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate validates the module properties.
|
||||
func (engine *ExifTool) Validate() error {
|
||||
_, err := os.Stat(engine.binPath)
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("ExifTool binary path does not exist: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Merge is not available in this implementation.
|
||||
func (engine *ExifTool) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
||||
return fmt.Errorf("merge PDFs with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||
}
|
||||
|
||||
// Convert is not available in this implementation.
|
||||
func (engine *ExifTool) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
||||
return fmt.Errorf("convert PDF to '%+v' with ExifTool: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported)
|
||||
}
|
||||
|
||||
// ReadMetadata extracts the metadata of a given PDF file.
|
||||
func (engine *ExifTool) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
|
||||
exifTool, err := exiftool.NewExiftool(exiftool.SetExiftoolBinaryPath(engine.binPath))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new ExifTool: %w", err)
|
||||
}
|
||||
|
||||
defer func(exifTool *exiftool.Exiftool) {
|
||||
err := exifTool.Close()
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("close ExifTool: %v", err))
|
||||
}
|
||||
}(exifTool)
|
||||
|
||||
fileMetadata := exifTool.ExtractMetadata(inputPath)
|
||||
if fileMetadata[0].Err != nil {
|
||||
return nil, fmt.Errorf("read metadata with ExitfTool: %w", fileMetadata[0].Err)
|
||||
}
|
||||
|
||||
return fileMetadata[0].Fields, nil
|
||||
}
|
||||
|
||||
// WriteMetadata writes the metadata into a given PDF file.
|
||||
func (engine *ExifTool) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
|
||||
exifTool, err := exiftool.NewExiftool(exiftool.SetExiftoolBinaryPath(engine.binPath))
|
||||
if err != nil {
|
||||
return fmt.Errorf("new ExifTool: %w", err)
|
||||
}
|
||||
|
||||
defer func(exifTool *exiftool.Exiftool) {
|
||||
err := exifTool.Close()
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("close ExifTool: %v", err))
|
||||
}
|
||||
}(exifTool)
|
||||
|
||||
fileMetadata := exifTool.ExtractMetadata(inputPath)
|
||||
if fileMetadata[0].Err != nil {
|
||||
return fmt.Errorf("read metadata with ExitfTool: %w", fileMetadata[0].Err)
|
||||
}
|
||||
|
||||
for key, value := range metadata {
|
||||
switch val := value.(type) {
|
||||
case string:
|
||||
fileMetadata[0].SetString(key, val)
|
||||
case []string:
|
||||
fileMetadata[0].SetStrings(key, val)
|
||||
case []interface{}:
|
||||
// See https://github.com/gotenberg/gotenberg/issues/1048.
|
||||
strings := make([]string, len(val))
|
||||
for i, entry := range val {
|
||||
if str, ok := entry.(string); ok {
|
||||
strings[i] = str
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("write PDF metadata with ExifTool: %s %+v %s %w", key, val, reflect.TypeOf(val), gotenberg.ErrPdfEngineMetadataValueNotSupported)
|
||||
}
|
||||
fileMetadata[0].SetStrings(key, strings)
|
||||
case bool:
|
||||
fileMetadata[0].SetString(key, fmt.Sprintf("%t", val))
|
||||
case int:
|
||||
fileMetadata[0].SetInt(key, int64(val))
|
||||
case int64:
|
||||
fileMetadata[0].SetInt(key, val)
|
||||
case float32:
|
||||
fileMetadata[0].SetFloat(key, float64(val))
|
||||
case float64:
|
||||
fileMetadata[0].SetFloat(key, val)
|
||||
// TODO: support more complex cases, e.g., arrays and nested objects
|
||||
// (limitations in underlying library).
|
||||
default:
|
||||
return fmt.Errorf("write PDF metadata with ExifTool: %s %+v %s %w", key, val, reflect.TypeOf(val), gotenberg.ErrPdfEngineMetadataValueNotSupported)
|
||||
}
|
||||
}
|
||||
|
||||
exifTool.WriteMetadata(fileMetadata)
|
||||
if fileMetadata[0].Err != nil {
|
||||
return fmt.Errorf("write PDF metadata with ExifTool: %w", fileMetadata[0].Err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ gotenberg.Module = (*ExifTool)(nil)
|
||||
_ gotenberg.Provisioner = (*ExifTool)(nil)
|
||||
_ gotenberg.Validator = (*ExifTool)(nil)
|
||||
_ gotenberg.PdfEngine = (*ExifTool)(nil)
|
||||
)
|
||||
353
pkg/modules/exiftool/exiftool_test.go
Normal file
353
pkg/modules/exiftool/exiftool_test.go
Normal file
@@ -0,0 +1,353 @@
|
||||
package exiftool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
|
||||
func TestExifTool_Descriptor(t *testing.T) {
|
||||
descriptor := new(ExifTool).Descriptor()
|
||||
|
||||
actual := reflect.TypeOf(descriptor.New())
|
||||
expect := reflect.TypeOf(new(ExifTool))
|
||||
|
||||
if actual != expect {
|
||||
t.Errorf("expected '%s' but got '%s'", expect, actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExifTool_Provision(t *testing.T) {
|
||||
engine := new(ExifTool)
|
||||
ctx := gotenberg.NewContext(gotenberg.ParsedFlags{}, nil)
|
||||
|
||||
err := engine.Provision(ctx)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error but got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExifTool_Validate(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
binPath string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
scenario: "empty bin path",
|
||||
binPath: "",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "bin path does not exist",
|
||||
binPath: "/foo",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "validate success",
|
||||
binPath: os.Getenv("EXIFTOOL_BIN_PATH"),
|
||||
expectError: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
engine := new(ExifTool)
|
||||
engine.binPath = tc.binPath
|
||||
err := engine.Validate()
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExiftool_Merge(t *testing.T) {
|
||||
engine := new(ExifTool)
|
||||
err := engine.Merge(context.Background(), zap.NewNop(), nil, "")
|
||||
|
||||
if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
|
||||
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExiftool_Convert(t *testing.T) {
|
||||
engine := new(ExifTool)
|
||||
err := engine.Convert(context.Background(), zap.NewNop(), gotenberg.PdfFormats{}, "", "")
|
||||
|
||||
if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
|
||||
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExiftool_ReadMetadata(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
inputPath string
|
||||
expectMetadata map[string]interface{}
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
scenario: "invalid input path",
|
||||
inputPath: "foo",
|
||||
expectMetadata: nil,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "success",
|
||||
inputPath: "/tests/test/testdata/pdfengines/sample1.pdf",
|
||||
expectMetadata: map[string]interface{}{
|
||||
"FileName": "sample1.pdf",
|
||||
"FileTypeExtension": "pdf",
|
||||
"MIMEType": "application/pdf",
|
||||
"PDFVersion": 1.4,
|
||||
"PageCount": float64(3),
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
engine := new(ExifTool)
|
||||
err := engine.Provision(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("expected error but got: %v", err)
|
||||
}
|
||||
|
||||
metadata, err := engine.ReadMetadata(context.Background(), zap.NewNop(), tc.inputPath)
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
|
||||
if tc.expectMetadata != nil && err == nil {
|
||||
for k, v := range tc.expectMetadata {
|
||||
if v2, ok := metadata[k]; !ok || v != v2 {
|
||||
t.Errorf("expected entry %s with value %v to exists", k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExiftool_WriteMetadata(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
createCopy bool
|
||||
inputPath string
|
||||
metadata map[string]interface{}
|
||||
expectMetadata map[string]interface{}
|
||||
expectError bool
|
||||
expectedError error
|
||||
}{
|
||||
{
|
||||
scenario: "invalid input path",
|
||||
createCopy: false,
|
||||
inputPath: "foo",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "gotenberg.ErrPdfEngineMetadataValueNotSupported (not string array)",
|
||||
createCopy: true,
|
||||
inputPath: "/tests/test/testdata/pdfengines/sample1.pdf",
|
||||
metadata: map[string]interface{}{
|
||||
"Unsupported": []interface{}{
|
||||
"foo",
|
||||
1,
|
||||
},
|
||||
},
|
||||
expectError: true,
|
||||
expectedError: gotenberg.ErrPdfEngineMetadataValueNotSupported,
|
||||
},
|
||||
{
|
||||
scenario: "gotenberg.ErrPdfEngineMetadataValueNotSupported (default)",
|
||||
createCopy: true,
|
||||
inputPath: "/tests/test/testdata/pdfengines/sample1.pdf",
|
||||
metadata: map[string]interface{}{
|
||||
"Unsupported": map[string]interface{}{},
|
||||
},
|
||||
expectError: true,
|
||||
expectedError: gotenberg.ErrPdfEngineMetadataValueNotSupported,
|
||||
},
|
||||
{
|
||||
scenario: "success (interface array to string array)",
|
||||
createCopy: true,
|
||||
inputPath: "/tests/test/testdata/pdfengines/sample1.pdf",
|
||||
metadata: map[string]interface{}{
|
||||
"Keywords": []interface{}{
|
||||
"first",
|
||||
"second",
|
||||
},
|
||||
},
|
||||
expectMetadata: map[string]interface{}{
|
||||
"Keywords": []interface{}{
|
||||
"first",
|
||||
"second",
|
||||
},
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "success",
|
||||
createCopy: true,
|
||||
inputPath: "/tests/test/testdata/pdfengines/sample1.pdf",
|
||||
metadata: map[string]interface{}{
|
||||
"Author": "Julien Neuhart",
|
||||
"Copyright": "Julien Neuhart",
|
||||
"CreationDate": "2006-09-18T16:27:50-04:00",
|
||||
"Creator": "Gotenberg",
|
||||
"Keywords": []string{
|
||||
"first",
|
||||
"second",
|
||||
},
|
||||
"Marked": true,
|
||||
"ModDate": "2006-09-18T16:27:50-04:00",
|
||||
"PDFVersion": 1.7,
|
||||
"Producer": "Gotenberg",
|
||||
"Subject": "Sample",
|
||||
"Title": "Sample",
|
||||
"Trapped": "Unknown",
|
||||
// Those are not valid PDF metadata.
|
||||
"int": 1,
|
||||
"int64": int64(2),
|
||||
"float32": float32(2.2),
|
||||
"float64": 3.3,
|
||||
},
|
||||
expectMetadata: map[string]interface{}{
|
||||
"Author": "Julien Neuhart",
|
||||
"Copyright": "Julien Neuhart",
|
||||
"CreationDate": "2006:09:18 16:27:50-04:00",
|
||||
"Creator": "Gotenberg",
|
||||
"Keywords": []interface{}{
|
||||
"first",
|
||||
"second",
|
||||
},
|
||||
"Marked": true,
|
||||
"ModDate": "2006:09:18 16:27:50-04:00",
|
||||
"PDFVersion": 1.7,
|
||||
"Producer": "Gotenberg",
|
||||
"Subject": "Sample",
|
||||
"Title": "Sample",
|
||||
"Trapped": "Unknown",
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
engine := new(ExifTool)
|
||||
err := engine.Provision(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
var destinationPath string
|
||||
if tc.createCopy {
|
||||
fs := gotenberg.NewFileSystem()
|
||||
outputDir, err := fs.MkdirAll()
|
||||
if err != nil {
|
||||
t.Fatalf("expected error no but got: %v", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err = os.RemoveAll(fs.WorkingDirPath())
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error while cleaning up but got: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
destinationPath = fmt.Sprintf("%s/copy_temp.pdf", outputDir)
|
||||
source, err := os.Open(tc.inputPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open source file: %v", err)
|
||||
}
|
||||
|
||||
defer func(source *os.File) {
|
||||
err := source.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("close file: %v", err)
|
||||
}
|
||||
}(source)
|
||||
|
||||
destination, err := os.Create(destinationPath)
|
||||
if err != nil {
|
||||
t.Fatalf("create destination file: %v", err)
|
||||
}
|
||||
|
||||
defer func(destination *os.File) {
|
||||
err := destination.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("close file: %v", err)
|
||||
}
|
||||
}(destination)
|
||||
|
||||
_, err = io.Copy(destination, source)
|
||||
if err != nil {
|
||||
t.Fatalf("copy source into destination: %v", err)
|
||||
}
|
||||
} else {
|
||||
destinationPath = tc.inputPath
|
||||
}
|
||||
|
||||
err = engine.WriteMetadata(context.Background(), zap.NewNop(), tc.metadata, destinationPath)
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
|
||||
if tc.expectedError != nil && !errors.Is(err, tc.expectedError) {
|
||||
t.Fatalf("expected error %v but got: %v", tc.expectedError, err)
|
||||
}
|
||||
|
||||
if tc.expectError {
|
||||
return
|
||||
}
|
||||
|
||||
metadata, err := engine.ReadMetadata(context.Background(), zap.NewNop(), destinationPath)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectMetadata != nil && err == nil {
|
||||
for k, v := range tc.expectMetadata {
|
||||
v2, ok := metadata[k]
|
||||
if !ok {
|
||||
t.Errorf("expected entry %s with value %v to exists, but got none", k, v)
|
||||
continue
|
||||
}
|
||||
|
||||
switch v2.(type) {
|
||||
case []interface{}:
|
||||
for i, entry := range v.([]interface{}) {
|
||||
if entry != v2.([]interface{})[i] {
|
||||
t.Errorf("expected entry %s to contain value %v, but got %v", k, entry, v2.([]interface{})[i])
|
||||
}
|
||||
}
|
||||
default:
|
||||
if v != v2 {
|
||||
t.Errorf("expected entry %s with value %v to exists, but got %v", k, v, v2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
// Package gc provides a module for removing files and directories that have
|
||||
// expired.
|
||||
package gc
|
||||
@@ -1,225 +0,0 @@
|
||||
package gc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func init() {
|
||||
gotenberg.MustRegisterModule(GarbageCollector{})
|
||||
}
|
||||
|
||||
// GarbageCollector is a module for removing files and directories that have
|
||||
// expired. It allows us to make sure that the application does not leak files
|
||||
// or directories when running.
|
||||
type GarbageCollector struct {
|
||||
rootPath string
|
||||
graceDuration time.Duration
|
||||
excludeSubstr []string
|
||||
|
||||
ticker *time.Ticker
|
||||
done chan bool
|
||||
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// GarbageCollectorGraceDurationModifier is a module interface which allows to
|
||||
// update the expiration time of files and directories parsed by the garbage
|
||||
// collector. For instance, if the grace duration is 30s, the garbage collector
|
||||
// will remove paths that have a modification time older than 30s. If there are
|
||||
// many GarbageCollectorGraceDurationModifier, only the longest grace duration
|
||||
// is selected.
|
||||
type GarbageCollectorGraceDurationModifier interface {
|
||||
GraceDuration() time.Duration
|
||||
}
|
||||
|
||||
// GarbageCollectorExcludeSubstrModifier is a module interface which adds the
|
||||
// given substrings to the exclude list of the garbage collector. If a path
|
||||
// contains one of those substrings, the garbage collector ignores it.
|
||||
type GarbageCollectorExcludeSubstrModifier interface {
|
||||
ExcludeSubstr() []string
|
||||
}
|
||||
|
||||
// Descriptor returns a GarbageCollector's module descriptor.
|
||||
func (gc GarbageCollector) Descriptor() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{
|
||||
ID: "gc",
|
||||
New: func() gotenberg.Module { return new(GarbageCollector) },
|
||||
}
|
||||
}
|
||||
|
||||
// Provision sets the module properties.
|
||||
func (gc *GarbageCollector) Provision(ctx *gotenberg.Context) error {
|
||||
gc.rootPath = gotenberg.TmpPath()
|
||||
|
||||
graceDurationModifiers, err := ctx.Modules(new(GarbageCollectorGraceDurationModifier))
|
||||
if err != nil {
|
||||
return fmt.Errorf("get grace duration modifiers: %w", err)
|
||||
}
|
||||
|
||||
for _, graceDurationModifier := range graceDurationModifiers {
|
||||
modifier := graceDurationModifier.(GarbageCollectorGraceDurationModifier)
|
||||
|
||||
if gc.graceDuration < modifier.GraceDuration() {
|
||||
gc.graceDuration = modifier.GraceDuration()
|
||||
}
|
||||
}
|
||||
|
||||
excludeSubstrModifiers, err := ctx.Modules(new(GarbageCollectorExcludeSubstrModifier))
|
||||
if err != nil {
|
||||
return fmt.Errorf("get exclude substr modifiers: %w", err)
|
||||
}
|
||||
|
||||
gc.excludeSubstr = strings.Split(os.Getenv("GC_EXCLUDE_SUBSTR"), ",")
|
||||
|
||||
for _, excludeSubstrModifier := range excludeSubstrModifiers {
|
||||
modifier := excludeSubstrModifier.(GarbageCollectorExcludeSubstrModifier)
|
||||
|
||||
gc.excludeSubstr = append(gc.excludeSubstr, modifier.ExcludeSubstr()...)
|
||||
}
|
||||
|
||||
loggerProvider, err := ctx.Module(new(gotenberg.LoggerProvider))
|
||||
if err != nil {
|
||||
return fmt.Errorf("get logger provider: %w", err)
|
||||
}
|
||||
|
||||
logger, err := loggerProvider.(gotenberg.LoggerProvider).Logger(gc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get logger: %w", err)
|
||||
}
|
||||
|
||||
gc.logger = logger
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Start starts the garbage collector.
|
||||
func (gc *GarbageCollector) Start() error {
|
||||
gc.ticker = time.NewTicker(gc.graceDuration + time.Duration(1)*time.Second)
|
||||
gc.done = make(chan bool, 1)
|
||||
|
||||
go func() {
|
||||
for {
|
||||
func() {
|
||||
gcMu.RLock()
|
||||
defer gcMu.RUnlock()
|
||||
|
||||
select {
|
||||
case <-gc.done:
|
||||
return
|
||||
case <-gc.ticker.C:
|
||||
gc.collect(false)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// collect parses the root path of the garbage collector and removes files or
|
||||
// directories that have expired. It ignores the expiration date if the "force"
|
||||
// argument is set to true.
|
||||
func (gc GarbageCollector) collect(force bool) {
|
||||
expirationTime := time.Now().Add(-gc.graceDuration)
|
||||
|
||||
// To make sure that the next Walk method stays on
|
||||
// the root level of the considered path, we have to
|
||||
// return a filepath.SkipDir error if the current path
|
||||
// is a directory.
|
||||
skipDirOrNil := func(info os.FileInfo) error {
|
||||
if info.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
removePath := func(path string) {
|
||||
err := os.RemoveAll(path)
|
||||
if err != nil {
|
||||
gc.logger.Error(fmt.Sprintf("remove '%s': %s", path, err))
|
||||
}
|
||||
|
||||
gc.logger.Debug(fmt.Sprintf("'%s' removed", path))
|
||||
}
|
||||
|
||||
err := filepath.Walk(gc.rootPath, func(path string, info os.FileInfo, pathErr error) error {
|
||||
if pathErr != nil {
|
||||
// For whatever reasons, the Walk method failed
|
||||
// to process the current path.
|
||||
return pathErr
|
||||
}
|
||||
|
||||
if path == gc.rootPath {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, substr := range gc.excludeSubstr {
|
||||
if strings.Contains(info.Name(), substr) {
|
||||
return skipDirOrNil(info)
|
||||
}
|
||||
}
|
||||
|
||||
if force {
|
||||
removePath(path)
|
||||
|
||||
return skipDirOrNil(info)
|
||||
}
|
||||
|
||||
if info.ModTime().Before(expirationTime) {
|
||||
removePath(path)
|
||||
}
|
||||
|
||||
return skipDirOrNil(info)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
gc.logger.Error(err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// StartupMessage returns an empty string.
|
||||
func (gc GarbageCollector) StartupMessage() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Stop stops the garbage collector.
|
||||
func (gc *GarbageCollector) Stop(ctx context.Context) error {
|
||||
_, ok := ctx.Deadline()
|
||||
if !ok {
|
||||
return errors.New("no context dead line")
|
||||
}
|
||||
|
||||
// Block until the context is done so that other module may gracefully stop
|
||||
// before we do a shutdown cleanup.
|
||||
gc.logger.Debug("wait for the end of grace duration")
|
||||
|
||||
<-ctx.Done()
|
||||
|
||||
gc.ticker.Stop()
|
||||
gc.done <- true
|
||||
|
||||
gc.logger.Debug("shutdown cleanup...")
|
||||
gc.collect(true)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var gcMu sync.RWMutex
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ gotenberg.Module = (*GarbageCollector)(nil)
|
||||
_ gotenberg.Provisioner = (*GarbageCollector)(nil)
|
||||
_ gotenberg.App = (*GarbageCollector)(nil)
|
||||
)
|
||||
@@ -1,468 +0,0 @@
|
||||
package gc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type ProtoModule struct {
|
||||
descriptor func() gotenberg.ModuleDescriptor
|
||||
}
|
||||
|
||||
func (mod ProtoModule) Descriptor() gotenberg.ModuleDescriptor {
|
||||
return mod.descriptor()
|
||||
}
|
||||
|
||||
type ProtoValidator struct {
|
||||
ProtoModule
|
||||
validate func() error
|
||||
}
|
||||
|
||||
func (mod ProtoValidator) Validate() error {
|
||||
return mod.validate()
|
||||
}
|
||||
|
||||
type ProtoGarbageCollectorGraceDurationModifier struct {
|
||||
ProtoValidator
|
||||
graceDuration func() time.Duration
|
||||
}
|
||||
|
||||
func (mod ProtoGarbageCollectorGraceDurationModifier) GraceDuration() time.Duration {
|
||||
return mod.graceDuration()
|
||||
}
|
||||
|
||||
type ProtoGarbageCollectorExcludeSubstrModifier struct {
|
||||
ProtoValidator
|
||||
excludeSubstr func() []string
|
||||
}
|
||||
|
||||
func (mod ProtoGarbageCollectorExcludeSubstrModifier) ExcludeSubstr() []string {
|
||||
return mod.excludeSubstr()
|
||||
}
|
||||
|
||||
type ProtoLoggerProvider struct {
|
||||
ProtoModule
|
||||
logger func(mod gotenberg.Module) (*zap.Logger, error)
|
||||
}
|
||||
|
||||
func (factory ProtoLoggerProvider) Logger(mod gotenberg.Module) (*zap.Logger, error) {
|
||||
return factory.logger(mod)
|
||||
}
|
||||
|
||||
func TestGarbageCollector_Descriptor(t *testing.T) {
|
||||
descriptor := GarbageCollector{}.Descriptor()
|
||||
|
||||
actual := reflect.TypeOf(descriptor.New())
|
||||
expect := reflect.TypeOf(new(GarbageCollector))
|
||||
|
||||
if actual != expect {
|
||||
t.Errorf("expected '%s' but got '%s'", expect, actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGarbageCollector_Provision(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
ctx *gotenberg.Context
|
||||
expectGraceDuration time.Duration
|
||||
expectExcludeSubstr []string
|
||||
expectErr bool
|
||||
}{
|
||||
{
|
||||
ctx: func() *gotenberg.Context {
|
||||
mod := struct {
|
||||
ProtoGarbageCollectorGraceDurationModifier
|
||||
}{}
|
||||
mod.descriptor = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }}
|
||||
}
|
||||
mod.validate = func() error { return errors.New("foo") }
|
||||
|
||||
return gotenberg.NewContext(gotenberg.ParsedFlags{}, []gotenberg.ModuleDescriptor{
|
||||
mod.Descriptor(),
|
||||
})
|
||||
}(),
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
ctx: func() *gotenberg.Context {
|
||||
mod := struct {
|
||||
ProtoGarbageCollectorExcludeSubstrModifier
|
||||
}{}
|
||||
mod.descriptor = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }}
|
||||
}
|
||||
mod.validate = func() error { return errors.New("foo") }
|
||||
|
||||
return gotenberg.NewContext(gotenberg.ParsedFlags{}, []gotenberg.ModuleDescriptor{
|
||||
mod.Descriptor(),
|
||||
})
|
||||
}(),
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
ctx: gotenberg.NewContext(gotenberg.ParsedFlags{}, make([]gotenberg.ModuleDescriptor, 0)),
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
ctx: func() *gotenberg.Context {
|
||||
mod := struct {
|
||||
ProtoLoggerProvider
|
||||
}{}
|
||||
mod.descriptor = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }}
|
||||
}
|
||||
mod.logger = func(mod gotenberg.Module) (*zap.Logger, error) { return nil, errors.New("foo") }
|
||||
|
||||
return gotenberg.NewContext(gotenberg.ParsedFlags{}, []gotenberg.ModuleDescriptor{
|
||||
mod.Descriptor(),
|
||||
})
|
||||
}(),
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
ctx: func() *gotenberg.Context {
|
||||
mod := struct {
|
||||
ProtoLoggerProvider
|
||||
}{}
|
||||
mod.descriptor = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }}
|
||||
}
|
||||
mod.logger = func(mod gotenberg.Module) (*zap.Logger, error) { return zap.NewNop(), nil }
|
||||
|
||||
return gotenberg.NewContext(gotenberg.ParsedFlags{}, []gotenberg.ModuleDescriptor{
|
||||
mod.Descriptor(),
|
||||
})
|
||||
}(),
|
||||
},
|
||||
{
|
||||
ctx: func() *gotenberg.Context {
|
||||
mod1 := struct {
|
||||
ProtoGarbageCollectorGraceDurationModifier
|
||||
}{}
|
||||
mod1.descriptor = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod1 }}
|
||||
}
|
||||
mod1.graceDuration = func() time.Duration { return time.Duration(10) * time.Second }
|
||||
mod1.validate = func() error { return nil }
|
||||
|
||||
mod2 := struct {
|
||||
ProtoGarbageCollectorGraceDurationModifier
|
||||
}{}
|
||||
mod2.descriptor = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod2 }}
|
||||
}
|
||||
mod2.graceDuration = func() time.Duration { return time.Duration(20) * time.Second }
|
||||
mod2.validate = func() error { return nil }
|
||||
|
||||
mod3 := struct {
|
||||
ProtoLoggerProvider
|
||||
}{}
|
||||
mod3.descriptor = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "baz", New: func() gotenberg.Module { return mod3 }}
|
||||
}
|
||||
mod3.logger = func(mod gotenberg.Module) (*zap.Logger, error) { return zap.NewNop(), nil }
|
||||
|
||||
return gotenberg.NewContext(gotenberg.ParsedFlags{}, []gotenberg.ModuleDescriptor{
|
||||
mod1.Descriptor(),
|
||||
mod2.Descriptor(),
|
||||
mod3.Descriptor(),
|
||||
})
|
||||
}(),
|
||||
expectGraceDuration: time.Duration(20) * time.Second,
|
||||
},
|
||||
{
|
||||
ctx: func() *gotenberg.Context {
|
||||
mod1 := struct {
|
||||
ProtoGarbageCollectorExcludeSubstrModifier
|
||||
}{}
|
||||
mod1.descriptor = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod1 }}
|
||||
}
|
||||
mod1.excludeSubstr = func() []string { return []string{"foo"} }
|
||||
mod1.validate = func() error { return nil }
|
||||
|
||||
mod2 := struct {
|
||||
ProtoGarbageCollectorExcludeSubstrModifier
|
||||
}{}
|
||||
mod2.descriptor = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod2 }}
|
||||
}
|
||||
mod2.excludeSubstr = func() []string { return []string{"bar"} }
|
||||
mod2.validate = func() error { return nil }
|
||||
|
||||
mod3 := struct {
|
||||
ProtoLoggerProvider
|
||||
}{}
|
||||
mod3.descriptor = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "baz", New: func() gotenberg.Module { return mod3 }}
|
||||
}
|
||||
mod3.logger = func(mod gotenberg.Module) (*zap.Logger, error) { return zap.NewNop(), nil }
|
||||
|
||||
return gotenberg.NewContext(gotenberg.ParsedFlags{}, []gotenberg.ModuleDescriptor{
|
||||
mod1.Descriptor(),
|
||||
mod2.Descriptor(),
|
||||
mod3.Descriptor(),
|
||||
})
|
||||
}(),
|
||||
expectExcludeSubstr: func() []string {
|
||||
expect := strings.Split(os.Getenv("GC_EXCLUDE_SUBSTR"), ",")
|
||||
return append(expect, "foo", "bar")
|
||||
}(),
|
||||
},
|
||||
} {
|
||||
mod := new(GarbageCollector)
|
||||
err := mod.Provision(tc.ctx)
|
||||
|
||||
if tc.expectErr && err == nil {
|
||||
t.Errorf("test %d: expected error but got: %v", i, err)
|
||||
}
|
||||
|
||||
if !tc.expectErr && err != nil {
|
||||
t.Errorf("test %d: expected no error but got: %v", i, err)
|
||||
}
|
||||
|
||||
if tc.expectGraceDuration != 0 && tc.expectGraceDuration != mod.graceDuration {
|
||||
t.Errorf("test %d: expected grace duration of '%s' but got '%s'", i, tc.expectGraceDuration, mod.graceDuration)
|
||||
}
|
||||
|
||||
if tc.expectExcludeSubstr != nil && !reflect.DeepEqual(tc.expectExcludeSubstr, mod.excludeSubstr) {
|
||||
t.Errorf("test %d: expected exclude substr '%s' but got '%s'", i, tc.expectExcludeSubstr, mod.excludeSubstr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGarbageCollector_Start(t *testing.T) {
|
||||
mod := new(GarbageCollector)
|
||||
mod.logger = zap.NewNop()
|
||||
|
||||
path, err := gotenberg.MkdirAll()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
mod.rootPath = path
|
||||
|
||||
err = mod.Start()
|
||||
if err != nil {
|
||||
t.Errorf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
time.Sleep(time.Duration(2) * time.Second)
|
||||
mod.ticker.Stop()
|
||||
mod.done <- true
|
||||
}
|
||||
|
||||
func TestGarbageCollector_collect(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
gc *GarbageCollector
|
||||
expectNotExists []string
|
||||
expectExists []string
|
||||
force bool
|
||||
}{
|
||||
{
|
||||
gc: func() *GarbageCollector {
|
||||
mod := new(GarbageCollector)
|
||||
mod.logger = zap.NewNop()
|
||||
|
||||
path, err := gotenberg.MkdirAll()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
mod.rootPath = path
|
||||
|
||||
err = os.WriteFile(path+"/foo", []byte{1}, 0755)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
mod.excludeSubstr = []string{
|
||||
"foo",
|
||||
}
|
||||
|
||||
return mod
|
||||
}(),
|
||||
expectExists: []string{
|
||||
"/foo",
|
||||
},
|
||||
},
|
||||
{
|
||||
gc: func() *GarbageCollector {
|
||||
mod := new(GarbageCollector)
|
||||
mod.logger = zap.NewNop()
|
||||
|
||||
path, err := gotenberg.MkdirAll()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
mod.rootPath = path
|
||||
|
||||
err = os.WriteFile(path+"/foo", []byte{1}, 0755)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
err = os.MkdirAll(path+"/bar", 0755)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
return mod
|
||||
}(),
|
||||
expectNotExists: []string{
|
||||
"/foo",
|
||||
"/bar",
|
||||
},
|
||||
force: true,
|
||||
},
|
||||
{
|
||||
gc: func() *GarbageCollector {
|
||||
mod := new(GarbageCollector)
|
||||
mod.logger = zap.NewNop()
|
||||
mod.graceDuration = time.Duration(10) * time.Second
|
||||
|
||||
path, err := gotenberg.MkdirAll()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
mod.rootPath = path
|
||||
|
||||
err = os.WriteFile(path+"/foo", []byte{1}, 0755)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
newTime := time.Now().Add(-time.Duration(20) * time.Second)
|
||||
err = os.Chtimes(path+"/foo", newTime, newTime)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
err = os.WriteFile(path+"/bar", []byte{1}, 0755)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
newTime = time.Now().Add(time.Duration(10) * time.Second)
|
||||
err = os.Chtimes(path+"/bar", newTime, newTime)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
return mod
|
||||
}(),
|
||||
expectNotExists: []string{
|
||||
"/foo",
|
||||
},
|
||||
expectExists: []string{
|
||||
"/bar",
|
||||
},
|
||||
},
|
||||
} {
|
||||
tc.gc.collect(tc.force)
|
||||
|
||||
for _, name := range tc.expectNotExists {
|
||||
path := tc.gc.rootPath + name
|
||||
_, err := os.Stat(path)
|
||||
if !os.IsNotExist(err) {
|
||||
t.Errorf("test %d: expected '%s' not to exist but got: %v", i, path, err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, name := range tc.expectExists {
|
||||
path := tc.gc.rootPath + name
|
||||
_, err := os.Stat(path)
|
||||
if os.IsNotExist(err) {
|
||||
t.Errorf("test %d: expected '%s' to exist but got: %v", i, path, err)
|
||||
}
|
||||
}
|
||||
|
||||
err := os.RemoveAll(tc.gc.rootPath)
|
||||
if err != nil {
|
||||
t.Fatalf("test %d: expected no error but got: %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGarbageCollector_StartupMessage(t *testing.T) {
|
||||
actual := new(GarbageCollector).StartupMessage()
|
||||
expect := ""
|
||||
|
||||
if actual != expect {
|
||||
t.Errorf("expected '%s' but got '%s'", expect, actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGarbageCollector_Stop(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
timeout time.Duration
|
||||
expectErr bool
|
||||
}{
|
||||
{
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
timeout: time.Duration(1) * time.Nanosecond,
|
||||
},
|
||||
} {
|
||||
func() {
|
||||
mod := new(GarbageCollector)
|
||||
mod.logger = zap.NewNop()
|
||||
|
||||
path, err := gotenberg.MkdirAll()
|
||||
if err != nil {
|
||||
t.Fatalf("test %d: expected no error but got: %v", i, err)
|
||||
}
|
||||
|
||||
mod.rootPath = path
|
||||
|
||||
err = mod.Start()
|
||||
if err != nil {
|
||||
t.Fatalf("test %d: expected no error but got: %v", i, err)
|
||||
}
|
||||
|
||||
if tc.timeout == 0 {
|
||||
err = mod.Stop(context.TODO())
|
||||
} else {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), tc.timeout)
|
||||
defer cancel()
|
||||
|
||||
err = mod.Stop(ctx)
|
||||
}
|
||||
|
||||
if tc.expectErr && err == nil {
|
||||
t.Errorf("test %d: expected error but got: %v", i, err)
|
||||
}
|
||||
|
||||
if !tc.expectErr && err != nil {
|
||||
t.Errorf("test %d: expected no error but got: %v", i, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ gotenberg.Module = (*ProtoModule)(nil)
|
||||
_ gotenberg.Validator = (*ProtoValidator)(nil)
|
||||
_ GarbageCollectorGraceDurationModifier = (*ProtoGarbageCollectorGraceDurationModifier)(nil)
|
||||
_ gotenberg.Module = (*ProtoGarbageCollectorGraceDurationModifier)(nil)
|
||||
_ gotenberg.Validator = (*ProtoGarbageCollectorGraceDurationModifier)(nil)
|
||||
_ GarbageCollectorExcludeSubstrModifier = (*ProtoGarbageCollectorExcludeSubstrModifier)(nil)
|
||||
_ gotenberg.Module = (*ProtoGarbageCollectorExcludeSubstrModifier)(nil)
|
||||
_ gotenberg.Validator = (*ProtoGarbageCollectorExcludeSubstrModifier)(nil)
|
||||
_ gotenberg.LoggerProvider = (*ProtoLoggerProvider)(nil)
|
||||
_ gotenberg.Module = (*ProtoLoggerProvider)(nil)
|
||||
)
|
||||
543
pkg/modules/libreoffice/api/api.go
Normal file
543
pkg/modules/libreoffice/api/api.go
Normal file
@@ -0,0 +1,543 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/alexliesenfeld/health"
|
||||
flag "github.com/spf13/pflag"
|
||||
"go.uber.org/multierr"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
|
||||
)
|
||||
|
||||
func init() {
|
||||
gotenberg.MustRegisterModule(new(Api))
|
||||
}
|
||||
|
||||
var (
|
||||
// ErrInvalidPdfFormats happens if the PDF formats option cannot be handled
|
||||
// by LibreOffice.
|
||||
ErrInvalidPdfFormats = errors.New("invalid PDF formats")
|
||||
|
||||
// ErrUnoException happens when unoconverter returns an exit code 5.
|
||||
ErrUnoException = errors.New("uno exception")
|
||||
|
||||
// ErrRuntimeException happens when unoconverter returns an exit code 6.
|
||||
ErrRuntimeException = errors.New("uno exception")
|
||||
|
||||
// ErrCoreDumped happens randomly; sometime a conversion will work as
|
||||
// expected, and some other time the same conversion will fail.
|
||||
// See https://github.com/gotenberg/gotenberg/issues/639.
|
||||
ErrCoreDumped = errors.New("core dumped")
|
||||
)
|
||||
|
||||
// Api is a module which provides a [Uno] to interact with LibreOffice.
|
||||
type Api struct {
|
||||
autoStart bool
|
||||
args libreOfficeArguments
|
||||
|
||||
logger *zap.Logger
|
||||
libreOffice libreOffice
|
||||
supervisor gotenberg.ProcessSupervisor
|
||||
}
|
||||
|
||||
// Options gathers available options when converting a document to PDF.
|
||||
// See: https://help.libreoffice.org/latest/en-US/text/shared/guide/pdf_params.html.
|
||||
type Options struct {
|
||||
// Password specifies the password for opening the source file.
|
||||
Password string
|
||||
|
||||
// Landscape allows to change the orientation of the resulting PDF.
|
||||
Landscape bool
|
||||
|
||||
// PageRanges allows to select the pages to convert.
|
||||
PageRanges string
|
||||
|
||||
// ExportFormFields specifies whether form fields are exported as widgets
|
||||
// or only their fixed print representation is exported.
|
||||
ExportFormFields bool
|
||||
|
||||
// AllowDuplicateFieldNames specifies whether multiple form fields exported
|
||||
// are allowed to have the same field name.
|
||||
AllowDuplicateFieldNames bool
|
||||
|
||||
// ExportBookmarks specifies if bookmarks are exported to PDF.
|
||||
ExportBookmarks bool
|
||||
|
||||
// ExportBookmarksToPdfDestination specifies that the bookmarks contained
|
||||
// in the source LibreOffice file should be exported to the PDF file as
|
||||
// Named Destination.
|
||||
ExportBookmarksToPdfDestination bool
|
||||
|
||||
// ExportPlaceholders exports the placeholders fields visual markings only.
|
||||
// The exported placeholder is ineffective.
|
||||
ExportPlaceholders bool
|
||||
|
||||
// ExportNotes specifies if notes are exported to PDF.
|
||||
ExportNotes bool
|
||||
|
||||
// ExportNotesPages specifies if notes pages are exported to PDF.
|
||||
// Notes pages are available in Impress documents only.
|
||||
ExportNotesPages bool
|
||||
|
||||
// ExportOnlyNotesPages specifies, if the property ExportNotesPages is set
|
||||
// to true, if only notes pages are exported to PDF.
|
||||
ExportOnlyNotesPages bool
|
||||
|
||||
// ExportNotesInMargin specifies if notes in margin are exported to PDF.
|
||||
ExportNotesInMargin bool
|
||||
|
||||
// ConvertOooTargetToPdfTarget specifies that the target documents with
|
||||
// .od[tpgs] extension, will have that extension changed to .pdf when the
|
||||
// link is exported to PDF. The source document remains untouched.
|
||||
ConvertOooTargetToPdfTarget bool
|
||||
|
||||
// ExportLinksRelativeFsys specifies that the file system related
|
||||
// hyperlinks (file:// protocol) present in the document will be exported
|
||||
// as relative to the source document location.
|
||||
ExportLinksRelativeFsys bool
|
||||
|
||||
// ExportHiddenSlides exports, for LibreOffice Impress, slides that are not
|
||||
// included in slide shows.
|
||||
ExportHiddenSlides bool
|
||||
|
||||
// SkipEmptyPages specifies that automatically inserted empty pages are
|
||||
// suppressed. This option is active only if storing Writer documents.
|
||||
SkipEmptyPages bool
|
||||
|
||||
// AddOriginalDocumentAsStream specifies that a stream is inserted to the
|
||||
// PDF file which contains the original document for archiving purposes.
|
||||
AddOriginalDocumentAsStream bool
|
||||
|
||||
// SinglePageSheets ignores each sheet’s paper size, print ranges and
|
||||
// shown/hidden status and puts every sheet (even hidden sheets) on exactly
|
||||
// one page.
|
||||
SinglePageSheets bool
|
||||
|
||||
// LosslessImageCompression specifies if images are exported to PDF using
|
||||
// a lossless compression format like PNG or compressed using the JPEG
|
||||
// format.
|
||||
LosslessImageCompression bool
|
||||
|
||||
// Quality specifies the quality of the JPG export. A higher value produces
|
||||
// a higher-quality image and a larger file. Between 1 and 100.
|
||||
Quality int
|
||||
|
||||
// ReduceImageResolution specifies if the resolution of each image is
|
||||
// reduced to the resolution specified by the property MaxImageResolution.
|
||||
ReduceImageResolution bool
|
||||
|
||||
// MaxImageResolution, if the property ReduceImageResolution is set to
|
||||
// true, tells if all images will be reduced to the given value in DPI.
|
||||
// Possible values are: 75, 150, 300, 600 and 1200.
|
||||
MaxImageResolution int
|
||||
|
||||
// PdfFormats allows to convert the resulting PDF to PDF/A-1b, PDF/A-2b,
|
||||
// PDF/A-3b and PDF/UA.
|
||||
PdfFormats gotenberg.PdfFormats
|
||||
}
|
||||
|
||||
// DefaultOptions returns the default values for Options.
|
||||
func DefaultOptions() Options {
|
||||
return Options{
|
||||
Password: "",
|
||||
Landscape: false,
|
||||
PageRanges: "",
|
||||
ExportFormFields: true,
|
||||
AllowDuplicateFieldNames: false,
|
||||
ExportBookmarks: true,
|
||||
ExportBookmarksToPdfDestination: false,
|
||||
ExportPlaceholders: false,
|
||||
ExportNotes: false,
|
||||
ExportNotesPages: false,
|
||||
ExportOnlyNotesPages: false,
|
||||
ExportNotesInMargin: false,
|
||||
ConvertOooTargetToPdfTarget: false,
|
||||
ExportLinksRelativeFsys: false,
|
||||
ExportHiddenSlides: false,
|
||||
SkipEmptyPages: false,
|
||||
AddOriginalDocumentAsStream: false,
|
||||
SinglePageSheets: false,
|
||||
LosslessImageCompression: false,
|
||||
Quality: 90,
|
||||
ReduceImageResolution: false,
|
||||
MaxImageResolution: 300,
|
||||
PdfFormats: gotenberg.PdfFormats{
|
||||
PdfA: "",
|
||||
PdfUa: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Uno is an abstraction on top of the Universal Network Objects API.
|
||||
type Uno interface {
|
||||
Pdf(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error
|
||||
Extensions() []string
|
||||
}
|
||||
|
||||
// Provider is a module interface which exposes a method for creating a
|
||||
// [Uno] for other modules.
|
||||
//
|
||||
// func (m *YourModule) Provision(ctx *gotenberg.Context) error {
|
||||
// provider, _ := ctx.Module(new(libreofficeapi.Provider))
|
||||
// libreOffice, _ := provider.(api.Provider).LibreOffice()
|
||||
// }
|
||||
type Provider interface {
|
||||
LibreOffice() (Uno, error)
|
||||
}
|
||||
|
||||
// Descriptor returns a [Api]'s module descriptor.
|
||||
func (a *Api) Descriptor() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{
|
||||
ID: "libreoffice-api",
|
||||
FlagSet: func() *flag.FlagSet {
|
||||
fs := flag.NewFlagSet("api", flag.ExitOnError)
|
||||
fs.Int64("libreoffice-restart-after", 10, "Number of conversions after which LibreOffice will automatically restart. Set to 0 to disable this feature")
|
||||
fs.Int64("libreoffice-max-queue-size", 0, "Maximum request queue size for LibreOffice. Set to 0 to disable this feature")
|
||||
fs.Bool("libreoffice-auto-start", false, "Automatically launch LibreOffice upon initialization if set to true; otherwise, LibreOffice will start at the time of the first conversion")
|
||||
fs.Duration("libreoffice-start-timeout", time.Duration(20)*time.Second, "Maximum duration to wait for LibreOffice to start or restart")
|
||||
|
||||
return fs
|
||||
}(),
|
||||
New: func() gotenberg.Module { return new(Api) },
|
||||
}
|
||||
}
|
||||
|
||||
// Provision sets the module properties.
|
||||
func (a *Api) Provision(ctx *gotenberg.Context) error {
|
||||
flags := ctx.ParsedFlags()
|
||||
a.autoStart = flags.MustBool("libreoffice-auto-start")
|
||||
|
||||
libreOfficeBinPath, ok := os.LookupEnv("LIBREOFFICE_BIN_PATH")
|
||||
if !ok {
|
||||
return errors.New("LIBREOFFICE_BIN_PATH environment variable is not set")
|
||||
}
|
||||
|
||||
unoBinPath, ok := os.LookupEnv("UNOCONVERTER_BIN_PATH")
|
||||
if !ok {
|
||||
return errors.New("UNOCONVERTER_BIN_PATH environment variable is not set")
|
||||
}
|
||||
|
||||
a.args = libreOfficeArguments{
|
||||
binPath: libreOfficeBinPath,
|
||||
unoBinPath: unoBinPath,
|
||||
startTimeout: flags.MustDuration("libreoffice-start-timeout"),
|
||||
}
|
||||
|
||||
// Logger.
|
||||
loggerProvider, err := ctx.Module(new(gotenberg.LoggerProvider))
|
||||
if err != nil {
|
||||
return fmt.Errorf("get logger provider: %w", err)
|
||||
}
|
||||
logger, err := loggerProvider.(gotenberg.LoggerProvider).Logger(a)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get logger: %w", err)
|
||||
}
|
||||
a.logger = logger.Named("libreoffice")
|
||||
|
||||
// Process.
|
||||
a.libreOffice = newLibreOfficeProcess(a.args)
|
||||
a.supervisor = gotenberg.NewProcessSupervisor(a.logger, a.libreOffice, flags.MustInt64("libreoffice-restart-after"), flags.MustInt64("libreoffice-max-queue-size"))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate validates the module properties.
|
||||
func (a *Api) Validate() error {
|
||||
var err error
|
||||
|
||||
_, statErr := os.Stat(a.args.binPath)
|
||||
if os.IsNotExist(statErr) {
|
||||
err = multierr.Append(err, fmt.Errorf("LibreOffice binary path does not exist: %w", statErr))
|
||||
}
|
||||
|
||||
_, statErr = os.Stat(a.args.unoBinPath)
|
||||
if os.IsNotExist(statErr) {
|
||||
err = multierr.Append(err, fmt.Errorf("unoconverter binary path does not exist: %w", statErr))
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// Start does nothing if auto-start is not enabled. Otherwise, it starts a
|
||||
// LibreOffice instance.
|
||||
func (a *Api) Start() error {
|
||||
if !a.autoStart {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := a.supervisor.Launch()
|
||||
if err != nil {
|
||||
return fmt.Errorf("launch supervisor: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// StartupMessage returns a custom startup message.
|
||||
func (a *Api) StartupMessage() string {
|
||||
if !a.autoStart {
|
||||
return "LibreOffice ready to start"
|
||||
}
|
||||
|
||||
return "LibreOffice automatically started"
|
||||
}
|
||||
|
||||
// Stop stops the current browser instance.
|
||||
func (a *Api) Stop(ctx context.Context) error {
|
||||
// Block until the context is done so that other module may gracefully stop
|
||||
// before we do a shutdown.
|
||||
a.logger.Debug("wait for the end of grace duration")
|
||||
|
||||
<-ctx.Done()
|
||||
|
||||
err := a.supervisor.Shutdown()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("stop LibreOffice: %w", err)
|
||||
}
|
||||
|
||||
// Metrics returns the metrics.
|
||||
func (a *Api) Metrics() ([]gotenberg.Metric, error) {
|
||||
return []gotenberg.Metric{
|
||||
{
|
||||
Name: "libreoffice_requests_queue_size",
|
||||
Description: "Current number of LibreOffice conversion requests waiting to be treated.",
|
||||
Read: func() float64 {
|
||||
return float64(a.supervisor.ReqQueueSize())
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "libreoffice_restarts_count",
|
||||
Description: "Current number of LibreOffice restarts.",
|
||||
Read: func() float64 {
|
||||
return float64(a.supervisor.RestartsCount())
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Checks adds a health check that verifies if LibreOffice is healthy.
|
||||
func (a *Api) Checks() ([]health.CheckerOption, error) {
|
||||
return []health.CheckerOption{
|
||||
health.WithCheck(health.Check{
|
||||
Name: "libreoffice",
|
||||
Check: func(_ context.Context) error {
|
||||
if a.supervisor.Healthy() {
|
||||
return nil
|
||||
}
|
||||
|
||||
return errors.New("LibreOffice is unhealthy")
|
||||
},
|
||||
}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Ready returns no error if the module is ready.
|
||||
func (a *Api) Ready() error {
|
||||
if !a.autoStart {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), a.args.startTimeout)
|
||||
defer cancel()
|
||||
|
||||
ticker := time.NewTicker(time.Duration(100) * time.Millisecond)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
ticker.Stop()
|
||||
return fmt.Errorf("context done while waiting for LibreOffice to be ready: %w", ctx.Err())
|
||||
case <-ticker.C:
|
||||
ok := a.libreOffice.Healthy(a.logger)
|
||||
if ok {
|
||||
ticker.Stop()
|
||||
return nil
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// LibreOffice returns a [Uno] for interacting with LibreOffice.
|
||||
func (a *Api) LibreOffice() (Uno, error) {
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// Pdf converts a document to PDF.
|
||||
func (a *Api) Pdf(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error {
|
||||
err := a.supervisor.Run(ctx, logger, func() error {
|
||||
return a.libreOffice.pdf(ctx, logger, inputPath, outputPath, options)
|
||||
})
|
||||
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// See https://github.com/gotenberg/gotenberg/issues/639.
|
||||
if errors.Is(err, ErrCoreDumped) {
|
||||
logger.Debug(fmt.Sprintf("got a '%s' error, retry conversion", err))
|
||||
return a.Pdf(ctx, logger, inputPath, outputPath, options)
|
||||
}
|
||||
|
||||
return fmt.Errorf("supervisor run task: %w", err)
|
||||
}
|
||||
|
||||
// Extensions returns the file extensions available for conversions.
|
||||
// FIXME: don't care, take all on the route level?
|
||||
func (a *Api) Extensions() []string {
|
||||
return []string{
|
||||
".123",
|
||||
".602",
|
||||
".abw",
|
||||
".bib",
|
||||
".bmp",
|
||||
".cdr",
|
||||
".cgm",
|
||||
".cmx",
|
||||
".csv",
|
||||
".cwk",
|
||||
".dbf",
|
||||
".dif",
|
||||
".doc",
|
||||
".docm",
|
||||
".docx",
|
||||
".dot",
|
||||
".dotm",
|
||||
".dotx",
|
||||
".dxf",
|
||||
".emf",
|
||||
".eps",
|
||||
".epub",
|
||||
".fodg",
|
||||
".fodp",
|
||||
".fods",
|
||||
".fodt",
|
||||
".fopd",
|
||||
".gif",
|
||||
".htm",
|
||||
".html",
|
||||
".hwp",
|
||||
".jpeg",
|
||||
".jpg",
|
||||
".key",
|
||||
".ltx",
|
||||
".lwp",
|
||||
".mcw",
|
||||
".met",
|
||||
".mml",
|
||||
".mw",
|
||||
".numbers",
|
||||
".odd",
|
||||
".odg",
|
||||
".odm",
|
||||
".odp",
|
||||
".ods",
|
||||
".odt",
|
||||
".otg",
|
||||
".oth",
|
||||
".otp",
|
||||
".ots",
|
||||
".ott",
|
||||
".pages",
|
||||
".pbm",
|
||||
".pcd",
|
||||
".pct",
|
||||
".pcx",
|
||||
".pdb",
|
||||
".pdf",
|
||||
".pgm",
|
||||
".png",
|
||||
".pot",
|
||||
".potm",
|
||||
".potx",
|
||||
".ppm",
|
||||
".pps",
|
||||
".ppt",
|
||||
".pptm",
|
||||
".pptx",
|
||||
".psd",
|
||||
".psw",
|
||||
".pub",
|
||||
".pwp",
|
||||
".pxl",
|
||||
".ras",
|
||||
".rtf",
|
||||
".sda",
|
||||
".sdc",
|
||||
".sdd",
|
||||
".sdp",
|
||||
".sdw",
|
||||
".sgl",
|
||||
".slk",
|
||||
".smf",
|
||||
".stc",
|
||||
".std",
|
||||
".sti",
|
||||
".stw",
|
||||
".svg",
|
||||
".svm",
|
||||
".swf",
|
||||
".sxc",
|
||||
".sxd",
|
||||
".sxg",
|
||||
".sxi",
|
||||
".sxm",
|
||||
".sxw",
|
||||
".tga",
|
||||
".tif",
|
||||
".tiff",
|
||||
".txt",
|
||||
".uof",
|
||||
".uop",
|
||||
".uos",
|
||||
".uot",
|
||||
".vdx",
|
||||
".vor",
|
||||
".vsd",
|
||||
".vsdm",
|
||||
".vsdx",
|
||||
".wb2",
|
||||
".wk1",
|
||||
".wks",
|
||||
".wmf",
|
||||
".wpd",
|
||||
".wpg",
|
||||
".wps",
|
||||
".xbm",
|
||||
".xhtml",
|
||||
".xls",
|
||||
".xlsb",
|
||||
".xlsm",
|
||||
".xlsx",
|
||||
".xlt",
|
||||
".xltm",
|
||||
".xltx",
|
||||
".xlw",
|
||||
".xml",
|
||||
".xpm",
|
||||
".zabw",
|
||||
}
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ gotenberg.Module = (*Api)(nil)
|
||||
_ gotenberg.Provisioner = (*Api)(nil)
|
||||
_ gotenberg.Validator = (*Api)(nil)
|
||||
_ gotenberg.App = (*Api)(nil)
|
||||
_ gotenberg.MetricsProvider = (*Api)(nil)
|
||||
_ api.HealthChecker = (*Api)(nil)
|
||||
_ Uno = (*Api)(nil)
|
||||
_ Provider = (*Api)(nil)
|
||||
)
|
||||
474
pkg/modules/libreoffice/api/api_test.go
Normal file
474
pkg/modules/libreoffice/api/api_test.go
Normal file
@@ -0,0 +1,474 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alexliesenfeld/health"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
|
||||
func TestDefaultOptions(t *testing.T) {
|
||||
actual := DefaultOptions()
|
||||
notExpect := Options{}
|
||||
|
||||
if reflect.DeepEqual(actual, notExpect) {
|
||||
t.Errorf("expected %v and got identical %v", actual, notExpect)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApi_Descriptor(t *testing.T) {
|
||||
descriptor := new(Api).Descriptor()
|
||||
|
||||
actual := reflect.TypeOf(descriptor.New())
|
||||
expect := reflect.TypeOf(new(Api))
|
||||
|
||||
if actual != expect {
|
||||
t.Errorf("expected '%s' but got '%s'", expect, actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApi_Provision(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
ctx *gotenberg.Context
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
scenario: "no logger provider",
|
||||
ctx: func() *gotenberg.Context {
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: new(Api).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{},
|
||||
)
|
||||
}(),
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "no logger from logger provider",
|
||||
ctx: func() *gotenberg.Context {
|
||||
mod := &struct {
|
||||
gotenberg.ModuleMock
|
||||
gotenberg.LoggerProviderMock
|
||||
}{}
|
||||
mod.DescriptorMock = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }}
|
||||
}
|
||||
mod.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) {
|
||||
return nil, errors.New("foo")
|
||||
}
|
||||
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: new(Api).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{
|
||||
mod.Descriptor(),
|
||||
},
|
||||
)
|
||||
}(),
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "provision success",
|
||||
ctx: func() *gotenberg.Context {
|
||||
mod := &struct {
|
||||
gotenberg.ModuleMock
|
||||
gotenberg.LoggerProviderMock
|
||||
}{}
|
||||
mod.DescriptorMock = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }}
|
||||
}
|
||||
mod.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) {
|
||||
return zap.NewNop(), nil
|
||||
}
|
||||
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: new(Api).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{
|
||||
mod.Descriptor(),
|
||||
},
|
||||
)
|
||||
}(),
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
a := new(Api)
|
||||
err := a.Provision(tc.ctx)
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApi_Validate(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
binPath string
|
||||
unoBinPath string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
scenario: "empty LibreOffice bin path",
|
||||
binPath: "",
|
||||
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "LibreOffice bin path does not exist",
|
||||
binPath: "/foo",
|
||||
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "empty uno bin path",
|
||||
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
|
||||
unoBinPath: "",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "uno bin path does not exist",
|
||||
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
|
||||
unoBinPath: "/foo",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "validate success",
|
||||
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
|
||||
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
|
||||
expectError: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
a := new(Api)
|
||||
a.args = libreOfficeArguments{
|
||||
binPath: tc.binPath,
|
||||
unoBinPath: tc.unoBinPath,
|
||||
}
|
||||
err := a.Validate()
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApi_Start(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
autoStart bool
|
||||
supervisor *gotenberg.ProcessSupervisorMock
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
scenario: "no auto-start",
|
||||
autoStart: false,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "auto-start success",
|
||||
autoStart: true,
|
||||
supervisor: &gotenberg.ProcessSupervisorMock{LaunchMock: func() error {
|
||||
return nil
|
||||
}},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "auto-start failed",
|
||||
autoStart: true,
|
||||
supervisor: &gotenberg.ProcessSupervisorMock{LaunchMock: func() error {
|
||||
return errors.New("foo")
|
||||
}},
|
||||
expectError: true,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
a := new(Api)
|
||||
a.autoStart = tc.autoStart
|
||||
a.supervisor = tc.supervisor
|
||||
|
||||
err := a.Start()
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApi_StartupMessage(t *testing.T) {
|
||||
a := new(Api)
|
||||
|
||||
a.autoStart = true
|
||||
autoStartMsg := a.StartupMessage()
|
||||
|
||||
a.autoStart = false
|
||||
noAutoStartMsg := a.StartupMessage()
|
||||
|
||||
if autoStartMsg == noAutoStartMsg {
|
||||
t.Errorf("expected differrent startup messages based on auto start, but got '%s'", autoStartMsg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApi_Stop(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
supervisor *gotenberg.ProcessSupervisorMock
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
scenario: "stop success",
|
||||
supervisor: &gotenberg.ProcessSupervisorMock{ShutdownMock: func() error {
|
||||
return nil
|
||||
}},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "stop failed",
|
||||
supervisor: &gotenberg.ProcessSupervisorMock{ShutdownMock: func() error {
|
||||
return errors.New("foo")
|
||||
}},
|
||||
expectError: true,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
a := new(Api)
|
||||
a.logger = zap.NewNop()
|
||||
a.supervisor = tc.supervisor
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 0*time.Second)
|
||||
cancel()
|
||||
|
||||
err := a.Stop(ctx)
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApi_Metrics(t *testing.T) {
|
||||
a := new(Api)
|
||||
a.supervisor = &gotenberg.ProcessSupervisorMock{
|
||||
ReqQueueSizeMock: func() int64 {
|
||||
return 10
|
||||
},
|
||||
RestartsCountMock: func() int64 {
|
||||
return 0
|
||||
},
|
||||
}
|
||||
|
||||
metrics, err := a.Metrics()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if len(metrics) != 2 {
|
||||
t.Fatalf("expected %d metrics, but got %d", 2, len(metrics))
|
||||
}
|
||||
|
||||
actual := metrics[0].Read()
|
||||
if actual != float64(10) {
|
||||
t.Errorf("expected %f for libreoffice_requests_queue_size, but got %f", float64(10), actual)
|
||||
}
|
||||
|
||||
actual = metrics[1].Read()
|
||||
if actual != float64(0) {
|
||||
t.Errorf("expected %f for libreoffice_restarts_count, but got %f", float64(0), actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApi_Checks(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
supervisor gotenberg.ProcessSupervisor
|
||||
expectAvailabilityStatus health.AvailabilityStatus
|
||||
}{
|
||||
{
|
||||
scenario: "healthy module",
|
||||
supervisor: &gotenberg.ProcessSupervisorMock{HealthyMock: func() bool {
|
||||
return true
|
||||
}},
|
||||
expectAvailabilityStatus: health.StatusUp,
|
||||
},
|
||||
{
|
||||
scenario: "unhealthy module",
|
||||
supervisor: &gotenberg.ProcessSupervisorMock{HealthyMock: func() bool {
|
||||
return false
|
||||
}},
|
||||
expectAvailabilityStatus: health.StatusDown,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
a := new(Api)
|
||||
a.supervisor = tc.supervisor
|
||||
|
||||
checks, err := a.Checks()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
checker := health.NewChecker(checks...)
|
||||
result := checker.Check(context.Background())
|
||||
|
||||
if result.Status != tc.expectAvailabilityStatus {
|
||||
t.Errorf("expected '%s' as availability status, but got '%s'", tc.expectAvailabilityStatus, result.Status)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChromium_Ready(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
autoStart bool
|
||||
startTimeout time.Duration
|
||||
libreOffice libreOffice
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
scenario: "no auto-start",
|
||||
autoStart: false,
|
||||
startTimeout: time.Duration(30) * time.Second,
|
||||
libreOffice: &libreOfficeMock{ProcessMock: gotenberg.ProcessMock{HealthyMock: func(logger *zap.Logger) bool {
|
||||
return false
|
||||
}}},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "auto-start: context done",
|
||||
autoStart: true,
|
||||
startTimeout: time.Duration(200) * time.Millisecond,
|
||||
libreOffice: &libreOfficeMock{ProcessMock: gotenberg.ProcessMock{HealthyMock: func(logger *zap.Logger) bool {
|
||||
return false
|
||||
}}},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "auto-start success",
|
||||
autoStart: true,
|
||||
startTimeout: time.Duration(30) * time.Second,
|
||||
libreOffice: &libreOfficeMock{ProcessMock: gotenberg.ProcessMock{HealthyMock: func(logger *zap.Logger) bool {
|
||||
return true
|
||||
}}},
|
||||
expectError: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
a := new(Api)
|
||||
a.autoStart = tc.autoStart
|
||||
a.args = libreOfficeArguments{startTimeout: tc.startTimeout}
|
||||
a.libreOffice = tc.libreOffice
|
||||
|
||||
err := a.Ready()
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApi_LibreOffice(t *testing.T) {
|
||||
a := new(Api)
|
||||
|
||||
_, err := a.LibreOffice()
|
||||
if err != nil {
|
||||
t.Errorf("expected no error but got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApi_Pdf(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
supervisor gotenberg.ProcessSupervisor
|
||||
libreOffice libreOffice
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
scenario: "PDF task success",
|
||||
libreOffice: &libreOfficeMock{pdfMock: func(ctx context.Context, logger *zap.Logger, input, outputPath string, options Options) error {
|
||||
return nil
|
||||
}},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "PDF task error",
|
||||
libreOffice: &libreOfficeMock{pdfMock: func(ctx context.Context, logger *zap.Logger, input, outputPath string, options Options) error {
|
||||
return errors.New("PDF task error")
|
||||
}},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "ErrCoreDumped",
|
||||
libreOffice: &libreOfficeMock{pdfMock: func(ctx context.Context, logger *zap.Logger, input, outputPath string, options Options) error {
|
||||
return ErrCoreDumped
|
||||
}},
|
||||
expectError: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
a := new(Api)
|
||||
a.supervisor = &gotenberg.ProcessSupervisorMock{RunMock: func(ctx context.Context, logger *zap.Logger, task func() error) error {
|
||||
return task()
|
||||
}}
|
||||
a.libreOffice = tc.libreOffice
|
||||
|
||||
err := a.Pdf(context.Background(), zap.NewNop(), "", "", Options{})
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApi_Extensions(t *testing.T) {
|
||||
a := new(Api)
|
||||
extensions := a.Extensions()
|
||||
|
||||
actual := len(extensions)
|
||||
expect := 130
|
||||
|
||||
if actual != expect {
|
||||
t.Errorf("expected %d extensions, but got %d", expect, actual)
|
||||
}
|
||||
}
|
||||
3
pkg/modules/libreoffice/api/doc.go
Normal file
3
pkg/modules/libreoffice/api/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package api provides a module which manages a LibreOffice instance and
|
||||
// interacts with it via the UNO (Universal Network Objects) API.
|
||||
package api
|
||||
@@ -1,4 +1,4 @@
|
||||
package uno
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
434
pkg/modules/libreoffice/api/libreoffice.go
Normal file
434
pkg/modules/libreoffice/api/libreoffice.go
Normal file
@@ -0,0 +1,434 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
|
||||
type libreOffice interface {
|
||||
gotenberg.Process
|
||||
pdf(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error
|
||||
}
|
||||
|
||||
type libreOfficeArguments struct {
|
||||
binPath string
|
||||
unoBinPath string
|
||||
startTimeout time.Duration
|
||||
}
|
||||
|
||||
type libreOfficeProcess struct {
|
||||
socketPort int
|
||||
userProfileDirPath string
|
||||
cmd *gotenberg.Cmd
|
||||
cfgMu sync.RWMutex
|
||||
isStarted atomic.Bool
|
||||
|
||||
arguments libreOfficeArguments
|
||||
fs *gotenberg.FileSystem
|
||||
}
|
||||
|
||||
func newLibreOfficeProcess(arguments libreOfficeArguments) libreOffice {
|
||||
p := &libreOfficeProcess{
|
||||
arguments: arguments,
|
||||
fs: gotenberg.NewFileSystem(),
|
||||
}
|
||||
p.isStarted.Store(false)
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *libreOfficeProcess) Start(logger *zap.Logger) error {
|
||||
if p.isStarted.Load() {
|
||||
return errors.New("LibreOffice is already started")
|
||||
}
|
||||
|
||||
port, err := freePort(logger)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get free port: %w", err)
|
||||
}
|
||||
|
||||
userProfileDirPath := p.fs.NewDirPath()
|
||||
args := []string{
|
||||
"--headless",
|
||||
"--invisible",
|
||||
"--nocrashreport",
|
||||
"--nodefault",
|
||||
"--nologo",
|
||||
"--nofirststartwizard",
|
||||
"--norestore",
|
||||
fmt.Sprintf("-env:UserInstallation=file://%s", userProfileDirPath),
|
||||
fmt.Sprintf("--accept=socket,host=127.0.0.1,port=%d,tcpNoDelay=1;urp;StarOffice.ComponentContext", port),
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), p.arguments.startTimeout)
|
||||
defer cancel()
|
||||
|
||||
cmd, err := gotenberg.CommandContext(ctx, logger, p.arguments.binPath, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create LibreOffice command: %w", err)
|
||||
}
|
||||
|
||||
// For whatever reason, LibreOffice requires a first start before being
|
||||
// able to run as a daemon.
|
||||
exitCode, err := cmd.Exec()
|
||||
if err != nil && exitCode != 81 {
|
||||
return fmt.Errorf("execute LibreOffice: %w", err)
|
||||
}
|
||||
|
||||
logger.Debug("got exit code 81, e.g., LibreOffice first start")
|
||||
|
||||
// Second start (daemon).
|
||||
cmd = gotenberg.Command(logger, p.arguments.binPath, args...)
|
||||
|
||||
err = cmd.Start()
|
||||
if err != nil {
|
||||
return fmt.Errorf("start LibreOffice: %w", err)
|
||||
}
|
||||
|
||||
waitChan := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
// By waiting the process, we avoid the creation of a zombie process
|
||||
// and make sure we catch an early exit if any.
|
||||
waitChan <- cmd.Wait()
|
||||
}()
|
||||
|
||||
connChan := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
// As the LibreOffice socket may take some time to be available, we
|
||||
// have to ensure that it is indeed accepting connections.
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
connChan <- ctx.Err()
|
||||
break
|
||||
}
|
||||
|
||||
conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), time.Duration(1)*time.Second)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
connChan <- nil
|
||||
err = conn.Close()
|
||||
if err != nil {
|
||||
logger.Debug(fmt.Sprintf("close connection after health checking the LibreOffice: %v", err))
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}()
|
||||
|
||||
var success bool
|
||||
|
||||
defer func() {
|
||||
if success {
|
||||
p.cfgMu.Lock()
|
||||
defer p.cfgMu.Unlock()
|
||||
|
||||
p.socketPort = port
|
||||
p.userProfileDirPath = userProfileDirPath
|
||||
p.cmd = cmd
|
||||
p.isStarted.Store(true)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Let's make sure the process is killed.
|
||||
err = cmd.Kill()
|
||||
if err != nil {
|
||||
logger.Debug(fmt.Sprintf("kill LibreOffice process: %v", err))
|
||||
}
|
||||
|
||||
// And the user profile directory is deleted.
|
||||
err = os.RemoveAll(userProfileDirPath)
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("remove LibreOffice's user profile directory: %v", err))
|
||||
}
|
||||
|
||||
logger.Debug(fmt.Sprintf("'%s' LibreOffice's user profile directory removed", userProfileDirPath))
|
||||
}()
|
||||
|
||||
logger.Debug("waiting for the LibreOffice socket to be available...")
|
||||
|
||||
for {
|
||||
select {
|
||||
case err = <-connChan:
|
||||
if err != nil {
|
||||
return fmt.Errorf("LibreOffice socket not available: %w", err)
|
||||
}
|
||||
|
||||
logger.Debug("LibreOffice socket available")
|
||||
success = true
|
||||
|
||||
return nil
|
||||
case err = <-waitChan:
|
||||
return fmt.Errorf("LibreOffice process exited: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *libreOfficeProcess) Stop(logger *zap.Logger) error {
|
||||
if !p.isStarted.Load() {
|
||||
// No big deal? Like calling cancel twice.
|
||||
return nil
|
||||
}
|
||||
|
||||
// Always remove the user profile directory created by LibreOffice.
|
||||
copyUserProfileDirPath := p.userProfileDirPath
|
||||
defer func(userProfileDirPath string) {
|
||||
go func() {
|
||||
err := os.RemoveAll(userProfileDirPath)
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("remove LibreOffice's user profile directory: %v", err))
|
||||
}
|
||||
|
||||
logger.Debug(fmt.Sprintf("'%s' LibreOffice's user profile directory removed", userProfileDirPath))
|
||||
|
||||
// Also remove LibreOffice specific files in the temporary directory.
|
||||
err = gotenberg.GarbageCollect(logger, os.TempDir(), []string{"OSL_PIPE", ".tmp"})
|
||||
if err != nil {
|
||||
logger.Error(err.Error())
|
||||
}
|
||||
}()
|
||||
}(copyUserProfileDirPath)
|
||||
|
||||
p.cfgMu.Lock()
|
||||
defer p.cfgMu.Unlock()
|
||||
|
||||
err := p.cmd.Kill()
|
||||
if err != nil {
|
||||
return fmt.Errorf("kill LibreOffice process: %w", err)
|
||||
}
|
||||
|
||||
p.socketPort = 0
|
||||
p.userProfileDirPath = ""
|
||||
p.cmd = nil
|
||||
p.isStarted.Store(false)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *libreOfficeProcess) Healthy(logger *zap.Logger) bool {
|
||||
// Good to know: the supervisor does not call this method if no first start
|
||||
// or if the process is restarting.
|
||||
|
||||
if !p.isStarted.Load() {
|
||||
// Non-started browser but not restarting?
|
||||
return false
|
||||
}
|
||||
|
||||
p.cfgMu.RLock()
|
||||
defer p.cfgMu.RUnlock()
|
||||
|
||||
conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", p.socketPort), time.Duration(10)*time.Second)
|
||||
if err == nil {
|
||||
err = conn.Close()
|
||||
if err != nil {
|
||||
logger.Debug(fmt.Sprintf("close connection after health checking LibreOffice: %v", err))
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (p *libreOfficeProcess) pdf(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error {
|
||||
if !p.isStarted.Load() {
|
||||
return errors.New("LibreOffice not started, cannot handle PDF conversion")
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"--no-launch",
|
||||
"--format",
|
||||
"pdf",
|
||||
}
|
||||
|
||||
args = append(args, "--port", fmt.Sprintf("%d", p.socketPort))
|
||||
|
||||
checkedEntry := logger.Check(zap.DebugLevel, "check for debug level before setting high verbosity")
|
||||
if checkedEntry != nil {
|
||||
args = append(args, "-vvv")
|
||||
}
|
||||
|
||||
if options.Password != "" {
|
||||
args = append(args, "--password", options.Password)
|
||||
}
|
||||
|
||||
if options.Landscape {
|
||||
args = append(args, "--printer", "PaperOrientation=landscape")
|
||||
}
|
||||
|
||||
if options.PageRanges != "" {
|
||||
args = append(args, "--export", fmt.Sprintf("PageRange=%s", options.PageRanges))
|
||||
}
|
||||
|
||||
args = append(args, "--export", fmt.Sprintf("ExportFormFields=%t", options.ExportFormFields))
|
||||
args = append(args, "--export", fmt.Sprintf("AllowDuplicateFieldNames=%t", options.AllowDuplicateFieldNames))
|
||||
args = append(args, "--export", fmt.Sprintf("ExportBookmarks=%t", options.ExportBookmarks))
|
||||
args = append(args, "--export", fmt.Sprintf("ExportBookmarks=%t", options.ExportBookmarks))
|
||||
args = append(args, "--export", fmt.Sprintf("ExportBookmarksToPDFDestination=%t", options.ExportBookmarksToPdfDestination))
|
||||
args = append(args, "--export", fmt.Sprintf("ExportPlaceholders=%t", options.ExportPlaceholders))
|
||||
args = append(args, "--export", fmt.Sprintf("ExportNotes=%t", options.ExportNotes))
|
||||
args = append(args, "--export", fmt.Sprintf("ExportNotesPages=%t", options.ExportNotesPages))
|
||||
args = append(args, "--export", fmt.Sprintf("ExportOnlyNotesPages=%t", options.ExportOnlyNotesPages))
|
||||
args = append(args, "--export", fmt.Sprintf("ExportNotesInMargin=%t", options.ExportNotesInMargin))
|
||||
args = append(args, "--export", fmt.Sprintf("ConvertOOoTargetToPDFTarget=%t", options.ConvertOooTargetToPdfTarget))
|
||||
args = append(args, "--export", fmt.Sprintf("ExportLinksRelativeFsys=%t", options.ExportLinksRelativeFsys))
|
||||
args = append(args, "--export", fmt.Sprintf("ExportHiddenSlides=%t", options.ExportHiddenSlides))
|
||||
args = append(args, "--export", fmt.Sprintf("IsSkipEmptyPages=%t", options.SkipEmptyPages))
|
||||
args = append(args, "--export", fmt.Sprintf("IsAddStream=%t", options.AddOriginalDocumentAsStream))
|
||||
args = append(args, "--export", fmt.Sprintf("SinglePageSheets=%t", options.SinglePageSheets))
|
||||
args = append(args, "--export", fmt.Sprintf("UseLosslessCompression=%t", options.LosslessImageCompression))
|
||||
args = append(args, "--export", fmt.Sprintf("Quality=%d", options.Quality))
|
||||
args = append(args, "--export", fmt.Sprintf("ReduceImageResolution=%t", options.ReduceImageResolution))
|
||||
args = append(args, "--export", fmt.Sprintf("MaxImageResolution=%d", options.MaxImageResolution))
|
||||
|
||||
switch options.PdfFormats.PdfA {
|
||||
case "":
|
||||
case gotenberg.PdfA1b:
|
||||
args = append(args, "--export", "SelectPdfVersion=1")
|
||||
case gotenberg.PdfA2b:
|
||||
args = append(args, "--export", "SelectPdfVersion=2")
|
||||
case gotenberg.PdfA3b:
|
||||
args = append(args, "--export", "SelectPdfVersion=3")
|
||||
default:
|
||||
return ErrInvalidPdfFormats
|
||||
}
|
||||
|
||||
if options.PdfFormats.PdfUa {
|
||||
args = append(
|
||||
args,
|
||||
"--export", "PDFUACompliance=true",
|
||||
"--export", "UseTaggedPDF=true",
|
||||
"--export", "EnableTextAccessForAccessibilityTools=true",
|
||||
)
|
||||
} else {
|
||||
args = append(
|
||||
args,
|
||||
"--export", "PDFUACompliance=false",
|
||||
"--export", "UseTaggedPDF=false",
|
||||
"--export", "EnableTextAccessForAccessibilityTools=false",
|
||||
)
|
||||
}
|
||||
|
||||
inputPath, err := nonBasicLatinCharactersGuard(logger, inputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("non-basic latin characters guard: %w", err)
|
||||
}
|
||||
|
||||
args = append(args, "--output", outputPath, inputPath)
|
||||
|
||||
cmd, err := gotenberg.CommandContext(ctx, logger, p.arguments.unoBinPath, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create uno command: %w", err)
|
||||
}
|
||||
|
||||
logger.Debug(fmt.Sprintf("print to PDF with: %+v", options))
|
||||
|
||||
exitCode, err := cmd.Exec()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// LibreOffice's errors are not explicit.
|
||||
// For instance, an exit code 5 may be explained by a malformed page
|
||||
// ranges, but also by a not required password.
|
||||
|
||||
// We may want to retry in case of a core dumped event.
|
||||
// See https://github.com/gotenberg/gotenberg/issues/639.
|
||||
if strings.Contains(err.Error(), "core dumped") {
|
||||
return ErrCoreDumped
|
||||
}
|
||||
|
||||
if exitCode == 5 {
|
||||
// Potentially malformed page ranges or password not required.
|
||||
return ErrUnoException
|
||||
}
|
||||
if exitCode == 6 {
|
||||
// Password potentially required or invalid.
|
||||
return ErrRuntimeException
|
||||
}
|
||||
|
||||
return fmt.Errorf("convert to PDF: %w", err)
|
||||
}
|
||||
|
||||
// LibreOffice cannot convert a file with a name containing non-basic Latin
|
||||
// characters.
|
||||
// See:
|
||||
// https://github.com/gotenberg/gotenberg/issues/104
|
||||
// https://github.com/gotenberg/gotenberg/issues/730
|
||||
func nonBasicLatinCharactersGuard(logger *zap.Logger, inputPath string) (string, error) {
|
||||
hasNonBasicLatinChars := func(str string) bool {
|
||||
for _, r := range str {
|
||||
// Check if the character is outside basic Latin.
|
||||
if r != '.' && (r < ' ' || r > '~') {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
filename := filepath.Base(inputPath)
|
||||
if !hasNonBasicLatinChars(filename) {
|
||||
logger.Debug("no non-basic latin characters in filename, skip copy")
|
||||
return inputPath, nil
|
||||
}
|
||||
|
||||
logger.Warn("non-basic latin characters in filename, copy to a file with a valid filename")
|
||||
basePath := filepath.Dir(inputPath)
|
||||
ext := filepath.Ext(inputPath)
|
||||
newInputPath := filepath.Join(basePath, fmt.Sprintf("%s%s", uuid.NewString(), ext))
|
||||
|
||||
in, err := os.Open(inputPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("open file: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err := in.Close()
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("close file: %s", err))
|
||||
}
|
||||
}()
|
||||
|
||||
out, err := os.Create(newInputPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create new file: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err := out.Close()
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("close new file: %s", err))
|
||||
}
|
||||
}()
|
||||
|
||||
_, err = io.Copy(out, in)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("copy file to new file: %w", err)
|
||||
}
|
||||
|
||||
return newInputPath, nil
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ gotenberg.Process = (*libreOfficeProcess)(nil)
|
||||
_ libreOffice = (*libreOfficeProcess)(nil)
|
||||
)
|
||||
699
pkg/modules/libreoffice/api/libreoffice_test.go
Normal file
699
pkg/modules/libreoffice/api/libreoffice_test.go
Normal file
@@ -0,0 +1,699 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
|
||||
func TestLibreOfficeProcess_Start(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
libreOffice libreOffice
|
||||
expectError bool
|
||||
cleanup bool
|
||||
}{
|
||||
{
|
||||
scenario: "successful start",
|
||||
libreOffice: newLibreOfficeProcess(
|
||||
libreOfficeArguments{
|
||||
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
|
||||
startTimeout: 5 * time.Second,
|
||||
},
|
||||
),
|
||||
expectError: false,
|
||||
cleanup: true,
|
||||
},
|
||||
{
|
||||
scenario: "LibreOffice already started",
|
||||
libreOffice: func() libreOffice {
|
||||
p := new(libreOfficeProcess)
|
||||
p.isStarted.Store(true)
|
||||
return p
|
||||
}(),
|
||||
expectError: true,
|
||||
cleanup: false,
|
||||
},
|
||||
{
|
||||
scenario: "non-exit code 81 on first start",
|
||||
libreOffice: newLibreOfficeProcess(
|
||||
libreOfficeArguments{
|
||||
binPath: "foo",
|
||||
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
|
||||
startTimeout: 5 * time.Second,
|
||||
},
|
||||
),
|
||||
expectError: true,
|
||||
cleanup: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
logger := zap.NewNop()
|
||||
err := tc.libreOffice.Start(logger)
|
||||
|
||||
if tc.cleanup {
|
||||
defer func(p libreOffice, logger *zap.Logger) {
|
||||
err = p.Stop(logger)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error while cleaning up, but got: %v", err)
|
||||
}
|
||||
}(tc.libreOffice, logger)
|
||||
}
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibreOfficeProcess_Stop(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
libreOffice libreOffice
|
||||
setup func(libreOffice libreOffice, logger *zap.Logger) error
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
scenario: "successful stop",
|
||||
libreOffice: newLibreOfficeProcess(
|
||||
libreOfficeArguments{
|
||||
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
|
||||
startTimeout: 5 * time.Second,
|
||||
},
|
||||
),
|
||||
setup: func(p libreOffice, logger *zap.Logger) error {
|
||||
return p.Start(logger)
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "LibreOffice already stopped",
|
||||
libreOffice: func() libreOffice {
|
||||
p := new(libreOfficeProcess)
|
||||
p.isStarted.Store(false)
|
||||
return p
|
||||
}(),
|
||||
expectError: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
logger := zap.NewNop()
|
||||
|
||||
if tc.setup != nil {
|
||||
err := tc.setup(tc.libreOffice, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("setup error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
err := tc.libreOffice.Stop(logger)
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibreOfficeProcess_Healthy(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
libreOffice libreOffice
|
||||
setup func(libreOffice libreOffice, logger *zap.Logger) error
|
||||
expectHealthy bool
|
||||
cleanup bool
|
||||
}{
|
||||
{
|
||||
scenario: "healthy LibreOffice",
|
||||
libreOffice: newLibreOfficeProcess(
|
||||
libreOfficeArguments{
|
||||
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
|
||||
startTimeout: 5 * time.Second,
|
||||
},
|
||||
),
|
||||
setup: func(p libreOffice, logger *zap.Logger) error {
|
||||
return p.Start(logger)
|
||||
},
|
||||
expectHealthy: true,
|
||||
cleanup: true,
|
||||
},
|
||||
{
|
||||
scenario: "LibreOffice not started",
|
||||
libreOffice: func() libreOffice {
|
||||
p := new(libreOfficeProcess)
|
||||
p.isStarted.Store(false)
|
||||
return p
|
||||
}(),
|
||||
expectHealthy: false,
|
||||
cleanup: false,
|
||||
},
|
||||
{
|
||||
scenario: "unhealthy LibreOffice",
|
||||
libreOffice: func() libreOffice {
|
||||
p := new(libreOfficeProcess)
|
||||
p.isStarted.Store(true)
|
||||
p.socketPort = 12345
|
||||
return p
|
||||
}(),
|
||||
expectHealthy: false,
|
||||
cleanup: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
logger := zap.NewNop()
|
||||
|
||||
if tc.setup != nil {
|
||||
err := tc.setup(tc.libreOffice, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("setup error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if tc.cleanup {
|
||||
defer func(p libreOffice, logger *zap.Logger) {
|
||||
err := p.Stop(logger)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error while cleaning up, but got: %v", err)
|
||||
}
|
||||
}(tc.libreOffice, logger)
|
||||
}
|
||||
|
||||
healthy := tc.libreOffice.Healthy(logger)
|
||||
|
||||
if !tc.expectHealthy && healthy {
|
||||
t.Fatal("expected unhealthy LibreOffice but got an healthy one")
|
||||
}
|
||||
|
||||
if tc.expectHealthy && !healthy {
|
||||
t.Fatal("expected a healthy LibreOffice but got an unhealthy one")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibreOfficeProcess_pdf(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
libreOffice libreOffice
|
||||
fs *gotenberg.FileSystem
|
||||
options Options
|
||||
cancelledCtx bool
|
||||
start bool
|
||||
expectError bool
|
||||
expectedError error
|
||||
}{
|
||||
{
|
||||
scenario: "LibreOffice not started",
|
||||
libreOffice: func() libreOffice {
|
||||
p := new(libreOfficeProcess)
|
||||
p.isStarted.Store(false)
|
||||
return p
|
||||
}(),
|
||||
fs: gotenberg.NewFileSystem(),
|
||||
cancelledCtx: false,
|
||||
start: false,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "ErrInvalidPdfFormats",
|
||||
libreOffice: func() libreOffice {
|
||||
p := new(libreOfficeProcess)
|
||||
p.socketPort = 12345
|
||||
p.isStarted.Store(true)
|
||||
return p
|
||||
}(),
|
||||
fs: gotenberg.NewFileSystem(),
|
||||
options: Options{PdfFormats: gotenberg.PdfFormats{PdfA: "foo"}},
|
||||
cancelledCtx: false,
|
||||
start: false,
|
||||
expectError: true,
|
||||
expectedError: ErrInvalidPdfFormats,
|
||||
},
|
||||
{
|
||||
scenario: "ErrUnoException",
|
||||
libreOffice: newLibreOfficeProcess(
|
||||
libreOfficeArguments{
|
||||
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
|
||||
startTimeout: 5 * time.Second,
|
||||
},
|
||||
),
|
||||
options: Options{PageRanges: "foo"},
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
fs := gotenberg.NewFileSystem()
|
||||
|
||||
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
|
||||
}
|
||||
|
||||
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Context done"), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
return fs
|
||||
}(),
|
||||
cancelledCtx: false,
|
||||
start: true,
|
||||
expectError: true,
|
||||
expectedError: ErrUnoException,
|
||||
},
|
||||
{
|
||||
scenario: "ErrRuntimeException",
|
||||
libreOffice: newLibreOfficeProcess(
|
||||
libreOfficeArguments{
|
||||
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
|
||||
startTimeout: 5 * time.Second,
|
||||
},
|
||||
),
|
||||
options: Options{Password: "foo"},
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
fs := gotenberg.NewFileSystem()
|
||||
|
||||
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
|
||||
}
|
||||
|
||||
in, err := os.Open("/tests/test/testdata/libreoffice/protected.docx")
|
||||
if err != nil {
|
||||
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err := in.Close()
|
||||
if err != nil {
|
||||
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
|
||||
}
|
||||
}()
|
||||
|
||||
out, err := os.Create(fmt.Sprintf("%s/protected.docx", fs.WorkingDirPath()))
|
||||
if err != nil {
|
||||
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err := out.Close()
|
||||
if err != nil {
|
||||
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
|
||||
}
|
||||
}()
|
||||
|
||||
_, err = io.Copy(out, in)
|
||||
if err != nil {
|
||||
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
|
||||
}
|
||||
|
||||
return fs
|
||||
}(),
|
||||
cancelledCtx: false,
|
||||
start: true,
|
||||
expectError: true,
|
||||
expectedError: ErrRuntimeException,
|
||||
},
|
||||
{
|
||||
scenario: "context done",
|
||||
libreOffice: newLibreOfficeProcess(
|
||||
libreOfficeArguments{
|
||||
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
|
||||
startTimeout: 5 * time.Second,
|
||||
},
|
||||
),
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
fs := gotenberg.NewFileSystem()
|
||||
|
||||
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
|
||||
}
|
||||
|
||||
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Context done"), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
return fs
|
||||
}(),
|
||||
cancelledCtx: true,
|
||||
start: true,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "success (default options)",
|
||||
libreOffice: newLibreOfficeProcess(
|
||||
libreOfficeArguments{
|
||||
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
|
||||
startTimeout: 5 * time.Second,
|
||||
},
|
||||
),
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
fs := gotenberg.NewFileSystem()
|
||||
|
||||
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
|
||||
}
|
||||
|
||||
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Success"), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
return fs
|
||||
}(),
|
||||
cancelledCtx: false,
|
||||
start: true,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "success (not default options)",
|
||||
libreOffice: newLibreOfficeProcess(
|
||||
libreOfficeArguments{
|
||||
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
|
||||
startTimeout: 5 * time.Second,
|
||||
},
|
||||
),
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
fs := gotenberg.NewFileSystem()
|
||||
|
||||
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
|
||||
}
|
||||
|
||||
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Success"), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
return fs
|
||||
}(),
|
||||
options: Options{
|
||||
Password: "", // Ok, the only exception in this list.
|
||||
Landscape: true,
|
||||
PageRanges: "1",
|
||||
ExportFormFields: false,
|
||||
AllowDuplicateFieldNames: true,
|
||||
ExportBookmarks: false,
|
||||
ExportBookmarksToPdfDestination: true,
|
||||
ExportPlaceholders: true,
|
||||
ExportNotes: true,
|
||||
ExportNotesPages: true,
|
||||
ExportOnlyNotesPages: true,
|
||||
ExportNotesInMargin: true,
|
||||
ConvertOooTargetToPdfTarget: true,
|
||||
ExportLinksRelativeFsys: true,
|
||||
ExportHiddenSlides: true,
|
||||
SkipEmptyPages: true,
|
||||
AddOriginalDocumentAsStream: true,
|
||||
SinglePageSheets: true,
|
||||
LosslessImageCompression: true,
|
||||
Quality: 100,
|
||||
ReduceImageResolution: true,
|
||||
MaxImageResolution: 600,
|
||||
},
|
||||
cancelledCtx: false,
|
||||
start: true,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "success (PDF/A-1b)",
|
||||
libreOffice: newLibreOfficeProcess(
|
||||
libreOfficeArguments{
|
||||
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
|
||||
startTimeout: 5 * time.Second,
|
||||
},
|
||||
),
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
fs := gotenberg.NewFileSystem()
|
||||
|
||||
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
|
||||
}
|
||||
|
||||
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Landscape"), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
return fs
|
||||
}(),
|
||||
options: Options{PdfFormats: gotenberg.PdfFormats{PdfA: gotenberg.PdfA1b}},
|
||||
cancelledCtx: false,
|
||||
start: true,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "success (PDF/A-2b)",
|
||||
libreOffice: newLibreOfficeProcess(
|
||||
libreOfficeArguments{
|
||||
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
|
||||
startTimeout: 5 * time.Second,
|
||||
},
|
||||
),
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
fs := gotenberg.NewFileSystem()
|
||||
|
||||
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
|
||||
}
|
||||
|
||||
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Landscape"), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
return fs
|
||||
}(),
|
||||
options: Options{PdfFormats: gotenberg.PdfFormats{PdfA: gotenberg.PdfA2b}},
|
||||
cancelledCtx: false,
|
||||
start: true,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "success (PDF/A-3b)",
|
||||
libreOffice: newLibreOfficeProcess(
|
||||
libreOfficeArguments{
|
||||
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
|
||||
startTimeout: 5 * time.Second,
|
||||
},
|
||||
),
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
fs := gotenberg.NewFileSystem()
|
||||
|
||||
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
|
||||
}
|
||||
|
||||
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Landscape"), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
return fs
|
||||
}(),
|
||||
options: Options{PdfFormats: gotenberg.PdfFormats{PdfA: gotenberg.PdfA3b}},
|
||||
cancelledCtx: false,
|
||||
start: true,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "success (PDF/UA)",
|
||||
libreOffice: newLibreOfficeProcess(
|
||||
libreOfficeArguments{
|
||||
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
|
||||
startTimeout: 5 * time.Second,
|
||||
},
|
||||
),
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
fs := gotenberg.NewFileSystem()
|
||||
|
||||
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
|
||||
}
|
||||
|
||||
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Landscape"), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
return fs
|
||||
}(),
|
||||
options: Options{PdfFormats: gotenberg.PdfFormats{PdfUa: true}},
|
||||
cancelledCtx: false,
|
||||
start: true,
|
||||
expectError: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
// Force the debug level.
|
||||
logger := zap.NewExample()
|
||||
|
||||
defer func() {
|
||||
err := os.RemoveAll(tc.fs.WorkingDirPath())
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error while cleaning up, but got: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
if tc.start {
|
||||
err := tc.libreOffice.Start(logger)
|
||||
if err != nil {
|
||||
t.Fatalf("setup error: %v", err)
|
||||
}
|
||||
|
||||
defer func(p libreOffice, logger *zap.Logger) {
|
||||
err = p.Stop(logger)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error while cleaning up, but got: %v", err)
|
||||
}
|
||||
}(tc.libreOffice, logger)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(5)*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if tc.cancelledCtx {
|
||||
cancel()
|
||||
}
|
||||
|
||||
err := tc.libreOffice.pdf(
|
||||
ctx,
|
||||
logger,
|
||||
fmt.Sprintf("%s/document.txt", tc.fs.WorkingDirPath()),
|
||||
fmt.Sprintf("%s/%s.pdf", tc.fs.WorkingDirPath(), uuid.NewString()),
|
||||
tc.options,
|
||||
)
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
|
||||
if tc.expectedError != nil && !errors.Is(err, tc.expectedError) {
|
||||
t.Fatalf("expected error %v but got: %v", tc.expectedError, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonBasicLatinCharactersGuard(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
fs *gotenberg.FileSystem
|
||||
filename string
|
||||
expectSameInputPath bool
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
scenario: "basic latin characters",
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
fs := gotenberg.NewFileSystem()
|
||||
|
||||
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
|
||||
}
|
||||
|
||||
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Basic latin characters"), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
return fs
|
||||
}(),
|
||||
filename: "document.txt",
|
||||
expectSameInputPath: true,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "non-basic latin characters",
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
fs := gotenberg.NewFileSystem()
|
||||
|
||||
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
|
||||
}
|
||||
|
||||
err = os.WriteFile(fmt.Sprintf("%s/éèßàùä.txt", fs.WorkingDirPath()), []byte("Non-basic latin characters"), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
return fs
|
||||
}(),
|
||||
filename: "éèßàùä.txt",
|
||||
expectSameInputPath: false,
|
||||
expectError: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
defer func() {
|
||||
err := os.RemoveAll(tc.fs.WorkingDirPath())
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error while cleaning up, but got: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
inputPath := fmt.Sprintf("%s/%s", tc.fs.WorkingDirPath(), tc.filename)
|
||||
newInputPath, err := nonBasicLatinCharactersGuard(
|
||||
zap.NewNop(),
|
||||
inputPath,
|
||||
)
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
|
||||
if tc.expectSameInputPath && newInputPath != inputPath {
|
||||
t.Fatalf("expected same input path, but got '%s'", newInputPath)
|
||||
}
|
||||
|
||||
if !tc.expectSameInputPath && newInputPath == inputPath {
|
||||
t.Fatalf("expected different input path, but got same '%s'", newInputPath)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
59
pkg/modules/libreoffice/api/mocks.go
Normal file
59
pkg/modules/libreoffice/api/mocks.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
|
||||
// ApiMock is a mock for the [Uno] interface.
|
||||
type ApiMock struct {
|
||||
PdfMock func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error
|
||||
ExtensionsMock func() []string
|
||||
}
|
||||
|
||||
func (api *ApiMock) Pdf(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error {
|
||||
return api.PdfMock(ctx, logger, inputPath, outputPath, options)
|
||||
}
|
||||
|
||||
func (api *ApiMock) Extensions() []string {
|
||||
return api.ExtensionsMock()
|
||||
}
|
||||
|
||||
// ProviderMock is a mock for the [Provider] interface.
|
||||
type ProviderMock struct {
|
||||
LibreOfficeMock func() (Uno, error)
|
||||
}
|
||||
|
||||
func (provider *ProviderMock) LibreOffice() (Uno, error) {
|
||||
return provider.LibreOfficeMock()
|
||||
}
|
||||
|
||||
// libreOfficeMock is a mock for the [libreOffice] interface.
|
||||
type libreOfficeMock struct {
|
||||
errCoreDumpedCount int
|
||||
|
||||
gotenberg.ProcessMock
|
||||
pdfMock func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error
|
||||
}
|
||||
|
||||
func (b *libreOfficeMock) pdf(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error {
|
||||
err := b.pdfMock(ctx, logger, inputPath, outputPath, options)
|
||||
if errors.Is(err, ErrCoreDumped) {
|
||||
b.errCoreDumpedCount += 1
|
||||
}
|
||||
if b.errCoreDumpedCount > 1 {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ Uno = (*ApiMock)(nil)
|
||||
_ Provider = (*ProviderMock)(nil)
|
||||
_ libreOffice = (*libreOfficeMock)(nil)
|
||||
)
|
||||
94
pkg/modules/libreoffice/api/mocks_test.go
Normal file
94
pkg/modules/libreoffice/api/mocks_test.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestApiMock(t *testing.T) {
|
||||
mock := &ApiMock{
|
||||
PdfMock: func(ctx context.Context, logger *zap.Logger, input, outputPath string, options Options) error {
|
||||
return nil
|
||||
},
|
||||
ExtensionsMock: func() []string {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
err := mock.Pdf(context.Background(), zap.NewNop(), "", "", Options{})
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from ApiMock.Pdf, but got: %v", err)
|
||||
}
|
||||
|
||||
ext := mock.Extensions()
|
||||
if ext != nil {
|
||||
t.Errorf("expected nil result from ApiMock.Extensions, but got: %v", ext)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderMock(t *testing.T) {
|
||||
mock := &ProviderMock{
|
||||
LibreOfficeMock: func() (Uno, error) {
|
||||
return nil, nil
|
||||
},
|
||||
}
|
||||
|
||||
_, err := mock.LibreOffice()
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from ProviderMock.LibreOffice, but got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibreOfficeMock(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
mock *libreOfficeMock
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
scenario: "success",
|
||||
mock: &libreOfficeMock{
|
||||
pdfMock: func(ctx context.Context, logger *zap.Logger, input, outputPath string, options Options) error {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "ErrCoreDumped (first call)",
|
||||
mock: &libreOfficeMock{
|
||||
pdfMock: func(ctx context.Context, logger *zap.Logger, input, outputPath string, options Options) error {
|
||||
return ErrCoreDumped
|
||||
},
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "ErrCoreDumped (second call)",
|
||||
mock: func() *libreOfficeMock {
|
||||
m := &libreOfficeMock{
|
||||
pdfMock: func(ctx context.Context, logger *zap.Logger, input, outputPath string, options Options) error {
|
||||
return ErrCoreDumped
|
||||
},
|
||||
}
|
||||
m.pdf(context.Background(), zap.NewNop(), "", "", Options{})
|
||||
return m
|
||||
}(),
|
||||
expectError: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
err := tc.mock.pdf(context.Background(), zap.NewNop(), "", "", Options{})
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error from libreOfficeMock.pdf but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error from libreOfficeMock.pdf but got none")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -3,26 +3,27 @@ package libreoffice
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/modules/api"
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/uno"
|
||||
flag "github.com/spf13/pflag"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
|
||||
libeofficeapi "github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice/api"
|
||||
)
|
||||
|
||||
func init() {
|
||||
gotenberg.MustRegisterModule(LibreOffice{})
|
||||
gotenberg.MustRegisterModule(new(LibreOffice))
|
||||
}
|
||||
|
||||
// LibreOffice is a module which provides a route for converting documents to
|
||||
// PDF with LibreOffice.
|
||||
type LibreOffice struct {
|
||||
unoAPI uno.API
|
||||
engine gotenberg.PDFEngine
|
||||
api libeofficeapi.Uno
|
||||
engine gotenberg.PdfEngine
|
||||
disableRoutes bool
|
||||
}
|
||||
|
||||
// Descriptor returns a LibreOffice's module descriptor.
|
||||
func (LibreOffice) Descriptor() gotenberg.ModuleDescriptor {
|
||||
// Descriptor returns a [LibreOffice]'s module descriptor.
|
||||
func (mod *LibreOffice) Descriptor() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{
|
||||
ID: "libreoffice",
|
||||
FlagSet: func() *flag.FlagSet {
|
||||
@@ -40,24 +41,24 @@ func (mod *LibreOffice) Provision(ctx *gotenberg.Context) error {
|
||||
flags := ctx.ParsedFlags()
|
||||
mod.disableRoutes = flags.MustBool("libreoffice-disable-routes")
|
||||
|
||||
provider, err := ctx.Module(new(uno.Provider))
|
||||
provider, err := ctx.Module(new(libeofficeapi.Provider))
|
||||
if err != nil {
|
||||
return fmt.Errorf("get unoAPI provider: %w", err)
|
||||
return fmt.Errorf("get LibreOffice Uno provider: %w", err)
|
||||
}
|
||||
|
||||
unoAPI, err := provider.(uno.Provider).UNO()
|
||||
libreOfficeApi, err := provider.(libeofficeapi.Provider).LibreOffice()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get unoAPI API: %w", err)
|
||||
return fmt.Errorf("get LibreOffice Uno: %w", err)
|
||||
}
|
||||
|
||||
mod.unoAPI = unoAPI
|
||||
mod.api = libreOfficeApi
|
||||
|
||||
provider, err = ctx.Module(new(gotenberg.PDFEngineProvider))
|
||||
provider, err = ctx.Module(new(gotenberg.PdfEngineProvider))
|
||||
if err != nil {
|
||||
return fmt.Errorf("get PDF engine provider: %w", err)
|
||||
}
|
||||
|
||||
engine, err := provider.(gotenberg.PDFEngineProvider).PDFEngine()
|
||||
engine, err := provider.(gotenberg.PdfEngineProvider).PdfEngine()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get PDF engine: %w", err)
|
||||
}
|
||||
@@ -68,13 +69,13 @@ func (mod *LibreOffice) Provision(ctx *gotenberg.Context) error {
|
||||
}
|
||||
|
||||
// Routes returns the HTTP routes.
|
||||
func (mod LibreOffice) Routes() ([]api.Route, error) {
|
||||
func (mod *LibreOffice) Routes() ([]api.Route, error) {
|
||||
if mod.disableRoutes {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return []api.Route{
|
||||
convertRoute(mod.unoAPI, mod.engine),
|
||||
convertRoute(mod.api, mod.engine),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -5,12 +5,12 @@ import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/uno"
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
libreofficeapi "github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice/api"
|
||||
)
|
||||
|
||||
func TestLibreOffice_Descriptor(t *testing.T) {
|
||||
descriptor := LibreOffice{}.Descriptor()
|
||||
descriptor := new(LibreOffice).Descriptor()
|
||||
|
||||
actual := reflect.TypeOf(descriptor.New())
|
||||
expect := reflect.TypeOf(new(LibreOffice))
|
||||
@@ -21,38 +21,35 @@ func TestLibreOffice_Descriptor(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLibreOffice_Provision(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ctx *gotenberg.Context
|
||||
expectProvisionErr bool
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
ctx *gotenberg.Context
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "nominal behavior",
|
||||
scenario: "no LibreOffice API provider",
|
||||
ctx: func() *gotenberg.Context {
|
||||
provider1 := struct {
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: new(LibreOffice).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{},
|
||||
)
|
||||
}(),
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "no LibreOffice API from LibreOffice API provider",
|
||||
ctx: func() *gotenberg.Context {
|
||||
mod := &struct {
|
||||
gotenberg.ModuleMock
|
||||
uno.ProviderMock
|
||||
libreofficeapi.ProviderMock
|
||||
}{}
|
||||
provider1.DescriptorMock = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
|
||||
return provider1
|
||||
}}
|
||||
mod.DescriptorMock = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }}
|
||||
}
|
||||
provider1.UNOMock = func() (uno.API, error) {
|
||||
return uno.APIMock{}, nil
|
||||
}
|
||||
|
||||
provider2 := struct {
|
||||
gotenberg.ModuleMock
|
||||
gotenberg.PDFEngineProviderMock
|
||||
}{}
|
||||
provider2.DescriptorMock = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module {
|
||||
return provider2
|
||||
}}
|
||||
}
|
||||
provider2.PDFEngineMock = func() (gotenberg.PDFEngine, error) {
|
||||
return gotenberg.PDFEngineMock{}, nil
|
||||
mod.LibreOfficeMock = func() (libreofficeapi.Uno, error) {
|
||||
return nil, errors.New("foo")
|
||||
}
|
||||
|
||||
return gotenberg.NewContext(
|
||||
@@ -60,36 +57,24 @@ func TestLibreOffice_Provision(t *testing.T) {
|
||||
FlagSet: new(LibreOffice).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{
|
||||
provider1.Descriptor(),
|
||||
provider2.Descriptor(),
|
||||
mod.Descriptor(),
|
||||
},
|
||||
)
|
||||
}(),
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "no UNO API provider",
|
||||
ctx: gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: new(LibreOffice).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{},
|
||||
),
|
||||
expectProvisionErr: true,
|
||||
},
|
||||
{
|
||||
name: "no API from UNO API provider",
|
||||
scenario: "no PDF engine provider",
|
||||
ctx: func() *gotenberg.Context {
|
||||
provider := struct {
|
||||
mod := &struct {
|
||||
gotenberg.ModuleMock
|
||||
uno.ProviderMock
|
||||
libreofficeapi.ProviderMock
|
||||
}{}
|
||||
provider.DescriptorMock = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
|
||||
return provider
|
||||
}}
|
||||
mod.DescriptorMock = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }}
|
||||
}
|
||||
provider.UNOMock = func() (uno.API, error) {
|
||||
return uno.APIMock{}, errors.New("foo")
|
||||
mod.LibreOfficeMock = func() (libreofficeapi.Uno, error) {
|
||||
return new(libreofficeapi.ApiMock), nil
|
||||
}
|
||||
|
||||
return gotenberg.NewContext(
|
||||
@@ -97,26 +82,28 @@ func TestLibreOffice_Provision(t *testing.T) {
|
||||
FlagSet: new(LibreOffice).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{
|
||||
provider.Descriptor(),
|
||||
mod.Descriptor(),
|
||||
},
|
||||
)
|
||||
}(),
|
||||
expectProvisionErr: true,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "no PDF engine provider",
|
||||
scenario: "no PDF engine from PDF engine provider",
|
||||
ctx: func() *gotenberg.Context {
|
||||
provider := struct {
|
||||
mod := &struct {
|
||||
gotenberg.ModuleMock
|
||||
uno.ProviderMock
|
||||
libreofficeapi.ProviderMock
|
||||
gotenberg.PdfEngineProviderMock
|
||||
}{}
|
||||
provider.DescriptorMock = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
|
||||
return provider
|
||||
}}
|
||||
mod.DescriptorMock = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }}
|
||||
}
|
||||
provider.UNOMock = func() (uno.API, error) {
|
||||
return uno.APIMock{}, nil
|
||||
mod.LibreOfficeMock = func() (libreofficeapi.Uno, error) {
|
||||
return new(libreofficeapi.ApiMock), nil
|
||||
}
|
||||
mod.PdfEngineMock = func() (gotenberg.PdfEngine, error) {
|
||||
return nil, errors.New("foo")
|
||||
}
|
||||
|
||||
return gotenberg.NewContext(
|
||||
@@ -124,39 +111,28 @@ func TestLibreOffice_Provision(t *testing.T) {
|
||||
FlagSet: new(LibreOffice).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{
|
||||
provider.Descriptor(),
|
||||
mod.Descriptor(),
|
||||
},
|
||||
)
|
||||
}(),
|
||||
expectProvisionErr: true,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "no PDF engine from PDF engine provider",
|
||||
scenario: "provision success",
|
||||
ctx: func() *gotenberg.Context {
|
||||
provider1 := struct {
|
||||
mod := &struct {
|
||||
gotenberg.ModuleMock
|
||||
uno.ProviderMock
|
||||
libreofficeapi.ProviderMock
|
||||
gotenberg.PdfEngineProviderMock
|
||||
}{}
|
||||
provider1.DescriptorMock = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
|
||||
return provider1
|
||||
}}
|
||||
mod.DescriptorMock = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }}
|
||||
}
|
||||
provider1.UNOMock = func() (uno.API, error) {
|
||||
return uno.APIMock{}, nil
|
||||
mod.LibreOfficeMock = func() (libreofficeapi.Uno, error) {
|
||||
return new(libreofficeapi.ApiMock), nil
|
||||
}
|
||||
|
||||
provider2 := struct {
|
||||
gotenberg.ModuleMock
|
||||
gotenberg.PDFEngineProviderMock
|
||||
}{}
|
||||
provider2.DescriptorMock = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module {
|
||||
return provider2
|
||||
}}
|
||||
}
|
||||
provider2.PDFEngineMock = func() (gotenberg.PDFEngine, error) {
|
||||
return gotenberg.PDFEngineMock{}, errors.New("foo")
|
||||
mod.PdfEngineMock = func() (gotenberg.PdfEngine, error) {
|
||||
return new(gotenberg.PdfEngineMock), nil
|
||||
}
|
||||
|
||||
return gotenberg.NewContext(
|
||||
@@ -164,59 +140,56 @@ func TestLibreOffice_Provision(t *testing.T) {
|
||||
FlagSet: new(LibreOffice).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{
|
||||
provider1.Descriptor(),
|
||||
provider2.Descriptor(),
|
||||
mod.Descriptor(),
|
||||
},
|
||||
)
|
||||
}(),
|
||||
expectProvisionErr: true,
|
||||
expectError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
mod := new(LibreOffice)
|
||||
err := mod.Provision(tc.ctx)
|
||||
|
||||
if tc.expectProvisionErr && err == nil {
|
||||
t.Error("expected mod.Provision() error, but got none")
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if !tc.expectProvisionErr && err != nil {
|
||||
t.Errorf("expected no error from mod.Provision(), but got: %v", err)
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibreOffice_Routes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mod LibreOffice
|
||||
expectRoutesCount int
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
expectRoutes int
|
||||
disableRoutes bool
|
||||
}{
|
||||
{
|
||||
name: "route not disabled",
|
||||
mod: LibreOffice{},
|
||||
expectRoutesCount: 1,
|
||||
scenario: "routes not disabled",
|
||||
expectRoutes: 1,
|
||||
disableRoutes: false,
|
||||
},
|
||||
{
|
||||
name: "route disabled",
|
||||
mod: LibreOffice{
|
||||
disableRoutes: true,
|
||||
},
|
||||
scenario: "routes disabled",
|
||||
expectRoutes: 0,
|
||||
disableRoutes: true,
|
||||
},
|
||||
}
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
mod := new(LibreOffice)
|
||||
mod.disableRoutes = tc.disableRoutes
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
routes, err := tc.mod.Routes()
|
||||
routes, err := mod.Routes()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error from mod.Routes(), but got: %v", err)
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectRoutesCount != len(routes) {
|
||||
t.Errorf("expected %d routes from mod.Routes(), but got %d", tc.expectRoutesCount, len(routes))
|
||||
if tc.expectRoutes != len(routes) {
|
||||
t.Errorf("expected %d routes but got %d", tc.expectRoutes, len(routes))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
// Package pdfengine provides a module which interacts with the UNO
|
||||
// (Universal Network Objects) API and implements the gotenberg.PDFEngine
|
||||
// interface.
|
||||
// Package pdfengine provides a module which interacts with LibreOffice via the
|
||||
// UNO (Universal Network Objects) API and implements the gotenberg.PdfEngine
|
||||
// interface. This package allows for:
|
||||
//
|
||||
// 1. The conversion to specific PDF formats.
|
||||
package pdfengine
|
||||
|
||||
@@ -5,73 +5,85 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/uno"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice/api"
|
||||
)
|
||||
|
||||
func init() {
|
||||
gotenberg.MustRegisterModule(UNO{})
|
||||
gotenberg.MustRegisterModule(new(LibreOfficePdfEngine))
|
||||
}
|
||||
|
||||
// UNO interacts with the UNO (Universal Network Objects) API and implements
|
||||
// the gotenberg.PDFEngine interface.
|
||||
type UNO struct {
|
||||
unoAPI uno.API
|
||||
// LibreOfficePdfEngine interacts with the LibreOffice (Universal Network Objects) API
|
||||
// and implements the [gotenberg.PdfEngine] interface.
|
||||
type LibreOfficePdfEngine struct {
|
||||
unoApi api.Uno
|
||||
}
|
||||
|
||||
// Descriptor returns a UNO's module descriptor.
|
||||
func (UNO) Descriptor() gotenberg.ModuleDescriptor {
|
||||
// Descriptor returns a [LibreOfficePdfEngine]'s module descriptor.
|
||||
func (engine *LibreOfficePdfEngine) Descriptor() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{
|
||||
ID: "uno-pdfengine",
|
||||
New: func() gotenberg.Module { return new(UNO) },
|
||||
ID: "libreoffice-pdfengine",
|
||||
New: func() gotenberg.Module { return new(LibreOfficePdfEngine) },
|
||||
}
|
||||
}
|
||||
|
||||
// Provision sets the module properties.
|
||||
func (engine *UNO) Provision(ctx *gotenberg.Context) error {
|
||||
provider, err := ctx.Module(new(uno.Provider))
|
||||
func (engine *LibreOfficePdfEngine) Provision(ctx *gotenberg.Context) error {
|
||||
provider, err := ctx.Module(new(api.Provider))
|
||||
if err != nil {
|
||||
return fmt.Errorf("get unoconv provider: %w", err)
|
||||
return fmt.Errorf("get LibreOffice Uno provider: %w", err)
|
||||
}
|
||||
|
||||
unoAPI, err := provider.(uno.Provider).UNO()
|
||||
unoApi, err := provider.(api.Provider).LibreOffice()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get unoconv API: %w", err)
|
||||
return fmt.Errorf("get LibreOffice Uno: %w", err)
|
||||
}
|
||||
|
||||
engine.unoAPI = unoAPI
|
||||
engine.unoApi = unoApi
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Merge is not available for this PDF engine.
|
||||
func (engine UNO) Merge(_ context.Context, _ *zap.Logger, _ []string, _ string) error {
|
||||
return fmt.Errorf("merge PDFs with unoconv: %w", gotenberg.ErrPDFEngineMethodNotAvailable)
|
||||
// Merge is not available in this implementation.
|
||||
func (engine *LibreOfficePdfEngine) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
||||
return fmt.Errorf("merge PDFs with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||
}
|
||||
|
||||
// Convert converts the given PDF to a specific PDF format. Currently, only the
|
||||
// PDF/A-1a, PDF/A-2b and PDF/A-3b formats are available. If another PDF format
|
||||
// is requested, it returns a gotenberg.ErrPDFFormatNotAvailable error.
|
||||
func (engine UNO) Convert(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error {
|
||||
err := engine.unoAPI.PDF(ctx, logger, inputPath, outputPath, uno.Options{
|
||||
PDFformat: format,
|
||||
})
|
||||
// PDF/A-1b, PDF/A-2b, PDF/A-3b and PDF/UA formats are available. If another
|
||||
// PDF format is requested, it returns a [gotenberg.ErrPdfFormatNotSupported]
|
||||
// error.
|
||||
func (engine *LibreOfficePdfEngine) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
||||
opts := api.DefaultOptions()
|
||||
opts.PdfFormats = formats
|
||||
err := engine.unoApi.Pdf(ctx, logger, inputPath, outputPath, opts)
|
||||
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if errors.Is(err, uno.ErrInvalidPDFformat) {
|
||||
return fmt.Errorf("convert PDF to '%s' with unoconv: %w", format, gotenberg.ErrPDFFormatNotAvailable)
|
||||
if errors.Is(err, api.ErrInvalidPdfFormats) {
|
||||
return fmt.Errorf("convert PDF to '%+v' with LibreOffice: %w", formats, gotenberg.ErrPdfFormatNotSupported)
|
||||
}
|
||||
|
||||
return fmt.Errorf("convert PDF to '%s' with unoconv: %w", format, err)
|
||||
return fmt.Errorf("convert PDF to '%+v' with LibreOffice: %w", formats, err)
|
||||
}
|
||||
|
||||
// ReadMetadata is not available in this implementation.
|
||||
func (engine *LibreOfficePdfEngine) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
|
||||
return nil, fmt.Errorf("read PDF metadata with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||
}
|
||||
|
||||
// WriteMetadata is not available in this implementation.
|
||||
func (engine *LibreOfficePdfEngine) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
|
||||
return fmt.Errorf("write PDF metadata with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ gotenberg.Module = (*UNO)(nil)
|
||||
_ gotenberg.Provisioner = (*UNO)(nil)
|
||||
_ gotenberg.PDFEngine = (*UNO)(nil)
|
||||
_ gotenberg.Module = (*LibreOfficePdfEngine)(nil)
|
||||
_ gotenberg.Provisioner = (*LibreOfficePdfEngine)(nil)
|
||||
_ gotenberg.PdfEngine = (*LibreOfficePdfEngine)(nil)
|
||||
)
|
||||
|
||||
@@ -6,169 +6,181 @@ import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/uno"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice/api"
|
||||
)
|
||||
|
||||
func TestUNO_Descriptor(t *testing.T) {
|
||||
descriptor := UNO{}.Descriptor()
|
||||
func TestLibreOfficePdfEngine_Descriptor(t *testing.T) {
|
||||
descriptor := new(LibreOfficePdfEngine).Descriptor()
|
||||
|
||||
actual := reflect.TypeOf(descriptor.New())
|
||||
expect := reflect.TypeOf(new(UNO))
|
||||
expect := reflect.TypeOf(new(LibreOfficePdfEngine))
|
||||
|
||||
if actual != expect {
|
||||
t.Errorf("expected '%s' but got '%s'", expect, actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUNO_Provider(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ctx *gotenberg.Context
|
||||
expectProvisionErr bool
|
||||
func TestLibreOfficePdfEngine_Provider(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
ctx *gotenberg.Context
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "nominal behavior",
|
||||
ctx: func() *gotenberg.Context {
|
||||
provider := struct {
|
||||
gotenberg.ModuleMock
|
||||
uno.ProviderMock
|
||||
}{}
|
||||
provider.DescriptorMock = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
|
||||
return provider
|
||||
}}
|
||||
}
|
||||
provider.UNOMock = func() (uno.API, error) {
|
||||
return uno.APIMock{}, nil
|
||||
}
|
||||
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: new(UNO).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{
|
||||
provider.Descriptor(),
|
||||
},
|
||||
)
|
||||
}(),
|
||||
},
|
||||
{
|
||||
name: "no UNO API provider",
|
||||
scenario: "no LibreOffice API provider",
|
||||
ctx: gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: new(UNO).Descriptor().FlagSet,
|
||||
FlagSet: new(LibreOfficePdfEngine).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{},
|
||||
),
|
||||
expectProvisionErr: true,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "no API from UNO API provider",
|
||||
scenario: "no API from LibreOffice API provider",
|
||||
ctx: func() *gotenberg.Context {
|
||||
provider := struct {
|
||||
provider := &struct {
|
||||
gotenberg.ModuleMock
|
||||
uno.ProviderMock
|
||||
api.ProviderMock
|
||||
}{}
|
||||
provider.DescriptorMock = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
|
||||
return provider
|
||||
}}
|
||||
}
|
||||
provider.UNOMock = func() (uno.API, error) {
|
||||
return uno.APIMock{}, errors.New("foo")
|
||||
provider.LibreOfficeMock = func() (api.Uno, error) {
|
||||
return nil, errors.New("foo")
|
||||
}
|
||||
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: new(UNO).Descriptor().FlagSet,
|
||||
FlagSet: new(LibreOfficePdfEngine).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{
|
||||
provider.Descriptor(),
|
||||
},
|
||||
)
|
||||
}(),
|
||||
expectProvisionErr: true,
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
{
|
||||
scenario: "provision success",
|
||||
ctx: func() *gotenberg.Context {
|
||||
provider := &struct {
|
||||
gotenberg.ModuleMock
|
||||
api.ProviderMock
|
||||
}{}
|
||||
provider.DescriptorMock = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
|
||||
return provider
|
||||
}}
|
||||
}
|
||||
provider.LibreOfficeMock = func() (api.Uno, error) {
|
||||
return new(api.ApiMock), nil
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
mod := new(UNO)
|
||||
err := mod.Provision(tc.ctx)
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: new(LibreOfficePdfEngine).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{
|
||||
provider.Descriptor(),
|
||||
},
|
||||
)
|
||||
}(),
|
||||
expectError: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
engine := new(LibreOfficePdfEngine)
|
||||
err := engine.Provision(tc.ctx)
|
||||
|
||||
if tc.expectProvisionErr && err == nil {
|
||||
t.Error("expected mod.Provision() error, but got none")
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if !tc.expectProvisionErr && err != nil {
|
||||
t.Errorf("expected no error from mod.Provision(), but got: %v", err)
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUNO_Merge(t *testing.T) {
|
||||
mod := new(UNO)
|
||||
err := mod.Merge(context.Background(), zap.NewNop(), nil, "")
|
||||
func TestLibreOfficePdfEngine_Merge(t *testing.T) {
|
||||
engine := new(LibreOfficePdfEngine)
|
||||
err := engine.Merge(context.Background(), zap.NewNop(), nil, "")
|
||||
|
||||
if !errors.Is(err, gotenberg.ErrPDFEngineMethodNotAvailable) {
|
||||
t.Errorf("expected error %v from mod.Merge(), but got: %v", gotenberg.ErrPDFEngineMethodNotAvailable, err)
|
||||
if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
|
||||
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUNO_Convert(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mod UNO
|
||||
expectConvertErr bool
|
||||
func TestLibreOfficePdfEngine_Convert(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
api api.Uno
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "nominal behavior",
|
||||
mod: UNO{
|
||||
unoAPI: uno.APIMock{
|
||||
PDFMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options uno.Options) error {
|
||||
return nil
|
||||
},
|
||||
scenario: "convert success",
|
||||
api: &api.ApiMock{
|
||||
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options api.Options) error {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "invalid PDF format",
|
||||
mod: UNO{
|
||||
unoAPI: uno.APIMock{
|
||||
PDFMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options uno.Options) error {
|
||||
return uno.ErrInvalidPDFformat
|
||||
},
|
||||
scenario: "invalid PDF format",
|
||||
api: &api.ApiMock{
|
||||
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options api.Options) error {
|
||||
return api.ErrInvalidPdfFormats
|
||||
},
|
||||
},
|
||||
expectConvertErr: true,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "convert fail",
|
||||
mod: UNO{
|
||||
unoAPI: uno.APIMock{
|
||||
PDFMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options uno.Options) error {
|
||||
return errors.New("foo")
|
||||
},
|
||||
scenario: "convert fail",
|
||||
api: &api.ApiMock{
|
||||
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options api.Options) error {
|
||||
return errors.New("foo")
|
||||
},
|
||||
},
|
||||
expectConvertErr: true,
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
engine := &LibreOfficePdfEngine{unoApi: tc.api}
|
||||
err := engine.Convert(context.Background(), zap.NewNop(), gotenberg.PdfFormats{}, "", "")
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := tc.mod.Convert(context.Background(), zap.NewNop(), "", "", "")
|
||||
|
||||
if tc.expectConvertErr && err == nil {
|
||||
t.Errorf("expected mod.Convert() error, but got none")
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if !tc.expectConvertErr && err != nil {
|
||||
t.Fatalf("expected no error from mod.Convert(), but got: %v", err)
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibreOfficePdfEngine_ReadMetadata(t *testing.T) {
|
||||
engine := new(LibreOfficePdfEngine)
|
||||
_, err := engine.ReadMetadata(context.Background(), zap.NewNop(), "")
|
||||
|
||||
if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
|
||||
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibreOfficePdfEngine_WriteMetadata(t *testing.T) {
|
||||
engine := new(LibreOfficePdfEngine)
|
||||
err := engine.WriteMetadata(context.Background(), zap.NewNop(), nil, "")
|
||||
|
||||
if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
|
||||
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,100 +1,204 @@
|
||||
package libreoffice
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/modules/api"
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/uno"
|
||||
"github.com/labstack/echo/v4"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
|
||||
libreofficeapi "github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice/api"
|
||||
)
|
||||
|
||||
// convertRoute returns an api.Route which can convert LibreOffice documents
|
||||
// convertRoute returns an [api.Route] which can convert LibreOffice documents
|
||||
// to PDF.
|
||||
func convertRoute(unoAPI uno.API, engine gotenberg.PDFEngine) api.Route {
|
||||
func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) api.Route {
|
||||
return api.Route{
|
||||
Method: http.MethodPost,
|
||||
Path: "/forms/libreoffice/convert",
|
||||
IsMultipart: true,
|
||||
Handler: func(c echo.Context) error {
|
||||
ctx := c.Get("context").(*api.Context)
|
||||
defaultOptions := libreofficeapi.DefaultOptions()
|
||||
|
||||
// Let's get the data from the form and validate them.
|
||||
var (
|
||||
inputPaths []string
|
||||
landscape bool
|
||||
nativePageRanges string
|
||||
nativePDFA1aFormat bool
|
||||
nativePDFformat string
|
||||
PDFformat string
|
||||
merge bool
|
||||
inputPaths []string
|
||||
password string
|
||||
landscape bool
|
||||
nativePageRanges string
|
||||
exportFormFields bool
|
||||
allowDuplicateFieldNames bool
|
||||
exportBookmarks bool
|
||||
exportBookmarksToPdfDestination bool
|
||||
exportPlaceholders bool
|
||||
exportNotes bool
|
||||
exportNotesPages bool
|
||||
exportOnlyNotesPages bool
|
||||
exportNotesInMargin bool
|
||||
convertOooTargetToPdfTarget bool
|
||||
exportLinksRelativeFsys bool
|
||||
exportHiddenSlides bool
|
||||
skipEmptyPages bool
|
||||
addOriginalDocumentAsStream bool
|
||||
singlePageSheets bool
|
||||
losslessImageCompression bool
|
||||
quality int
|
||||
reduceImageResolution bool
|
||||
maxImageResolution int
|
||||
pdfa string
|
||||
pdfua bool
|
||||
nativePdfFormats bool
|
||||
merge bool
|
||||
metadata map[string]interface{}
|
||||
)
|
||||
|
||||
err := ctx.FormData().
|
||||
MandatoryPaths(unoAPI.Extensions(), &inputPaths).
|
||||
Bool("landscape", &landscape, false).
|
||||
String("nativePageRanges", &nativePageRanges, "").
|
||||
Bool("nativePdfA1aFormat", &nativePDFA1aFormat, false).
|
||||
String("nativePdfFormat", &nativePDFformat, "").
|
||||
String("pdfFormat", &PDFformat, "").
|
||||
Bool("merge", &merge, false).
|
||||
Validate()
|
||||
MandatoryPaths(libreOffice.Extensions(), &inputPaths).
|
||||
String("password", &password, defaultOptions.Password).
|
||||
Bool("landscape", &landscape, defaultOptions.Landscape).
|
||||
String("nativePageRanges", &nativePageRanges, defaultOptions.PageRanges).
|
||||
Bool("exportFormFields", &exportFormFields, defaultOptions.ExportFormFields).
|
||||
Bool("allowDuplicateFieldNames", &allowDuplicateFieldNames, defaultOptions.AllowDuplicateFieldNames).
|
||||
Bool("exportBookmarks", &exportBookmarks, defaultOptions.ExportBookmarks).
|
||||
Bool("exportBookmarksToPdfDestination", &exportBookmarksToPdfDestination, defaultOptions.ExportBookmarksToPdfDestination).
|
||||
Bool("exportPlaceholders", &exportPlaceholders, defaultOptions.ExportPlaceholders).
|
||||
Bool("exportNotes", &exportNotes, defaultOptions.ExportNotes).
|
||||
Bool("exportNotesPages", &exportNotesPages, defaultOptions.ExportNotesPages).
|
||||
Bool("exportOnlyNotesPages", &exportOnlyNotesPages, defaultOptions.ExportOnlyNotesPages).
|
||||
Bool("exportNotesInMargin", &exportNotesInMargin, defaultOptions.ExportNotesInMargin).
|
||||
Bool("convertOooTargetToPdfTarget", &convertOooTargetToPdfTarget, defaultOptions.ConvertOooTargetToPdfTarget).
|
||||
Bool("exportLinksRelativeFsys", &exportLinksRelativeFsys, defaultOptions.ExportLinksRelativeFsys).
|
||||
Bool("exportHiddenSlides", &exportHiddenSlides, defaultOptions.ExportHiddenSlides).
|
||||
Bool("skipEmptyPages", &skipEmptyPages, defaultOptions.SkipEmptyPages).
|
||||
Bool("addOriginalDocumentAsStream", &addOriginalDocumentAsStream, defaultOptions.AddOriginalDocumentAsStream).
|
||||
Bool("singlePageSheets", &singlePageSheets, defaultOptions.SinglePageSheets).
|
||||
Bool("losslessImageCompression", &losslessImageCompression, defaultOptions.LosslessImageCompression).
|
||||
Custom("quality", func(value string) error {
|
||||
if value == "" {
|
||||
quality = defaultOptions.Quality
|
||||
return nil
|
||||
}
|
||||
|
||||
intValue, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if intValue < 1 {
|
||||
return errors.New("value is inferior to 1")
|
||||
}
|
||||
|
||||
if intValue > 100 {
|
||||
return errors.New("value is superior to 100")
|
||||
}
|
||||
|
||||
quality = intValue
|
||||
return nil
|
||||
}).
|
||||
Bool("reduceImageResolution", &reduceImageResolution, defaultOptions.ReduceImageResolution).
|
||||
Custom("maxImageResolution", func(value string) error {
|
||||
if value == "" {
|
||||
maxImageResolution = defaultOptions.MaxImageResolution
|
||||
return nil
|
||||
}
|
||||
|
||||
intValue, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !slices.Contains([]int{75, 150, 300, 600, 1200}, intValue) {
|
||||
return errors.New("value is not 75, 150, 300, 600 or 1200")
|
||||
}
|
||||
|
||||
maxImageResolution = intValue
|
||||
return nil
|
||||
}).
|
||||
String("pdfa", &pdfa, "").
|
||||
Bool("pdfua", &pdfua, false).
|
||||
Bool("nativePdfFormats", &nativePdfFormats, true).
|
||||
Bool("merge", &merge, false).
|
||||
Custom("metadata", func(value string) error {
|
||||
if len(value) > 0 {
|
||||
err := json.Unmarshal([]byte(value), &metadata)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unmarshal metadata: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}).
|
||||
Validate()
|
||||
if err != nil {
|
||||
return fmt.Errorf("validate form data: %w", err)
|
||||
}
|
||||
|
||||
if nativePDFA1aFormat {
|
||||
ctx.Log().Warn("'nativePdfA1aFormat' is deprecated; prefer 'nativePdfFormat' or 'pdfFormat' form fields instead")
|
||||
}
|
||||
|
||||
if nativePDFA1aFormat && nativePDFformat != "" {
|
||||
return api.WrapError(
|
||||
errors.New("got both 'nativePdfFormat' and 'nativePdfA1aFormat' form fields"),
|
||||
api.NewSentinelHTTPError(http.StatusBadRequest, "Both 'nativePdfFormat' and 'nativePdfA1aFormat' form fields are provided"),
|
||||
)
|
||||
}
|
||||
|
||||
if nativePDFA1aFormat && PDFformat != "" {
|
||||
return api.WrapError(
|
||||
errors.New("got both 'pdfFormat' and 'nativePdfA1aFormat' form fields"),
|
||||
api.NewSentinelHTTPError(http.StatusBadRequest, "Both 'pdfFormat' and 'nativePdfA1aFormat' form fields are provided"),
|
||||
)
|
||||
}
|
||||
|
||||
if nativePDFformat != "" && PDFformat != "" {
|
||||
return api.WrapError(
|
||||
errors.New("got both 'pdfFormat' and 'nativePdfFormat' form fields"),
|
||||
api.NewSentinelHTTPError(http.StatusBadRequest, "Both 'pdfFormat' and 'nativePdfFormat' form fields are provided"),
|
||||
)
|
||||
}
|
||||
|
||||
if nativePDFA1aFormat {
|
||||
nativePDFformat = gotenberg.FormatPDFA1a
|
||||
pdfFormats := gotenberg.PdfFormats{
|
||||
PdfA: pdfa,
|
||||
PdfUa: pdfua,
|
||||
}
|
||||
|
||||
// Alright, let's convert each document to PDF.
|
||||
|
||||
outputPaths := make([]string, len(inputPaths))
|
||||
|
||||
for i, inputPath := range inputPaths {
|
||||
outputPaths[i] = ctx.GeneratePath(".pdf")
|
||||
|
||||
options := uno.Options{
|
||||
Landscape: landscape,
|
||||
PageRanges: nativePageRanges,
|
||||
PDFformat: nativePDFformat,
|
||||
options := libreofficeapi.Options{
|
||||
Password: password,
|
||||
Landscape: landscape,
|
||||
PageRanges: nativePageRanges,
|
||||
ExportFormFields: exportFormFields,
|
||||
AllowDuplicateFieldNames: allowDuplicateFieldNames,
|
||||
ExportBookmarks: exportBookmarks,
|
||||
ExportBookmarksToPdfDestination: exportBookmarksToPdfDestination,
|
||||
ExportPlaceholders: exportPlaceholders,
|
||||
ExportNotes: exportNotes,
|
||||
ExportNotesPages: exportNotesPages,
|
||||
ExportOnlyNotesPages: exportOnlyNotesPages,
|
||||
ExportNotesInMargin: exportNotesInMargin,
|
||||
ConvertOooTargetToPdfTarget: convertOooTargetToPdfTarget,
|
||||
ExportLinksRelativeFsys: exportLinksRelativeFsys,
|
||||
ExportHiddenSlides: exportHiddenSlides,
|
||||
SkipEmptyPages: skipEmptyPages,
|
||||
AddOriginalDocumentAsStream: addOriginalDocumentAsStream,
|
||||
SinglePageSheets: singlePageSheets,
|
||||
LosslessImageCompression: losslessImageCompression,
|
||||
Quality: quality,
|
||||
ReduceImageResolution: reduceImageResolution,
|
||||
MaxImageResolution: maxImageResolution,
|
||||
}
|
||||
|
||||
err = unoAPI.PDF(ctx, ctx.Log(), inputPath, outputPaths[i], options)
|
||||
if nativePdfFormats {
|
||||
options.PdfFormats = pdfFormats
|
||||
}
|
||||
|
||||
err = libreOffice.Pdf(ctx, ctx.Log(), inputPath, outputPaths[i], options)
|
||||
if err != nil {
|
||||
if errors.Is(err, uno.ErrMalformedPageRanges) {
|
||||
if errors.Is(err, libreofficeapi.ErrInvalidPdfFormats) {
|
||||
return api.WrapError(
|
||||
fmt.Errorf("convert to PDF: %w", err),
|
||||
api.NewSentinelHTTPError(http.StatusBadRequest, fmt.Sprintf("Malformed page ranges '%s' (nativePageRanges)", options.PageRanges)),
|
||||
api.NewSentinelHttpError(
|
||||
http.StatusBadRequest,
|
||||
fmt.Sprintf("A PDF format in '%+v' is not supported", pdfFormats),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if errors.Is(err, libreofficeapi.ErrUnoException) {
|
||||
return api.WrapError(
|
||||
fmt.Errorf("convert to PDF: %w", err),
|
||||
api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("LibreOffice failed to process a document: possible causes include malformed page ranges '%s' (nativePageRanges), or, if a password has been provided, it may not be required. In any case, the exact cause is uncertain.", options.PageRanges)),
|
||||
)
|
||||
}
|
||||
|
||||
if errors.Is(err, libreofficeapi.ErrRuntimeException) {
|
||||
return api.WrapError(
|
||||
fmt.Errorf("convert to PDF: %w", err),
|
||||
api.NewSentinelHttpError(http.StatusBadRequest, "LibreOffice failed to process a document: a password may be required, or, if one has been given, it is invalid. In any case, the exact cause is uncertain."),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -102,9 +206,7 @@ func convertRoute(unoAPI uno.API, engine gotenberg.PDFEngine) api.Route {
|
||||
}
|
||||
}
|
||||
|
||||
// So far so good, let's check if we have to merge the PDFs. Quick
|
||||
// win: if there is only one PDF, skip this step.
|
||||
|
||||
// So far so good, let's check if we have to merge the PDFs.
|
||||
if len(outputPaths) > 1 && merge {
|
||||
outputPath := ctx.GeneratePath(".pdf")
|
||||
|
||||
@@ -113,85 +215,56 @@ func convertRoute(unoAPI uno.API, engine gotenberg.PDFEngine) api.Route {
|
||||
return fmt.Errorf("merge PDFs: %w", err)
|
||||
}
|
||||
|
||||
// Now, let's check if the client want to convert this result
|
||||
// PDF to a specific PDF format.
|
||||
|
||||
// Note: nativePdfA1aFormat/nativePdfFormat have not been
|
||||
// specified if PDFformat is not empty.
|
||||
|
||||
if PDFformat != "" {
|
||||
convertInputPath := outputPath
|
||||
convertOutputPath := ctx.GeneratePath(".pdf")
|
||||
|
||||
err = engine.Convert(ctx, ctx.Log(), PDFformat, convertInputPath, convertOutputPath)
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, gotenberg.ErrPDFFormatNotAvailable) {
|
||||
return api.WrapError(
|
||||
fmt.Errorf("convert PDF: %w", err),
|
||||
api.NewSentinelHTTPError(
|
||||
http.StatusBadRequest,
|
||||
fmt.Sprintf("At least one PDF engine does not handle the PDF format '%s' (pdfFormat), while other have failed to convert for other reasons", PDFformat),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return fmt.Errorf("convert PDF: %w", err)
|
||||
}
|
||||
|
||||
// Important: the output path is now the converted file.
|
||||
outputPath = convertOutputPath
|
||||
}
|
||||
|
||||
// Last but not least, add the output path to the context so that
|
||||
// the API is able to send it as a response to the client.
|
||||
|
||||
err = ctx.AddOutputPaths(outputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("add output path: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
// Only one output path.
|
||||
outputPaths = []string{outputPath}
|
||||
}
|
||||
|
||||
// Ok, we don't have to merge the PDFs. Let's check if the client
|
||||
// want to convert each PDF to a specific PDF format.
|
||||
|
||||
// Note: nativePdfA1aFormat/nativePdfFormat have not been
|
||||
// specified if PDFformat is not empty.
|
||||
|
||||
if PDFformat != "" {
|
||||
// Let's check if the client want to convert each PDF to a specific
|
||||
// PDF format.
|
||||
zeroValued := gotenberg.PdfFormats{}
|
||||
if !nativePdfFormats && pdfFormats != zeroValued {
|
||||
convertOutputPaths := make([]string, len(outputPaths))
|
||||
|
||||
for i, outputPath := range outputPaths {
|
||||
convertInputPath := outputPath
|
||||
convertOutputPaths[i] = ctx.GeneratePath(".pdf")
|
||||
|
||||
err = engine.Convert(ctx, ctx.Log(), PDFformat, convertInputPath, convertOutputPaths[i])
|
||||
|
||||
err = engine.Convert(ctx, ctx.Log(), pdfFormats, convertInputPath, convertOutputPaths[i])
|
||||
if err != nil {
|
||||
if errors.Is(err, gotenberg.ErrPDFFormatNotAvailable) {
|
||||
return api.WrapError(
|
||||
fmt.Errorf("convert PDF: %w", err),
|
||||
api.NewSentinelHTTPError(
|
||||
http.StatusBadRequest,
|
||||
fmt.Sprintf("At least one PDF engine does not handle the PDF format '%s' (pdfFormat), while other have failed to convert for other reasons", PDFformat),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return fmt.Errorf("convert PDF: %w", err)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Important: the output paths are now the converted files.
|
||||
outputPaths = convertOutputPaths
|
||||
}
|
||||
|
||||
// Writes and potentially overrides metadata entries, if any.
|
||||
if len(metadata) > 0 {
|
||||
for _, outputPath := range outputPaths {
|
||||
err = engine.WriteMetadata(ctx, ctx.Log(), metadata, outputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("write metadata: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(outputPaths) > 1 {
|
||||
// If .zip archive, document.docx -> document.docx.pdf.
|
||||
for i, inputPath := range inputPaths {
|
||||
outputPath := fmt.Sprintf("%s.pdf", inputPath)
|
||||
|
||||
err = ctx.Rename(outputPaths[i], outputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("rename output path: %w", err)
|
||||
}
|
||||
|
||||
outputPaths[i] = outputPath
|
||||
}
|
||||
}
|
||||
|
||||
// Last but not least, add the output paths to the context so that
|
||||
// the API is able to send them as a response to the client.
|
||||
|
||||
err = ctx.AddOutputPaths(outputPaths...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("add output paths: %w", err)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +0,0 @@
|
||||
// Package uno provides a module which interacts with the UNO
|
||||
// (Universal Network Objects) API.
|
||||
package uno
|
||||
@@ -1,379 +0,0 @@
|
||||
package uno
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type listener interface {
|
||||
start(logger *zap.Logger) error
|
||||
stop(logger *zap.Logger) error
|
||||
restart(logger *zap.Logger) error
|
||||
lock(ctx context.Context, logger *zap.Logger) error
|
||||
unlock(logger *zap.Logger) error
|
||||
port() int
|
||||
queue() int
|
||||
healthy() bool
|
||||
}
|
||||
|
||||
// TODO: this implementation, even if it's working, is way too complex.
|
||||
type libreOfficeListener struct {
|
||||
binPath string
|
||||
startTimeout time.Duration
|
||||
threshold int
|
||||
|
||||
socketPort int
|
||||
userProfileDirPath string
|
||||
cmd gotenberg.Cmd
|
||||
cfgMu sync.RWMutex
|
||||
|
||||
usage int
|
||||
hadFirstStart bool
|
||||
hadFirstStartMu sync.RWMutex
|
||||
restarting bool
|
||||
restartingMu sync.RWMutex
|
||||
queueLength int
|
||||
queueLengthMu sync.RWMutex
|
||||
lockChan chan struct{}
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func newLibreOfficeListener(logger *zap.Logger, binPath string, startTimeout time.Duration, threshold int) listener {
|
||||
return &libreOfficeListener{
|
||||
binPath: binPath,
|
||||
startTimeout: startTimeout,
|
||||
threshold: threshold,
|
||||
lockChan: make(chan struct{}, 1),
|
||||
logger: logger.Named("listener"),
|
||||
}
|
||||
}
|
||||
|
||||
func (listener *libreOfficeListener) start(logger *zap.Logger) error {
|
||||
listener.hadFirstStartMu.Lock()
|
||||
listener.hadFirstStart = true
|
||||
listener.hadFirstStartMu.Unlock()
|
||||
|
||||
port, err := freePort(logger)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get free port: %w", err)
|
||||
}
|
||||
|
||||
// Good to know: the garbage collector might delete the next directory
|
||||
// while it is still running. It does seem to cause any issue though.
|
||||
userProfileDirPath := gotenberg.NewDirPath()
|
||||
|
||||
args := []string{
|
||||
"--headless",
|
||||
"--invisible",
|
||||
"--nocrashreport",
|
||||
"--nodefault",
|
||||
"--nologo",
|
||||
"--nofirststartwizard",
|
||||
"--norestore",
|
||||
fmt.Sprintf("-env:UserInstallation=file://%s", userProfileDirPath),
|
||||
fmt.Sprintf("--accept=socket,host=127.0.0.1,port=%d,tcpNoDelay=1;urp;StarOffice.ComponentContext", port),
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), listener.startTimeout)
|
||||
defer cancel()
|
||||
|
||||
cmd, err := gotenberg.CommandContext(ctx, logger, listener.binPath, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create LibreOffice listener command: %w", err)
|
||||
}
|
||||
|
||||
// For whatever reason, LibreOffice requires a first start before being
|
||||
// able to run as a daemon.
|
||||
exitCode, err := cmd.Exec()
|
||||
if err != nil && exitCode != 81 {
|
||||
return fmt.Errorf("execute LibreOffice listener: %w", err)
|
||||
}
|
||||
|
||||
logger.Debug("got exit code 81, e.g., LibreOffice listener first start")
|
||||
|
||||
// Second start (daemon).
|
||||
cmd = gotenberg.Command(logger, listener.binPath, args...)
|
||||
|
||||
err = cmd.Start()
|
||||
if err != nil {
|
||||
return fmt.Errorf("start LibreOffice listener: %w", err)
|
||||
}
|
||||
|
||||
waitChan := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
// By waiting the process, we avoid the creation of a zombie process
|
||||
// and make sure we catch an early exit if any.
|
||||
waitChan <- cmd.Wait()
|
||||
}()
|
||||
|
||||
connChan := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
// As the LibreOffice socket may take some time to be available, we
|
||||
// have to ensure that it is indeed accepting connections.
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
connChan <- ctx.Err()
|
||||
break
|
||||
}
|
||||
|
||||
conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), time.Duration(1)*time.Second)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
connChan <- nil
|
||||
err = conn.Close()
|
||||
if err != nil {
|
||||
logger.Debug(fmt.Sprintf("close connection after health checking the LibreOffice listener: %v", err))
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}()
|
||||
|
||||
var success bool
|
||||
|
||||
defer func() {
|
||||
if success {
|
||||
listener.cfgMu.Lock()
|
||||
listener.socketPort = port
|
||||
listener.userProfileDirPath = userProfileDirPath
|
||||
listener.cmd = cmd
|
||||
listener.cfgMu.Unlock()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Let's make sure the process is killed.
|
||||
err = cmd.Kill()
|
||||
if err != nil {
|
||||
logger.Debug(fmt.Sprintf("kill LibreOffice listener process: %v", err))
|
||||
}
|
||||
}()
|
||||
|
||||
logger.Debug("waiting for the LibreOffice listener socket to be available...")
|
||||
|
||||
for {
|
||||
select {
|
||||
case err = <-connChan:
|
||||
if err != nil {
|
||||
return fmt.Errorf("LibreOffice listener socket not available: %w", err)
|
||||
}
|
||||
|
||||
logger.Debug("LibreOffice listener socket available")
|
||||
success = true
|
||||
|
||||
return nil
|
||||
case err = <-waitChan:
|
||||
return fmt.Errorf("LibreOffice listener process exited: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (listener *libreOfficeListener) stop(logger *zap.Logger) error {
|
||||
listener.cfgMu.RLock()
|
||||
|
||||
defer func() {
|
||||
defer listener.cfgMu.RUnlock()
|
||||
|
||||
err := os.RemoveAll(listener.userProfileDirPath)
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("remove LibreOffice listener user profile directory: %v", err))
|
||||
}
|
||||
}()
|
||||
|
||||
err := listener.cmd.Kill()
|
||||
if err != nil {
|
||||
return fmt.Errorf("kill LibreOffice listener process: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (listener *libreOfficeListener) restart(logger *zap.Logger) error {
|
||||
listener.restartingMu.Lock()
|
||||
listener.restarting = true
|
||||
listener.restartingMu.Unlock()
|
||||
|
||||
defer func() {
|
||||
listener.restartingMu.Lock()
|
||||
listener.restarting = false
|
||||
listener.restartingMu.Unlock()
|
||||
}()
|
||||
|
||||
err := listener.stop(logger)
|
||||
if err != nil {
|
||||
return fmt.Errorf("stop LibreOffice listener: %w", err)
|
||||
}
|
||||
|
||||
err = listener.start(logger)
|
||||
if err != nil {
|
||||
return fmt.Errorf("start LibreOffice listener: %w", err)
|
||||
}
|
||||
|
||||
listener.usage = 0
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (listener *libreOfficeListener) lock(ctx context.Context, logger *zap.Logger) error {
|
||||
listener.queueLengthMu.Lock()
|
||||
listener.queueLength += 1
|
||||
listener.queueLengthMu.Unlock()
|
||||
|
||||
defer func() {
|
||||
listener.queueLengthMu.Lock()
|
||||
listener.queueLength -= 1
|
||||
listener.queueLengthMu.Unlock()
|
||||
}()
|
||||
|
||||
doWithContext := func(ctx context.Context, do func() error) error {
|
||||
doChan := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
doChan <- do()
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case err := <-doChan:
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
case listener.lockChan <- struct{}{}:
|
||||
logger.Debug("LibreOffice listener lock acquired")
|
||||
|
||||
listener.hadFirstStartMu.RLock()
|
||||
|
||||
if !listener.hadFirstStart {
|
||||
listener.hadFirstStartMu.RUnlock()
|
||||
|
||||
logger.Debug("starting LibreOffice listener...")
|
||||
|
||||
err := doWithContext(ctx, func() error {
|
||||
return listener.start(logger)
|
||||
})
|
||||
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("start long-running LibreOffice listener: %w", err)
|
||||
}
|
||||
|
||||
listener.hadFirstStartMu.RUnlock()
|
||||
|
||||
if !listener.healthy() {
|
||||
logger.Debug("LibreOffice listener is unhealthy, restarting it...")
|
||||
|
||||
err := doWithContext(ctx, func() error {
|
||||
return listener.restart(logger)
|
||||
})
|
||||
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("restart long-running LibreOffice listener: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
logger.Debug("failed to acquire LibreOffice listener lock before deadline")
|
||||
|
||||
return fmt.Errorf("acquire LibreOffice listener lock: %w", ctx.Err())
|
||||
}
|
||||
}
|
||||
|
||||
func (listener *libreOfficeListener) unlock(logger *zap.Logger) error {
|
||||
defer func() {
|
||||
<-listener.lockChan
|
||||
logger.Debug("LibreOffice listener lock released")
|
||||
}()
|
||||
|
||||
if !listener.healthy() {
|
||||
logger.Debug("LibreOffice listener is unhealthy, restarting it...")
|
||||
|
||||
err := listener.restart(logger)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("restart LibreOffice listener: %w", err)
|
||||
}
|
||||
|
||||
listener.usage += 1
|
||||
if listener.usage < listener.threshold {
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.Debug("LibreOffice listener threshold reached, restarting it...")
|
||||
|
||||
err := listener.restart(logger)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("restart LibreOffice listener: %w", err)
|
||||
}
|
||||
|
||||
func (listener *libreOfficeListener) port() int {
|
||||
listener.cfgMu.RLock()
|
||||
defer listener.cfgMu.RUnlock()
|
||||
|
||||
return listener.socketPort
|
||||
}
|
||||
|
||||
func (listener *libreOfficeListener) queue() int {
|
||||
listener.queueLengthMu.RLock()
|
||||
defer listener.queueLengthMu.RUnlock()
|
||||
|
||||
return listener.queueLength
|
||||
}
|
||||
|
||||
func (listener *libreOfficeListener) healthy() bool {
|
||||
listener.hadFirstStartMu.RLock()
|
||||
defer listener.hadFirstStartMu.RUnlock()
|
||||
|
||||
if !listener.hadFirstStart {
|
||||
return true
|
||||
}
|
||||
|
||||
listener.restartingMu.RLock()
|
||||
defer listener.restartingMu.RUnlock()
|
||||
|
||||
if listener.restarting {
|
||||
return true
|
||||
}
|
||||
|
||||
conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", listener.port()), time.Duration(1)*time.Second)
|
||||
if err == nil {
|
||||
err := conn.Close()
|
||||
if err != nil {
|
||||
listener.logger.Debug(fmt.Sprintf("close connection after health checking the LibreOffice listener: %v", err))
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ listener = (*libreOfficeListener)(nil)
|
||||
)
|
||||
@@ -1,431 +0,0 @@
|
||||
package uno
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestListener_start(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
listener listener
|
||||
expectStartErr bool
|
||||
}{
|
||||
{
|
||||
name: "nominal behavior",
|
||||
listener: newLibreOfficeListener(zap.NewNop(), os.Getenv("LIBREOFFICE_BIN_PATH"), time.Duration(10)*time.Second, 10),
|
||||
},
|
||||
{
|
||||
name: "non-exit code 81 on first start",
|
||||
listener: newLibreOfficeListener(zap.NewNop(), "foo", time.Duration(10)*time.Second, 10),
|
||||
expectStartErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := tc.listener.start(zap.NewNop())
|
||||
|
||||
if tc.expectStartErr && err == nil {
|
||||
t.Fatalf("expected listener.start() error, but got none")
|
||||
}
|
||||
|
||||
if !tc.expectStartErr && err != nil {
|
||||
t.Fatalf("expected no error from listener.start(), but got: %v", err)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if tc.listener.healthy() {
|
||||
t.Error("expected a non-running LibreOffice listener")
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
err = tc.listener.stop(zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error from listener.stop(), but got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestListener_stop(t *testing.T) {
|
||||
listener := newLibreOfficeListener(
|
||||
zap.NewNop(),
|
||||
os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
time.Duration(10)*time.Second,
|
||||
10,
|
||||
)
|
||||
|
||||
err := listener.start(zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error from listener.start(), but got: %v", err)
|
||||
}
|
||||
|
||||
err = listener.stop(zap.NewNop())
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from listener.stop(), but got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListener_restart(t *testing.T) {
|
||||
listener := newLibreOfficeListener(
|
||||
zap.NewNop(),
|
||||
os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
time.Duration(10)*time.Second,
|
||||
10,
|
||||
)
|
||||
|
||||
err := listener.start(zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error from listener.start(), but got: %v", err)
|
||||
}
|
||||
|
||||
err = listener.restart(zap.NewNop())
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from listener.stop(), but got: %v", err)
|
||||
}
|
||||
|
||||
if !listener.healthy() {
|
||||
t.Error("expected an healthy LibreOffice listener")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListener_lock(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
listener listener
|
||||
ctx context.Context
|
||||
teardown func(listener listener) error
|
||||
expectLockErr bool
|
||||
}{
|
||||
{
|
||||
name: "nominal behavior",
|
||||
listener: func() listener {
|
||||
listener := newLibreOfficeListener(zap.NewNop(), os.Getenv("LIBREOFFICE_BIN_PATH"), time.Duration(10)*time.Second, 10)
|
||||
|
||||
err := listener.start(zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error from listener.start(), but got: %v", err)
|
||||
}
|
||||
|
||||
return listener
|
||||
}(),
|
||||
ctx: context.Background(),
|
||||
teardown: func(listener listener) error {
|
||||
return listener.stop(zap.NewNop())
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "first start",
|
||||
listener: newLibreOfficeListener(zap.NewNop(), os.Getenv("LIBREOFFICE_BIN_PATH"), time.Duration(10)*time.Second, 10),
|
||||
ctx: context.Background(),
|
||||
teardown: func(listener listener) error {
|
||||
return listener.stop(zap.NewNop())
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unhealthy listener",
|
||||
listener: func() listener {
|
||||
listener := newLibreOfficeListener(zap.NewNop(), os.Getenv("LIBREOFFICE_BIN_PATH"), time.Duration(10)*time.Second, 10)
|
||||
|
||||
err := listener.start(zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error from listener.start(), but got: %v", err)
|
||||
}
|
||||
|
||||
err = listener.stop(zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error from listener.stop(), but got: %v", err)
|
||||
}
|
||||
|
||||
return listener
|
||||
}(),
|
||||
ctx: context.Background(),
|
||||
teardown: func(listener listener) error {
|
||||
return listener.stop(zap.NewNop())
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "context done",
|
||||
listener: func() listener {
|
||||
listener := newLibreOfficeListener(zap.NewNop(), os.Getenv("LIBREOFFICE_BIN_PATH"), time.Duration(10)*time.Second, 10)
|
||||
|
||||
err := listener.start(zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error from listener.start(), but got: %v", err)
|
||||
}
|
||||
|
||||
err = listener.lock(context.Background(), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error from listener.lock(), but got: %v", err)
|
||||
}
|
||||
|
||||
return listener
|
||||
}(),
|
||||
ctx: func() context.Context {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
return ctx
|
||||
}(),
|
||||
expectLockErr: true,
|
||||
teardown: func(listener listener) error {
|
||||
return listener.stop(zap.NewNop())
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
defer func() {
|
||||
err := tc.teardown(tc.listener)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from tc.teardown(), but got: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
err := tc.listener.lock(tc.ctx, zap.NewNop())
|
||||
|
||||
if tc.expectLockErr && err == nil {
|
||||
t.Fatalf("expected listener.lock() error, but got none")
|
||||
}
|
||||
|
||||
if !tc.expectLockErr && err != nil {
|
||||
t.Fatalf("expected no error from listener.lock(), but got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestListener_unlock(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
listener listener
|
||||
teardown func(listener listener) error
|
||||
}{
|
||||
{
|
||||
name: "nominal behavior",
|
||||
listener: func() listener {
|
||||
listener := newLibreOfficeListener(zap.NewNop(), os.Getenv("LIBREOFFICE_BIN_PATH"), time.Duration(10)*time.Second, 10)
|
||||
|
||||
err := listener.start(zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error from listener.start(), but got: %v", err)
|
||||
}
|
||||
|
||||
err = listener.lock(context.Background(), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error from listener.lock(), but got: %v", err)
|
||||
}
|
||||
|
||||
return listener
|
||||
}(),
|
||||
teardown: func(listener listener) error {
|
||||
return listener.stop(zap.NewNop())
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unhealthy listener",
|
||||
listener: func() listener {
|
||||
listener := newLibreOfficeListener(zap.NewNop(), os.Getenv("LIBREOFFICE_BIN_PATH"), time.Duration(10)*time.Second, 10)
|
||||
|
||||
err := listener.start(zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error from listener.start(), but got: %v", err)
|
||||
}
|
||||
|
||||
err = listener.lock(context.Background(), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error from listener.lock(), but got: %v", err)
|
||||
}
|
||||
|
||||
err = listener.stop(zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error from listener.stop(), but got: %v", err)
|
||||
}
|
||||
|
||||
return listener
|
||||
}(),
|
||||
teardown: func(listener listener) error {
|
||||
return listener.stop(zap.NewNop())
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "threshold reached",
|
||||
listener: func() listener {
|
||||
listener := newLibreOfficeListener(zap.NewNop(), os.Getenv("LIBREOFFICE_BIN_PATH"), time.Duration(10)*time.Second, 1)
|
||||
|
||||
err := listener.start(zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error from listener.start(), but got: %v", err)
|
||||
}
|
||||
|
||||
err = listener.lock(context.Background(), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error from listener.lock(), but got: %v", err)
|
||||
}
|
||||
|
||||
return listener
|
||||
}(),
|
||||
teardown: func(listener listener) error {
|
||||
return listener.stop(zap.NewNop())
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
defer func() {
|
||||
err := tc.teardown(tc.listener)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from tc.teardown(), but got: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
err := tc.listener.unlock(zap.NewNop())
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from listener.unlock(), but got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestListener_port(t *testing.T) {
|
||||
listener := newLibreOfficeListener(
|
||||
zap.NewNop(),
|
||||
os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
time.Duration(10)*time.Second,
|
||||
10,
|
||||
)
|
||||
|
||||
err := listener.start(zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error from listener.start(), but got: %v", err)
|
||||
}
|
||||
|
||||
port := listener.port()
|
||||
if port == 0 {
|
||||
t.Error("expected a non-zero value from listener.port")
|
||||
}
|
||||
|
||||
err = listener.stop(zap.NewNop())
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from listener.stop(), but got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListener_queue(t *testing.T) {
|
||||
listener := newLibreOfficeListener(
|
||||
zap.NewNop(),
|
||||
os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
time.Duration(10)*time.Second,
|
||||
10,
|
||||
)
|
||||
|
||||
err := listener.start(zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error from listener.start(), but got: %v", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err := listener.stop(zap.NewNop())
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from listener.stop(), but got: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
queueLength := listener.queue()
|
||||
if queueLength != 0 {
|
||||
t.Fatalf("expected a zero value from listener.queue(), but got %d", queueLength)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(10)*time.Second)
|
||||
|
||||
err = listener.lock(ctx, zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error from listener.lock(), but got: %v", err)
|
||||
}
|
||||
|
||||
queueLength = listener.queue()
|
||||
if queueLength != 0 {
|
||||
t.Fatalf("expected a zero value from listener.queue(), but got %d", queueLength)
|
||||
}
|
||||
|
||||
go func() {
|
||||
_ = listener.lock(ctx, zap.NewNop())
|
||||
}()
|
||||
|
||||
time.Sleep(time.Duration(100) * time.Millisecond)
|
||||
|
||||
queueLength = listener.queue()
|
||||
if queueLength != 1 {
|
||||
t.Fatalf("expected 1 from listener.queue(), but got %d", queueLength)
|
||||
}
|
||||
|
||||
go func() {
|
||||
_ = listener.lock(ctx, zap.NewNop())
|
||||
}()
|
||||
|
||||
time.Sleep(time.Duration(100) * time.Millisecond)
|
||||
|
||||
queueLength = listener.queue()
|
||||
if queueLength != 2 {
|
||||
t.Fatalf("expected 2 from listener.queue(), but got %d", queueLength)
|
||||
}
|
||||
|
||||
cancel()
|
||||
|
||||
time.Sleep(time.Duration(100) * time.Millisecond)
|
||||
|
||||
queueLength = listener.queue()
|
||||
if queueLength != 0 {
|
||||
t.Fatalf("expected a zero value from listener.queue(), but got %d", queueLength)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListener_healthy(t *testing.T) {
|
||||
listener := &libreOfficeListener{
|
||||
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
startTimeout: time.Duration(10) * time.Second,
|
||||
threshold: 10,
|
||||
lockChan: make(chan struct{}, 1),
|
||||
logger: zap.NewNop(),
|
||||
}
|
||||
|
||||
// i.e., first start.
|
||||
if !listener.healthy() {
|
||||
t.Error("expected an healthy LibreOffice listener")
|
||||
}
|
||||
|
||||
err := listener.start(zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error from listener.start(), but got: %v", err)
|
||||
}
|
||||
|
||||
if !listener.healthy() {
|
||||
t.Error("expected an healthy LibreOffice listener")
|
||||
}
|
||||
|
||||
err = listener.stop(zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error from listener.stop(), but got: %v", err)
|
||||
}
|
||||
|
||||
time.Sleep(time.Duration(1) * time.Second)
|
||||
|
||||
if listener.healthy() {
|
||||
t.Errorf("expected a non-healthy LibreOffice listener")
|
||||
}
|
||||
|
||||
listener.restarting = true
|
||||
|
||||
if !listener.healthy() {
|
||||
t.Error("expected an healthy LibreOffice listener")
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
package uno
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// APIMock is a mock for the API interface.
|
||||
type APIMock struct {
|
||||
PDFMock func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error
|
||||
ExtensionsMock func() []string
|
||||
}
|
||||
|
||||
func (api APIMock) PDF(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error {
|
||||
return api.PDFMock(ctx, logger, inputPath, outputPath, options)
|
||||
}
|
||||
|
||||
func (api APIMock) Extensions() []string {
|
||||
return api.ExtensionsMock()
|
||||
}
|
||||
|
||||
// ProviderMock is a mock for the Provider interface.
|
||||
type ProviderMock struct {
|
||||
UNOMock func() (API, error)
|
||||
}
|
||||
|
||||
func (provider ProviderMock) UNO() (API, error) {
|
||||
return provider.UNOMock()
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ API = (*APIMock)(nil)
|
||||
_ Provider = (*ProviderMock)(nil)
|
||||
)
|
||||
@@ -1,42 +0,0 @@
|
||||
package uno
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestAPIMock(t *testing.T) {
|
||||
mock := APIMock{
|
||||
PDFMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error {
|
||||
return nil
|
||||
},
|
||||
ExtensionsMock: func() []string {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
err := mock.PDF(context.Background(), zap.NewNop(), "", "", Options{})
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from mock.PDF(), but got: %v", err)
|
||||
}
|
||||
|
||||
ext := mock.Extensions()
|
||||
if ext != nil {
|
||||
t.Errorf("expected no extensions from mock.Extensions(), but got: %+v", ext)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderMock(t *testing.T) {
|
||||
mock := ProviderMock{
|
||||
UNOMock: func() (API, error) {
|
||||
return APIMock{}, nil
|
||||
},
|
||||
}
|
||||
|
||||
_, err := mock.UNO()
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from mock.UNO(), but got: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,515 +0,0 @@
|
||||
package uno
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/alexliesenfeld/health"
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/modules/api"
|
||||
flag "github.com/spf13/pflag"
|
||||
"go.uber.org/multierr"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func init() {
|
||||
gotenberg.MustRegisterModule(UNO{})
|
||||
}
|
||||
|
||||
var (
|
||||
// ErrInvalidPDFformat happens if the PDF format option cannot be handled
|
||||
// by LibreOffice.
|
||||
ErrInvalidPDFformat = errors.New("invalid PDF format")
|
||||
|
||||
// ErrMalformedPageRanges happens if the page ranges option cannot be
|
||||
// interpreted by LibreOffice.
|
||||
ErrMalformedPageRanges = errors.New("page ranges are malformed")
|
||||
)
|
||||
|
||||
// UNO is a module which provides an API to interact with LibreOffice.
|
||||
type UNO struct {
|
||||
unoconvBinPath string
|
||||
libreOfficeBinPath string
|
||||
libreOfficeStartTimeout time.Duration
|
||||
libreOfficeRestartThreshold int
|
||||
|
||||
listener listener
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// Options gathers available options when converting a document to PDF.
|
||||
type Options struct {
|
||||
// Landscape allows to change the orientation of the resulting PDF.
|
||||
// Optional.
|
||||
Landscape bool
|
||||
|
||||
// PageRanges allows to select the pages to convert.
|
||||
// TODO: should prefer a method form PDFEngine.
|
||||
// Optional.
|
||||
PageRanges string
|
||||
|
||||
// PDFformat allows to convert the resulting PDF to PDF/A-1a, PDF/A-2b, or
|
||||
// PDF/A-3b.
|
||||
// Optional.
|
||||
PDFformat string
|
||||
}
|
||||
|
||||
// API is an abstraction on top of uno.
|
||||
type API interface {
|
||||
PDF(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error
|
||||
Extensions() []string
|
||||
}
|
||||
|
||||
// Provider is a module interface which exposes a method for creating an API
|
||||
// for other modules.
|
||||
//
|
||||
// func (m *YourModule) Provision(ctx *gotenberg.Context) error {
|
||||
// provider, _ := ctx.Module(new(uno.Provider))
|
||||
// unoAPI, _ := provider.(uno.Provider).UNO()
|
||||
// }
|
||||
type Provider interface {
|
||||
UNO() (API, error)
|
||||
}
|
||||
|
||||
// Descriptor returns a UNO's module descriptor.
|
||||
func (UNO) Descriptor() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{
|
||||
ID: "uno",
|
||||
FlagSet: func() *flag.FlagSet {
|
||||
fs := flag.NewFlagSet("uno", flag.ExitOnError)
|
||||
fs.Duration("uno-listener-start-timeout", time.Duration(10)*time.Second, "Time limit for restarting the LibreOffice listener")
|
||||
fs.Int("uno-listener-restart-threshold", 10, "Conversions limit after which the LibreOffice listener is restarted - 0 means no long-running LibreOffice listener")
|
||||
fs.Bool("unoconv-disable-listener", false, "Do not start a long-running listener - save resources in detriment of unitary performance")
|
||||
|
||||
err := fs.MarkDeprecated("unoconv-disable-listener", "use uno-listener-restart-threshold with 0 instead")
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("create deprecated flags for the uno module: %v", err))
|
||||
}
|
||||
|
||||
return fs
|
||||
}(),
|
||||
New: func() gotenberg.Module { return new(UNO) },
|
||||
}
|
||||
}
|
||||
|
||||
// Provision sets the module properties. It returns an error if the environment
|
||||
// variables UNOCONV_BIN_PATH and LIBREOFFICE_BIN_PATH are not set.
|
||||
func (mod *UNO) Provision(ctx *gotenberg.Context) error {
|
||||
flags := ctx.ParsedFlags()
|
||||
mod.libreOfficeStartTimeout = flags.MustDuration("uno-listener-start-timeout")
|
||||
mod.libreOfficeRestartThreshold = flags.MustInt("uno-listener-restart-threshold")
|
||||
|
||||
disableListener := flags.MustBool("unoconv-disable-listener")
|
||||
if disableListener {
|
||||
mod.libreOfficeRestartThreshold = 0
|
||||
}
|
||||
|
||||
unoconvBinPath, ok := os.LookupEnv("UNOCONV_BIN_PATH")
|
||||
if !ok {
|
||||
return errors.New("UNOCONV_BIN_PATH environment variable is not set")
|
||||
}
|
||||
|
||||
mod.unoconvBinPath = unoconvBinPath
|
||||
|
||||
libreOfficeBinPath, ok := os.LookupEnv("LIBREOFFICE_BIN_PATH")
|
||||
if !ok {
|
||||
return errors.New("LIBREOFFICE_BIN_PATH environment variable is not set")
|
||||
}
|
||||
|
||||
mod.libreOfficeBinPath = libreOfficeBinPath
|
||||
|
||||
loggerProvider, err := ctx.Module(new(gotenberg.LoggerProvider))
|
||||
if err != nil {
|
||||
return fmt.Errorf("get logger provider: %w", err)
|
||||
}
|
||||
|
||||
logger, err := loggerProvider.(gotenberg.LoggerProvider).Logger(mod)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get logger: %w", err)
|
||||
}
|
||||
|
||||
mod.logger = logger
|
||||
|
||||
mod.listener = newLibreOfficeListener(
|
||||
mod.logger,
|
||||
mod.libreOfficeBinPath,
|
||||
mod.libreOfficeStartTimeout,
|
||||
mod.libreOfficeRestartThreshold,
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate validates the module properties.
|
||||
func (mod UNO) Validate() error {
|
||||
var err error
|
||||
|
||||
_, statErr := os.Stat(mod.unoconvBinPath)
|
||||
if os.IsNotExist(statErr) {
|
||||
err = multierr.Append(err, fmt.Errorf("unoconv binary path does not exist: %w", statErr))
|
||||
}
|
||||
|
||||
_, statErr = os.Stat(mod.libreOfficeBinPath)
|
||||
if os.IsNotExist(statErr) {
|
||||
err = multierr.Append(err, fmt.Errorf("LibreOffice binary path does not exist: %w", statErr))
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// Start does nothing: it is here to validate the contract from the
|
||||
// gotenberg.App interface. The long-running LibreOffice Listener will be
|
||||
// started on the first call to PDF.
|
||||
func (mod UNO) Start() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// StartupMessage returns a custom startup message.
|
||||
func (mod UNO) StartupMessage() string {
|
||||
if mod.libreOfficeRestartThreshold == 0 {
|
||||
return "long-running LibreOffice listener disabled"
|
||||
}
|
||||
|
||||
return "long-running LibreOffice listener ready to start"
|
||||
}
|
||||
|
||||
// Stop stops the long-running LibreOffice Listener if it exists.
|
||||
func (mod UNO) Stop(ctx context.Context) error {
|
||||
if mod.libreOfficeRestartThreshold == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Block until the context is done so that other module may gracefully stop
|
||||
// before we do a shutdown cleanup.
|
||||
mod.logger.Debug("wait for the end of grace duration")
|
||||
|
||||
<-ctx.Done()
|
||||
|
||||
err := mod.listener.stop(mod.logger)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("stop long-running LibreOffice listener")
|
||||
}
|
||||
|
||||
// Metrics returns the metrics.
|
||||
func (mod UNO) Metrics() ([]gotenberg.Metric, error) {
|
||||
return []gotenberg.Metric{
|
||||
{
|
||||
Name: "unoconv_active_instances_count",
|
||||
Description: "Current number of active unoconv instances.",
|
||||
Read: func() float64 {
|
||||
activeInstancesCountMu.RLock()
|
||||
defer activeInstancesCountMu.RUnlock()
|
||||
|
||||
return activeInstancesCount
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "libreoffice_listener_active_instances_count",
|
||||
Description: "Current number of active LibreOffice listener instances.",
|
||||
Read: func() float64 {
|
||||
if mod.libreOfficeRestartThreshold == 0 {
|
||||
listenerActiveInstancesCountMu.RLock()
|
||||
defer listenerActiveInstancesCountMu.RUnlock()
|
||||
|
||||
return listenerActiveInstancesCount
|
||||
}
|
||||
|
||||
if mod.listener.healthy() {
|
||||
return 1
|
||||
}
|
||||
|
||||
return 0
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "unoconv_listener_active_instances_count",
|
||||
Description: "Current number of active unoconv listener instances - deprecated, prefer libreoffice_listener_active_instances_count.",
|
||||
Read: func() float64 {
|
||||
if mod.libreOfficeRestartThreshold == 0 {
|
||||
listenerActiveInstancesCountMu.RLock()
|
||||
defer listenerActiveInstancesCountMu.RUnlock()
|
||||
|
||||
return listenerActiveInstancesCount
|
||||
}
|
||||
|
||||
if mod.listener.healthy() {
|
||||
return 1
|
||||
}
|
||||
|
||||
return 0
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "libreoffice_listener_queue_length",
|
||||
Description: "Current number of processes in the LibreOffice listener queue.",
|
||||
Read: func() float64 {
|
||||
return float64(mod.listener.queue())
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "unoconv_listener_queue_length",
|
||||
Description: "Current number of processes in the queue - deprecated, prefer libreoffice_listener_queue_length.",
|
||||
Read: func() float64 {
|
||||
return float64(mod.listener.queue())
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Checks adds a health check that verifies the health of the long-running
|
||||
// LibreOffice listener.
|
||||
func (mod UNO) Checks() ([]health.CheckerOption, error) {
|
||||
if mod.libreOfficeRestartThreshold == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return []health.CheckerOption{
|
||||
health.WithCheck(health.Check{
|
||||
Name: "uno",
|
||||
Check: func(_ context.Context) error {
|
||||
if mod.listener.healthy() {
|
||||
return nil
|
||||
}
|
||||
|
||||
return errors.New("long-running LibreOffice listener unhealthy")
|
||||
},
|
||||
}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// PDF converts a document to PDF.
|
||||
//
|
||||
// If there is no long-running LibreOffice listener, it creates a dedicated
|
||||
// LibreOffice instance for the conversion. Substantial calls to this method
|
||||
// may increase CPU and memory usage drastically
|
||||
//
|
||||
// If there is a long-running LibreOffice listener, the conversion performance
|
||||
// improves substantially. However, it cannot perform parallel operations.
|
||||
func (mod UNO) PDF(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error {
|
||||
args := []string{
|
||||
"--no-launch",
|
||||
"--format",
|
||||
"pdf",
|
||||
}
|
||||
|
||||
switch mod.libreOfficeRestartThreshold {
|
||||
case 0:
|
||||
listener := newLibreOfficeListener(logger, mod.libreOfficeBinPath, mod.libreOfficeStartTimeout, 0)
|
||||
|
||||
err := listener.start(logger)
|
||||
if err != nil {
|
||||
return fmt.Errorf("start LibreOffice listener: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err := listener.stop(logger)
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("stop LibreOffice listener: %v", err))
|
||||
}
|
||||
}()
|
||||
|
||||
args = append(args, "--port", fmt.Sprintf("%d", listener.port()))
|
||||
default:
|
||||
err := mod.listener.lock(ctx, logger)
|
||||
if err != nil {
|
||||
return fmt.Errorf("lock long-running LibreOffice listener: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
go func() {
|
||||
err := mod.listener.unlock(logger)
|
||||
if err != nil {
|
||||
mod.logger.Error(fmt.Sprintf("unlock long-running LibreOffice listener: %v", err))
|
||||
|
||||
return
|
||||
}
|
||||
}()
|
||||
}()
|
||||
|
||||
// If the LibreOffice listener is restarting while acquiring the lock,
|
||||
// the port will change. It's therefore important to add the port args
|
||||
// after we acquire the lock.
|
||||
args = append(args, "--port", fmt.Sprintf("%d", mod.listener.port()))
|
||||
}
|
||||
|
||||
checkedEntry := logger.Check(zap.DebugLevel, "check for debug level before setting high verbosity")
|
||||
if checkedEntry != nil {
|
||||
args = append(args, "-vvv")
|
||||
}
|
||||
|
||||
if options.Landscape {
|
||||
args = append(args, "--printer", "PaperOrientation=landscape")
|
||||
}
|
||||
|
||||
if options.PageRanges != "" {
|
||||
args = append(args, "--export", fmt.Sprintf("PageRange=%s", options.PageRanges))
|
||||
}
|
||||
|
||||
switch options.PDFformat {
|
||||
case "":
|
||||
case gotenberg.FormatPDFA1a:
|
||||
args = append(args, "--export", "SelectPdfVersion=1")
|
||||
case gotenberg.FormatPDFA2b:
|
||||
args = append(args, "--export", "SelectPdfVersion=2")
|
||||
case gotenberg.FormatPDFA3b:
|
||||
args = append(args, "--export", "SelectPdfVersion=3")
|
||||
default:
|
||||
return ErrInvalidPDFformat
|
||||
}
|
||||
|
||||
args = append(args, "--output", outputPath, inputPath)
|
||||
|
||||
cmd, err := gotenberg.CommandContext(ctx, logger, mod.unoconvBinPath, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create unoconv command: %w", err)
|
||||
}
|
||||
|
||||
logger.Debug(fmt.Sprintf("print to PDF with: %+v", options))
|
||||
|
||||
activeInstancesCountMu.Lock()
|
||||
activeInstancesCount += 1
|
||||
activeInstancesCountMu.Unlock()
|
||||
|
||||
exitCode, err := cmd.Exec()
|
||||
|
||||
activeInstancesCountMu.Lock()
|
||||
activeInstancesCount -= 1
|
||||
activeInstancesCountMu.Unlock()
|
||||
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Unoconv/LibreOffice errors are not explicit.
|
||||
// That's why we have to make an educated guess according to the exit code
|
||||
// and given inputs.
|
||||
|
||||
if exitCode == 5 && options.PageRanges != "" {
|
||||
return ErrMalformedPageRanges
|
||||
}
|
||||
|
||||
// Possible errors:
|
||||
// 1. Unoconv/LibreOffice failed for some reason.
|
||||
// 2. Context done.
|
||||
//
|
||||
// On the second scenario, LibreOffice might not have time to remove some
|
||||
// of its temporary files, as it has been killed without warning. The
|
||||
// garbage collector will delete them for us (if the module is loaded).
|
||||
return fmt.Errorf("unoconv PDF: %w", err)
|
||||
}
|
||||
|
||||
// Extensions returns the file extensions available for conversions.
|
||||
func (mod UNO) Extensions() []string {
|
||||
return []string{
|
||||
".bib",
|
||||
".doc",
|
||||
".xml",
|
||||
".docx",
|
||||
".fodt",
|
||||
".html",
|
||||
".ltx",
|
||||
".txt",
|
||||
".odt",
|
||||
".ott",
|
||||
".pdb",
|
||||
".pdf",
|
||||
".psw",
|
||||
".rtf",
|
||||
".sdw",
|
||||
".stw",
|
||||
".sxw",
|
||||
".uot",
|
||||
".vor",
|
||||
".wps",
|
||||
".epub",
|
||||
".png",
|
||||
".bmp",
|
||||
".emf",
|
||||
".eps",
|
||||
".fodg",
|
||||
".gif",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".met",
|
||||
".odd",
|
||||
".otg",
|
||||
".pbm",
|
||||
".pct",
|
||||
".pgm",
|
||||
".ppm",
|
||||
".ras",
|
||||
".std",
|
||||
".svg",
|
||||
".svm",
|
||||
".swf",
|
||||
".sxd",
|
||||
".sxw",
|
||||
".tif",
|
||||
".tiff",
|
||||
".xhtml",
|
||||
".xpm",
|
||||
".odp",
|
||||
".fodp",
|
||||
".potm",
|
||||
".pot",
|
||||
".pptx",
|
||||
".pps",
|
||||
".ppt",
|
||||
".pwp",
|
||||
".sda",
|
||||
".sdd",
|
||||
".sti",
|
||||
".sxi",
|
||||
".uop",
|
||||
".wmf",
|
||||
".csv",
|
||||
".dbf",
|
||||
".dif",
|
||||
".fods",
|
||||
".ods",
|
||||
".ots",
|
||||
".pxl",
|
||||
".sdc",
|
||||
".slk",
|
||||
".stc",
|
||||
".sxc",
|
||||
".uos",
|
||||
".xls",
|
||||
".xlt",
|
||||
".xlsx",
|
||||
".odg",
|
||||
".dotx",
|
||||
".xltx",
|
||||
}
|
||||
}
|
||||
|
||||
// UNO returns an API for interacting with LibreOffice.
|
||||
func (mod UNO) UNO() (API, error) {
|
||||
return mod, nil
|
||||
}
|
||||
|
||||
var (
|
||||
listenerActiveInstancesCount float64
|
||||
listenerActiveInstancesCountMu sync.RWMutex
|
||||
activeInstancesCount float64
|
||||
activeInstancesCountMu sync.RWMutex
|
||||
)
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ gotenberg.Module = (*UNO)(nil)
|
||||
_ gotenberg.Provisioner = (*UNO)(nil)
|
||||
_ gotenberg.Validator = (*UNO)(nil)
|
||||
_ gotenberg.App = (*UNO)(nil)
|
||||
_ gotenberg.MetricsProvider = (*UNO)(nil)
|
||||
_ api.HealthChecker = (*UNO)(nil)
|
||||
_ API = (*UNO)(nil)
|
||||
_ Provider = (*UNO)(nil)
|
||||
)
|
||||
@@ -1,822 +0,0 @@
|
||||
package uno
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alexliesenfeld/health"
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
||||
flag "github.com/spf13/pflag"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestUNO_Descriptor(t *testing.T) {
|
||||
descriptor := UNO{}.Descriptor()
|
||||
|
||||
actual := reflect.TypeOf(descriptor.New())
|
||||
expect := reflect.TypeOf(new(UNO))
|
||||
|
||||
if actual != expect {
|
||||
t.Errorf("expected '%s' but got '%s'", expect, actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUNO_Provision(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ctx *gotenberg.Context
|
||||
expectProvisionErr bool
|
||||
}{
|
||||
{
|
||||
name: "nominal behavior",
|
||||
ctx: func() *gotenberg.Context {
|
||||
provider := struct {
|
||||
gotenberg.ModuleMock
|
||||
gotenberg.LoggerProviderMock
|
||||
}{}
|
||||
provider.DescriptorMock = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
|
||||
return provider
|
||||
}}
|
||||
}
|
||||
provider.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) {
|
||||
return zap.NewNop(), nil
|
||||
}
|
||||
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: new(UNO).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{
|
||||
provider.Descriptor(),
|
||||
},
|
||||
)
|
||||
}(),
|
||||
},
|
||||
{
|
||||
name: "threshold from deprecated flag --unoconv-disable-listener",
|
||||
ctx: func() *gotenberg.Context {
|
||||
provider := struct {
|
||||
gotenberg.ModuleMock
|
||||
gotenberg.LoggerProviderMock
|
||||
}{}
|
||||
provider.DescriptorMock = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
|
||||
return provider
|
||||
}}
|
||||
}
|
||||
provider.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) {
|
||||
return zap.NewNop(), nil
|
||||
}
|
||||
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: func() *flag.FlagSet {
|
||||
fs := new(UNO).Descriptor().FlagSet
|
||||
err := fs.Parse([]string{"--unoconv-disable-listener=true"})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error from fs.Parse(), but got: %v", err)
|
||||
}
|
||||
|
||||
return fs
|
||||
}(),
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{
|
||||
provider.Descriptor(),
|
||||
},
|
||||
)
|
||||
}(),
|
||||
},
|
||||
{
|
||||
name: "no logger provider",
|
||||
ctx: func() *gotenberg.Context {
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: new(UNO).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{},
|
||||
)
|
||||
}(),
|
||||
expectProvisionErr: true,
|
||||
},
|
||||
{
|
||||
name: "no logger from logger provider",
|
||||
ctx: func() *gotenberg.Context {
|
||||
provider := struct {
|
||||
gotenberg.ModuleMock
|
||||
gotenberg.LoggerProviderMock
|
||||
}{}
|
||||
provider.DescriptorMock = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
|
||||
return provider
|
||||
}}
|
||||
}
|
||||
provider.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) {
|
||||
return nil, errors.New("foo")
|
||||
}
|
||||
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: new(UNO).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{
|
||||
provider.Descriptor(),
|
||||
},
|
||||
)
|
||||
}(),
|
||||
expectProvisionErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
mod := new(UNO)
|
||||
err := mod.Provision(tc.ctx)
|
||||
|
||||
if tc.expectProvisionErr && err == nil {
|
||||
t.Errorf("expected mod.Provision() error, but got none")
|
||||
}
|
||||
|
||||
if !tc.expectProvisionErr && err != nil {
|
||||
t.Errorf("expected no error from mod.Provision(), but got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUNO_Validate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
unoconvBinPath string
|
||||
libreOfficeBinPath string
|
||||
expectValidateErr bool
|
||||
}{
|
||||
{
|
||||
name: "nominal behavior",
|
||||
unoconvBinPath: os.Getenv("UNOCONV_BIN_PATH"),
|
||||
libreOfficeBinPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
},
|
||||
{
|
||||
name: "unoconv bin path does not exist",
|
||||
unoconvBinPath: "/foo",
|
||||
libreOfficeBinPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
expectValidateErr: true,
|
||||
},
|
||||
{
|
||||
name: "LibreOffice bin path does not exist",
|
||||
unoconvBinPath: os.Getenv("UNOCONV_BIN_PATH"),
|
||||
libreOfficeBinPath: "/foo",
|
||||
expectValidateErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
mod := UNO{
|
||||
unoconvBinPath: tc.unoconvBinPath,
|
||||
libreOfficeBinPath: tc.libreOfficeBinPath,
|
||||
}
|
||||
|
||||
err := mod.Validate()
|
||||
|
||||
if tc.expectValidateErr && err == nil {
|
||||
t.Errorf("expected mod.Validate() error, but got none")
|
||||
}
|
||||
|
||||
if !tc.expectValidateErr && err != nil {
|
||||
t.Errorf("expected no error from mod.Validate(), but got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUNO_Start(t *testing.T) {
|
||||
mod := new(UNO)
|
||||
|
||||
err := mod.Start()
|
||||
if err != nil {
|
||||
t.Errorf("expected no error but got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUNO_StartupMessage(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mod UNO
|
||||
expectMessage string
|
||||
}{
|
||||
{
|
||||
name: "long-running LibreOffice listener ready to start",
|
||||
mod: UNO{
|
||||
libreOfficeRestartThreshold: 10,
|
||||
},
|
||||
expectMessage: "long-running LibreOffice listener ready to start",
|
||||
},
|
||||
{
|
||||
name: "long-running LibreOffice listener disabled",
|
||||
mod: UNO{
|
||||
libreOfficeRestartThreshold: 0,
|
||||
},
|
||||
expectMessage: "long-running LibreOffice listener disabled",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
actual := tc.mod.StartupMessage()
|
||||
|
||||
if tc.expectMessage != actual {
|
||||
t.Errorf("expected '%s' from mod.StartupMessage(), but got '%s'", tc.expectMessage, actual)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUNO_Stop(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mod UNO
|
||||
expectStopErr bool
|
||||
}{
|
||||
{
|
||||
name: "nominal behavior",
|
||||
mod: UNO{
|
||||
libreOfficeRestartThreshold: 10,
|
||||
listener: listenerMock{
|
||||
stopMock: func(logger *zap.Logger) error {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
logger: zap.NewNop(),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no long-running LibreOffice listener",
|
||||
mod: UNO{
|
||||
libreOfficeRestartThreshold: 0,
|
||||
logger: zap.NewNop(),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "stop error",
|
||||
mod: UNO{
|
||||
libreOfficeRestartThreshold: 10,
|
||||
listener: listenerMock{
|
||||
stopMock: func(logger *zap.Logger) error {
|
||||
return errors.New("foo")
|
||||
},
|
||||
},
|
||||
logger: zap.NewNop(),
|
||||
},
|
||||
expectStopErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(10)*time.Second)
|
||||
cancel()
|
||||
|
||||
err := tc.mod.Stop(ctx)
|
||||
|
||||
if tc.expectStopErr && err == nil {
|
||||
t.Errorf("expected mod.Stop() error, but got none")
|
||||
}
|
||||
|
||||
if !tc.expectStopErr && err != nil {
|
||||
t.Errorf("expected no error from mod.Stop(), but got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUNO_Metrics(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mod UNO
|
||||
expectUnoconvActiveInstancesCount float64
|
||||
expectLibreOfficeListenerActiveInstancesCount float64
|
||||
expectLibreOfficeListenerQueueLength float64
|
||||
}{
|
||||
{
|
||||
name: "with healthy long-running LibreOffice listener",
|
||||
mod: UNO{
|
||||
libreOfficeRestartThreshold: 10,
|
||||
listener: listenerMock{
|
||||
queueMock: func() int {
|
||||
return 0
|
||||
},
|
||||
healthyMock: func() bool {
|
||||
return true
|
||||
},
|
||||
},
|
||||
},
|
||||
expectLibreOfficeListenerActiveInstancesCount: 1,
|
||||
},
|
||||
{
|
||||
name: "with unhealthy long-running LibreOffice listener",
|
||||
mod: UNO{
|
||||
libreOfficeRestartThreshold: 10,
|
||||
listener: listenerMock{
|
||||
queueMock: func() int {
|
||||
return 0
|
||||
},
|
||||
healthyMock: func() bool {
|
||||
return false
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with no long-running LibreOffice listener",
|
||||
mod: UNO{
|
||||
libreOfficeRestartThreshold: 0,
|
||||
listener: listenerMock{
|
||||
queueMock: func() int {
|
||||
return 0
|
||||
},
|
||||
healthyMock: func() bool {
|
||||
return false
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with a queue of 3",
|
||||
mod: UNO{
|
||||
libreOfficeRestartThreshold: 0,
|
||||
listener: listenerMock{
|
||||
queueMock: func() int {
|
||||
return 3
|
||||
},
|
||||
healthyMock: func() bool {
|
||||
return true
|
||||
},
|
||||
},
|
||||
},
|
||||
expectLibreOfficeListenerQueueLength: 3,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
metrics, err := tc.mod.Metrics()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error from mod.Metrics(), but got: %v", err)
|
||||
}
|
||||
|
||||
for _, metric := range metrics {
|
||||
switch metric.Name {
|
||||
case "unoconv_active_instances_count":
|
||||
actual := metric.Read()
|
||||
if actual != tc.expectUnoconvActiveInstancesCount {
|
||||
t.Errorf("expected 'unoconv_active_instances_count' to be %.0f, but got %.0f", tc.expectUnoconvActiveInstancesCount, actual)
|
||||
}
|
||||
case "libreoffice_listener_active_instances_count":
|
||||
actual := metric.Read()
|
||||
if actual != tc.expectLibreOfficeListenerActiveInstancesCount {
|
||||
t.Errorf("expected 'libreoffice_listener_active_instances_count' to be %.0f, but got %.0f", tc.expectLibreOfficeListenerActiveInstancesCount, actual)
|
||||
}
|
||||
case "unoconv_listener_active_instances_count":
|
||||
actual := metric.Read()
|
||||
if actual != tc.expectLibreOfficeListenerActiveInstancesCount {
|
||||
t.Errorf("expected 'unoconv_listener_active_instances_count' to be %.0f, but got %.0f", tc.expectLibreOfficeListenerActiveInstancesCount, actual)
|
||||
}
|
||||
case "libreoffice_listener_queue_length":
|
||||
actual := metric.Read()
|
||||
if actual != tc.expectLibreOfficeListenerQueueLength {
|
||||
t.Errorf("expected 'libreoffice_listener_queue_length' to be %.0f, but got %.0f", tc.expectLibreOfficeListenerQueueLength, actual)
|
||||
}
|
||||
case "unoconv_listener_queue_length":
|
||||
actual := metric.Read()
|
||||
if actual != tc.expectLibreOfficeListenerQueueLength {
|
||||
t.Errorf("expected 'unoconv_listener_queue_length' to be %.0f, but got %.0f", tc.expectLibreOfficeListenerQueueLength, actual)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUNO_Checks(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mod UNO
|
||||
expectAvailabilityStatus health.AvailabilityStatus
|
||||
}{
|
||||
{
|
||||
name: "no long-running LibreOffice listener",
|
||||
mod: UNO{
|
||||
libreOfficeRestartThreshold: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with healthy long-running LibreOffice listener",
|
||||
mod: UNO{
|
||||
libreOfficeRestartThreshold: 10,
|
||||
listener: listenerMock{
|
||||
healthyMock: func() bool {
|
||||
return true
|
||||
},
|
||||
},
|
||||
},
|
||||
expectAvailabilityStatus: health.StatusUp,
|
||||
},
|
||||
{
|
||||
name: "with unhealthy long-running LibreOffice listener",
|
||||
mod: UNO{
|
||||
libreOfficeRestartThreshold: 10,
|
||||
listener: listenerMock{
|
||||
healthyMock: func() bool {
|
||||
return false
|
||||
},
|
||||
},
|
||||
},
|
||||
expectAvailabilityStatus: health.StatusDown,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
checks, err := tc.mod.Checks()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error from mod.Checks(), but got: %v", err)
|
||||
}
|
||||
|
||||
if len(checks) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
if len(checks) != 1 {
|
||||
t.Fatalf("expected 1 check from mod.Checks(), but got %d", len(checks))
|
||||
}
|
||||
|
||||
checker := health.NewChecker(checks...)
|
||||
result := checker.Check(context.Background())
|
||||
|
||||
if result.Status != tc.expectAvailabilityStatus {
|
||||
t.Errorf("expected '%s' as availability status, but got '%s'", tc.expectAvailabilityStatus, result.Status)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUNO_PDF(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mod UNO
|
||||
ctx context.Context
|
||||
logger *zap.Logger
|
||||
inputPath string
|
||||
options Options
|
||||
expectPDFErr bool
|
||||
teardown func(mod UNO) error
|
||||
}{
|
||||
{
|
||||
name: "nominal behavior with no long-running LibreOffice listener",
|
||||
mod: UNO{
|
||||
unoconvBinPath: os.Getenv("UNOCONV_BIN_PATH"),
|
||||
libreOfficeBinPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
libreOfficeStartTimeout: time.Duration(10) * time.Second,
|
||||
libreOfficeRestartThreshold: 0,
|
||||
},
|
||||
ctx: context.Background(),
|
||||
logger: zap.NewNop(),
|
||||
inputPath: "/tests/test/testdata/libreoffice/sample1.docx",
|
||||
},
|
||||
{
|
||||
name: "nominal behavior with a long-running LibreOffice listener",
|
||||
mod: func() UNO {
|
||||
mod := UNO{
|
||||
unoconvBinPath: os.Getenv("UNOCONV_BIN_PATH"),
|
||||
libreOfficeBinPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
libreOfficeStartTimeout: time.Duration(10) * time.Second,
|
||||
libreOfficeRestartThreshold: 10,
|
||||
logger: zap.NewNop(),
|
||||
}
|
||||
mod.listener = newLibreOfficeListener(
|
||||
mod.logger,
|
||||
mod.libreOfficeBinPath,
|
||||
mod.libreOfficeStartTimeout,
|
||||
mod.libreOfficeRestartThreshold,
|
||||
)
|
||||
|
||||
return mod
|
||||
}(),
|
||||
ctx: context.Background(),
|
||||
logger: zap.NewNop(),
|
||||
inputPath: "/tests/test/testdata/libreoffice/sample1.docx",
|
||||
teardown: func(mod UNO) error {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
return mod.Stop(ctx)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "convert with a debug logger",
|
||||
mod: UNO{
|
||||
unoconvBinPath: os.Getenv("UNOCONV_BIN_PATH"),
|
||||
libreOfficeBinPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
libreOfficeStartTimeout: time.Duration(10) * time.Second,
|
||||
libreOfficeRestartThreshold: 0,
|
||||
},
|
||||
ctx: context.Background(),
|
||||
logger: zap.NewExample(),
|
||||
inputPath: "/tests/test/testdata/libreoffice/sample1.docx",
|
||||
},
|
||||
{
|
||||
name: "convert with landscape",
|
||||
mod: UNO{
|
||||
unoconvBinPath: os.Getenv("UNOCONV_BIN_PATH"),
|
||||
libreOfficeBinPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
libreOfficeStartTimeout: time.Duration(10) * time.Second,
|
||||
libreOfficeRestartThreshold: 0,
|
||||
},
|
||||
ctx: context.Background(),
|
||||
logger: zap.NewNop(),
|
||||
inputPath: "/tests/test/testdata/libreoffice/sample1.docx",
|
||||
options: Options{
|
||||
Landscape: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "convert with page ranges",
|
||||
mod: UNO{
|
||||
unoconvBinPath: os.Getenv("UNOCONV_BIN_PATH"),
|
||||
libreOfficeBinPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
libreOfficeStartTimeout: time.Duration(10) * time.Second,
|
||||
libreOfficeRestartThreshold: 0,
|
||||
},
|
||||
ctx: context.Background(),
|
||||
logger: zap.NewNop(),
|
||||
inputPath: "/tests/test/testdata/libreoffice/sample1.docx",
|
||||
options: Options{
|
||||
PageRanges: "1-2",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "convert with invalid page ranges",
|
||||
mod: UNO{
|
||||
unoconvBinPath: os.Getenv("UNOCONV_BIN_PATH"),
|
||||
libreOfficeBinPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
libreOfficeStartTimeout: time.Duration(10) * time.Second,
|
||||
libreOfficeRestartThreshold: 0,
|
||||
},
|
||||
ctx: context.Background(),
|
||||
logger: zap.NewNop(),
|
||||
inputPath: "/tests/test/testdata/libreoffice/sample1.docx",
|
||||
options: Options{
|
||||
PageRanges: "foo",
|
||||
},
|
||||
expectPDFErr: true,
|
||||
},
|
||||
{
|
||||
name: "convert to PDF/A-1a",
|
||||
mod: UNO{
|
||||
unoconvBinPath: os.Getenv("UNOCONV_BIN_PATH"),
|
||||
libreOfficeBinPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
libreOfficeStartTimeout: time.Duration(10) * time.Second,
|
||||
libreOfficeRestartThreshold: 0,
|
||||
},
|
||||
ctx: context.Background(),
|
||||
logger: zap.NewNop(),
|
||||
inputPath: "/tests/test/testdata/libreoffice/sample1.docx",
|
||||
options: Options{
|
||||
PDFformat: gotenberg.FormatPDFA1a,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "convert to PDF/A-2b",
|
||||
mod: UNO{
|
||||
unoconvBinPath: os.Getenv("UNOCONV_BIN_PATH"),
|
||||
libreOfficeBinPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
libreOfficeStartTimeout: time.Duration(10) * time.Second,
|
||||
libreOfficeRestartThreshold: 0,
|
||||
},
|
||||
ctx: context.Background(),
|
||||
logger: zap.NewNop(),
|
||||
inputPath: "/tests/test/testdata/libreoffice/sample1.docx",
|
||||
options: Options{
|
||||
PDFformat: gotenberg.FormatPDFA2b,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "convert to PDF/A-3b",
|
||||
mod: UNO{
|
||||
unoconvBinPath: os.Getenv("UNOCONV_BIN_PATH"),
|
||||
libreOfficeBinPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
libreOfficeStartTimeout: time.Duration(10) * time.Second,
|
||||
libreOfficeRestartThreshold: 0,
|
||||
},
|
||||
ctx: context.Background(),
|
||||
logger: zap.NewNop(),
|
||||
inputPath: "/tests/test/testdata/libreoffice/sample1.docx",
|
||||
options: Options{
|
||||
PDFformat: gotenberg.FormatPDFA3b,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "convert to invalid PDF format",
|
||||
mod: UNO{
|
||||
unoconvBinPath: os.Getenv("UNOCONV_BIN_PATH"),
|
||||
libreOfficeBinPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
libreOfficeStartTimeout: time.Duration(10) * time.Second,
|
||||
libreOfficeRestartThreshold: 0,
|
||||
},
|
||||
ctx: context.Background(),
|
||||
logger: zap.NewNop(),
|
||||
inputPath: "/tests/test/testdata/libreoffice/sample1.docx",
|
||||
options: Options{
|
||||
PDFformat: "foo",
|
||||
},
|
||||
expectPDFErr: true,
|
||||
},
|
||||
{
|
||||
name: "nil context",
|
||||
mod: UNO{
|
||||
unoconvBinPath: os.Getenv("UNOCONV_BIN_PATH"),
|
||||
libreOfficeBinPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
libreOfficeStartTimeout: time.Duration(10) * time.Second,
|
||||
libreOfficeRestartThreshold: 0,
|
||||
},
|
||||
ctx: nil,
|
||||
logger: zap.NewNop(),
|
||||
inputPath: "/tests/test/testdata/libreoffice/sample1.docx",
|
||||
expectPDFErr: true,
|
||||
},
|
||||
{
|
||||
name: "expired context",
|
||||
mod: UNO{
|
||||
unoconvBinPath: os.Getenv("UNOCONV_BIN_PATH"),
|
||||
libreOfficeBinPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
libreOfficeStartTimeout: time.Duration(10) * time.Second,
|
||||
libreOfficeRestartThreshold: 0,
|
||||
},
|
||||
ctx: func() context.Context {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
return ctx
|
||||
}(),
|
||||
logger: zap.NewNop(),
|
||||
inputPath: "/tests/test/testdata/libreoffice/sample1.docx",
|
||||
expectPDFErr: true,
|
||||
},
|
||||
{
|
||||
name: "cannot lock long-running LibreOffice listener",
|
||||
mod: UNO{
|
||||
unoconvBinPath: os.Getenv("UNOCONV_BIN_PATH"),
|
||||
libreOfficeBinPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
libreOfficeStartTimeout: time.Duration(10) * time.Second,
|
||||
libreOfficeRestartThreshold: 10,
|
||||
listener: listenerMock{
|
||||
lockMock: func(ctx context.Context, logger *zap.Logger) error {
|
||||
return errors.New("foo")
|
||||
},
|
||||
},
|
||||
logger: zap.NewNop(),
|
||||
},
|
||||
ctx: context.Background(),
|
||||
logger: zap.NewNop(),
|
||||
inputPath: "/tests/test/testdata/libreoffice/sample1.docx",
|
||||
expectPDFErr: true,
|
||||
},
|
||||
{
|
||||
name: "cannot unlock long-running LibreOffice listener",
|
||||
mod: UNO{
|
||||
unoconvBinPath: os.Getenv("UNOCONV_BIN_PATH"),
|
||||
libreOfficeBinPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
libreOfficeStartTimeout: time.Duration(10) * time.Second,
|
||||
libreOfficeRestartThreshold: 10,
|
||||
listener: listenerMock{
|
||||
lockMock: func(ctx context.Context, logger *zap.Logger) error {
|
||||
return nil
|
||||
},
|
||||
unlockMock: func(logger *zap.Logger) error {
|
||||
return errors.New("foo")
|
||||
},
|
||||
portMock: func() int {
|
||||
return 2002
|
||||
},
|
||||
},
|
||||
logger: zap.NewNop(),
|
||||
},
|
||||
ctx: context.Background(),
|
||||
logger: zap.NewNop(),
|
||||
inputPath: "/tests/test/testdata/libreoffice/sample1.docx",
|
||||
expectPDFErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
defer func() {
|
||||
if tc.teardown == nil {
|
||||
return
|
||||
}
|
||||
|
||||
err := tc.teardown(tc.mod)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from tc.teardown(), but got: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
outputDir, err := gotenberg.MkdirAll()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error from gotenberg.MkdirAll(), but got: %v", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err := os.RemoveAll(outputDir)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from os.RemoveAll(), but got: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
err = tc.mod.PDF(tc.ctx, tc.logger, tc.inputPath, outputDir+"/foo.pdf", tc.options)
|
||||
|
||||
if tc.expectPDFErr && err == nil {
|
||||
t.Fatalf("expected mod.PDF() error, but got none")
|
||||
}
|
||||
|
||||
if !tc.expectPDFErr && err != nil {
|
||||
t.Fatalf("expected no error from mod.PDF(), but got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUNO_Extensions(t *testing.T) {
|
||||
mod := new(UNO)
|
||||
extensions := mod.Extensions()
|
||||
|
||||
actual := len(extensions)
|
||||
expect := 79
|
||||
|
||||
if actual != expect {
|
||||
t.Errorf("expected %d extensions, but got %d", expect, actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUNO_UNO(t *testing.T) {
|
||||
mod := new(UNO)
|
||||
|
||||
_, err := mod.UNO()
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from mod.UNO(), but got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type listenerMock struct {
|
||||
startMock func(logger *zap.Logger) error
|
||||
stopMock func(logger *zap.Logger) error
|
||||
restartMock func(logger *zap.Logger) error
|
||||
lockMock func(ctx context.Context, logger *zap.Logger) error
|
||||
unlockMock func(logger *zap.Logger) error
|
||||
portMock func() int
|
||||
queueMock func() int
|
||||
healthyMock func() bool
|
||||
}
|
||||
|
||||
func (listener listenerMock) start(logger *zap.Logger) error {
|
||||
return listener.startMock(logger)
|
||||
}
|
||||
|
||||
func (listener listenerMock) stop(logger *zap.Logger) error {
|
||||
return listener.stopMock(logger)
|
||||
}
|
||||
|
||||
func (listener listenerMock) restart(logger *zap.Logger) error {
|
||||
return listener.restartMock(logger)
|
||||
}
|
||||
|
||||
func (listener listenerMock) lock(ctx context.Context, logger *zap.Logger) error {
|
||||
return listener.lockMock(ctx, logger)
|
||||
}
|
||||
|
||||
func (listener listenerMock) unlock(logger *zap.Logger) error {
|
||||
return listener.unlockMock(logger)
|
||||
}
|
||||
|
||||
func (listener listenerMock) port() int {
|
||||
return listener.portMock()
|
||||
}
|
||||
|
||||
func (listener listenerMock) queue() int {
|
||||
return listener.queueMock()
|
||||
}
|
||||
|
||||
func (listener listenerMock) healthy() bool {
|
||||
return listener.healthyMock()
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ listener = (*listenerMock)(nil)
|
||||
)
|
||||
@@ -1,3 +1,3 @@
|
||||
// Package logging provides a module which creates a zap.Logger for other
|
||||
// modules.
|
||||
// Package logging provides a module which creates a zap.Logger instance for
|
||||
// other modules.
|
||||
package logging
|
||||
|
||||
@@ -5,16 +5,17 @@ import (
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
||||
flag "github.com/spf13/pflag"
|
||||
"go.uber.org/multierr"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
"golang.org/x/term"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
|
||||
func init() {
|
||||
gotenberg.MustRegisterModule(Logging{})
|
||||
gotenberg.MustRegisterModule(new(Logging))
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -30,15 +31,16 @@ const (
|
||||
textLoggingFormat = "text"
|
||||
)
|
||||
|
||||
// Logging is a module which implements the gotenberg.LoggerProvider interface.
|
||||
// Logging is a module which implements the [gotenberg.LoggerProvider]
|
||||
// interface.
|
||||
type Logging struct {
|
||||
level string
|
||||
format string
|
||||
fieldsPrefix string
|
||||
}
|
||||
|
||||
// Descriptor returns a Logging's module descriptor.
|
||||
func (Logging) Descriptor() gotenberg.ModuleDescriptor {
|
||||
// Descriptor returns a [Logging]'s module descriptor.
|
||||
func (log *Logging) Descriptor() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{
|
||||
ID: "logging",
|
||||
FlagSet: func() *flag.FlagSet {
|
||||
@@ -65,7 +67,7 @@ func (log *Logging) Provision(ctx *gotenberg.Context) error {
|
||||
}
|
||||
|
||||
// Validate validates the log level and format.
|
||||
func (log Logging) Validate() error {
|
||||
func (log *Logging) Validate() error {
|
||||
var err error
|
||||
|
||||
switch log.level {
|
||||
@@ -91,8 +93,8 @@ func (log Logging) Validate() error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Logger returns a zap.Logger.
|
||||
func (log Logging) Logger(mod gotenberg.Module) (*zap.Logger, error) {
|
||||
// Logger returns a [zap.Logger].
|
||||
func (log *Logging) Logger(mod gotenberg.Module) (*zap.Logger, error) {
|
||||
if logger == nil {
|
||||
lvl, err := newLogLevel(log.level)
|
||||
if err != nil {
|
||||
@@ -172,7 +174,7 @@ func newLogEncoder(format string) (zapcore.Encoder, error) {
|
||||
// If interactive terminal, make output more human-readable by default.
|
||||
// Credits: https://github.com/caddyserver/caddy/blob/v2.1.1/logging.go#L671.
|
||||
encCfg.EncodeTime = func(ts time.Time, encoder zapcore.PrimitiveArrayEncoder) {
|
||||
encoder.AppendString(ts.UTC().Format("2006/01/02 15:04:05.000"))
|
||||
encoder.AppendString(ts.Local().Format("2006/01/02 15:04:05.000"))
|
||||
}
|
||||
|
||||
if format == textLoggingFormat || format == autoLoggingFormat {
|
||||
|
||||
@@ -5,14 +5,16 @@ import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
"go.uber.org/zap/zaptest/observer"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
|
||||
func TestLogging_Descriptor(t *testing.T) {
|
||||
descriptor := Logging{}.Descriptor()
|
||||
descriptor := new(Logging).Descriptor()
|
||||
|
||||
actual := reflect.TypeOf(descriptor.New())
|
||||
expect := reflect.TypeOf(new(Logging))
|
||||
|
||||
@@ -55,66 +57,68 @@ func TestLogging_Provision(t *testing.T) {
|
||||
expectFieldsPrefix: "",
|
||||
},
|
||||
} {
|
||||
var flags []string
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
var flags []string
|
||||
|
||||
if tc.level != "" {
|
||||
flags = append(flags, "--log-level", tc.level)
|
||||
}
|
||||
if tc.level != "" {
|
||||
flags = append(flags, "--log-level", tc.level)
|
||||
}
|
||||
|
||||
if tc.format != "" {
|
||||
flags = append(flags, "--log-format", tc.format)
|
||||
}
|
||||
if tc.format != "" {
|
||||
flags = append(flags, "--log-format", tc.format)
|
||||
}
|
||||
|
||||
if tc.fieldsPrefix != "" {
|
||||
flags = append(flags, "--log-fields-prefix", tc.fieldsPrefix)
|
||||
}
|
||||
if tc.fieldsPrefix != "" {
|
||||
flags = append(flags, "--log-fields-prefix", tc.fieldsPrefix)
|
||||
}
|
||||
|
||||
logging := new(Logging)
|
||||
fs := logging.Descriptor().FlagSet
|
||||
logging := new(Logging)
|
||||
fs := logging.Descriptor().FlagSet
|
||||
|
||||
err := fs.Parse(flags)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: expected no error but got: %v", tc.scenario, err)
|
||||
}
|
||||
err := fs.Parse(flags)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error while parsing flags but got: %v", err)
|
||||
}
|
||||
|
||||
ctx := gotenberg.NewContext(gotenberg.ParsedFlags{FlagSet: fs}, nil)
|
||||
ctx := gotenberg.NewContext(gotenberg.ParsedFlags{FlagSet: fs}, nil)
|
||||
|
||||
err = logging.Provision(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: expected no error but got: %v", tc.scenario, err)
|
||||
}
|
||||
err = logging.Provision(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error while provisioning but got: %v", err)
|
||||
}
|
||||
|
||||
if logging.level != tc.expectLevel {
|
||||
t.Errorf("%s: expected '%s' but got '%s'", tc.scenario, tc.expectLevel, logging.level)
|
||||
}
|
||||
if logging.level != tc.expectLevel {
|
||||
t.Errorf("expected logging level '%s' but got '%s'", tc.expectLevel, logging.level)
|
||||
}
|
||||
|
||||
if logging.format != tc.expectFormat {
|
||||
t.Errorf("%s: expected '%s' but got '%s'", tc.scenario, tc.expectFormat, logging.format)
|
||||
}
|
||||
if logging.format != tc.expectFormat {
|
||||
t.Errorf("expected logging format '%s' but got '%s'", tc.expectFormat, logging.format)
|
||||
}
|
||||
|
||||
if logging.fieldsPrefix != tc.expectFieldsPrefix {
|
||||
t.Errorf("%s: expected '%s' but got '%s'", tc.scenario, tc.expectFieldsPrefix, logging.fieldsPrefix)
|
||||
}
|
||||
if logging.fieldsPrefix != tc.expectFieldsPrefix {
|
||||
t.Errorf("expected logging fields prefix '%s' but got '%s'", tc.expectFieldsPrefix, logging.fieldsPrefix)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogging_Validate(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
level string
|
||||
format string
|
||||
expectErr bool
|
||||
scenario string
|
||||
level string
|
||||
format string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
scenario: "invalid level",
|
||||
level: "foo",
|
||||
expectErr: true,
|
||||
scenario: "invalid level",
|
||||
level: "foo",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "invalid format",
|
||||
level: debugLoggingLevel,
|
||||
format: "foo",
|
||||
expectErr: true,
|
||||
scenario: "invalid format",
|
||||
level: debugLoggingLevel,
|
||||
format: "foo",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "valid level and format",
|
||||
@@ -128,11 +132,11 @@ func TestLogging_Validate(t *testing.T) {
|
||||
|
||||
err := logging.Validate()
|
||||
|
||||
if tc.expectErr && err == nil {
|
||||
if tc.expectError && err == nil {
|
||||
t.Errorf("%s: expected error but got: %v", tc.scenario, err)
|
||||
}
|
||||
|
||||
if !tc.expectErr && err != nil {
|
||||
if !tc.expectError && err != nil {
|
||||
t.Errorf("%s: expected no error but got: %v", tc.scenario, err)
|
||||
}
|
||||
}
|
||||
@@ -144,18 +148,18 @@ func TestLogging_Logger(t *testing.T) {
|
||||
level string
|
||||
format string
|
||||
fieldsPrefix string
|
||||
expectErr bool
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
scenario: "invalid level",
|
||||
level: "foo",
|
||||
expectErr: true,
|
||||
scenario: "invalid level",
|
||||
level: "foo",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "invalid format",
|
||||
level: debugLoggingLevel,
|
||||
format: "foo",
|
||||
expectErr: true,
|
||||
scenario: "invalid format",
|
||||
level: debugLoggingLevel,
|
||||
format: "foo",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "valid level and format",
|
||||
@@ -163,24 +167,26 @@ func TestLogging_Logger(t *testing.T) {
|
||||
format: autoLoggingFormat,
|
||||
},
|
||||
} {
|
||||
logging := new(Logging)
|
||||
logging.level = tc.level
|
||||
logging.format = tc.format
|
||||
logging.fieldsPrefix = tc.fieldsPrefix
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
logging := new(Logging)
|
||||
logging.level = tc.level
|
||||
logging.format = tc.format
|
||||
logging.fieldsPrefix = tc.fieldsPrefix
|
||||
|
||||
_, err := logging.Logger(gotenberg.ModuleMock{
|
||||
DescriptorMock: func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "mock", New: nil}
|
||||
},
|
||||
_, err := logging.Logger(&gotenberg.ModuleMock{
|
||||
DescriptorMock: func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "mock", New: nil}
|
||||
},
|
||||
})
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
if tc.expectErr && err == nil {
|
||||
t.Errorf("%s: expected error but got: %v", tc.scenario, err)
|
||||
}
|
||||
|
||||
if !tc.expectErr && err != nil {
|
||||
t.Errorf("%s: expected no error but got: %v", tc.scenario, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,44 +213,46 @@ func TestCustomCore(t *testing.T) {
|
||||
level: zapcore.ErrorLevel,
|
||||
},
|
||||
} {
|
||||
core, obsvr := observer.New(tc.level)
|
||||
lgr := zap.New(customCore{
|
||||
Core: core,
|
||||
fieldsPrefix: tc.fieldsPrefix,
|
||||
}).With(zap.String("a_field", "a value"))
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
core, obsvr := observer.New(tc.level)
|
||||
lgr := zap.New(customCore{
|
||||
Core: core,
|
||||
fieldsPrefix: tc.fieldsPrefix,
|
||||
}).With(zap.String("a_field", "a value"))
|
||||
|
||||
lgr.Debug("a debug message", zap.String("another_field", "another value"))
|
||||
lgr.Debug("a debug message", zap.String("another_field", "another value"))
|
||||
|
||||
entries := obsvr.TakeAll()
|
||||
entries := obsvr.TakeAll()
|
||||
|
||||
if tc.expectEntry && len(entries) == 0 {
|
||||
t.Fatalf("%s: expected an entry", tc.scenario)
|
||||
}
|
||||
|
||||
if !tc.expectEntry && len(entries) != 0 {
|
||||
t.Fatalf("%s: expected no entry", tc.scenario)
|
||||
}
|
||||
|
||||
var prefix string
|
||||
if tc.fieldsPrefix != "" {
|
||||
prefix = tc.fieldsPrefix + "_"
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
fields := entry.Context
|
||||
|
||||
if len(fields) != 2 {
|
||||
t.Fatalf("expected 2 fields but got %d", len(fields))
|
||||
if tc.expectEntry && len(entries) == 0 {
|
||||
t.Fatal("expected an entry")
|
||||
}
|
||||
|
||||
if fields[0].Key != fmt.Sprintf("%sa_field", prefix) {
|
||||
t.Errorf("expected 'gotenberg_a_field' but got '%s'", fields[0].Key)
|
||||
if !tc.expectEntry && len(entries) != 0 {
|
||||
t.Fatal("expected no entry")
|
||||
}
|
||||
|
||||
if fields[1].Key != fmt.Sprintf("%sanother_field", prefix) {
|
||||
t.Errorf("expected 'gotenberg_another_field' but got '%s'", fields[1].Key)
|
||||
var prefix string
|
||||
if tc.fieldsPrefix != "" {
|
||||
prefix = tc.fieldsPrefix + "_"
|
||||
}
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
fields := entry.Context
|
||||
|
||||
if len(fields) != 2 {
|
||||
t.Fatalf("expected 2 fields but got %d", len(fields))
|
||||
}
|
||||
|
||||
if fields[0].Key != fmt.Sprintf("%sa_field", prefix) {
|
||||
t.Errorf("expected 'gotenberg_a_field' but got '%s'", fields[0].Key)
|
||||
}
|
||||
|
||||
if fields[1].Key != fmt.Sprintf("%sanother_field", prefix) {
|
||||
t.Errorf("expected 'gotenberg_another_field' but got '%s'", fields[1].Key)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,7 +261,7 @@ func Test_newLogLevel(t *testing.T) {
|
||||
scenario string
|
||||
level string
|
||||
expectZapLevel zapcore.Level
|
||||
expectErr bool
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
scenario: "error level",
|
||||
@@ -279,30 +287,32 @@ func Test_newLogLevel(t *testing.T) {
|
||||
scenario: "invalid level",
|
||||
level: "foo",
|
||||
expectZapLevel: zapcore.InvalidLevel,
|
||||
expectErr: true,
|
||||
expectError: true,
|
||||
},
|
||||
} {
|
||||
actual, err := newLogLevel(tc.level)
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
actual, err := newLogLevel(tc.level)
|
||||
|
||||
if tc.expectErr && err == nil {
|
||||
t.Errorf("%s: expected error but got: %v", tc.scenario, err)
|
||||
}
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
|
||||
if !tc.expectErr && err != nil {
|
||||
t.Errorf("%s: expected no error but got: %v", tc.scenario, err)
|
||||
}
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectZapLevel != actual {
|
||||
t.Errorf("%s: expected %d level but got %d", tc.scenario, tc.expectZapLevel, actual)
|
||||
}
|
||||
if tc.expectZapLevel != actual {
|
||||
t.Errorf("expected %d level but got %d", tc.expectZapLevel, actual)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_newLogEncoder(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
format string
|
||||
expectErr bool
|
||||
scenario string
|
||||
format string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
scenario: "auto format",
|
||||
@@ -317,19 +327,21 @@ func Test_newLogEncoder(t *testing.T) {
|
||||
format: jsonLoggingFormat,
|
||||
},
|
||||
{
|
||||
scenario: "invalid format",
|
||||
format: "foo",
|
||||
expectErr: true,
|
||||
scenario: "invalid format",
|
||||
format: "foo",
|
||||
expectError: true,
|
||||
},
|
||||
} {
|
||||
_, err := newLogEncoder(tc.format)
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
_, err := newLogEncoder(tc.format)
|
||||
|
||||
if tc.expectErr && err == nil {
|
||||
t.Errorf("%s: expected error but got: %v", tc.scenario, err)
|
||||
}
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
|
||||
if !tc.expectErr && err != nil {
|
||||
t.Errorf("%s: expected no error but got: %v", tc.scenario, err)
|
||||
}
|
||||
if !tc.expectError && err != nil {
|
||||
t.Errorf("expected no error but got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
// Package pdfcpu provides a module which wraps the
|
||||
// https://github.com/pdfcpu/pdfcpu library and implements the
|
||||
// gotenberg.PDFEngine interface.
|
||||
// Package pdfcpu provides an implementation of the gotenberg.PdfEngine
|
||||
// interface using the pdfcpu command-line tool. This package allows for:
|
||||
//
|
||||
// 1. The merging of PDF files.
|
||||
//
|
||||
// See: https://github.com/pdfcpu/pdfcpu.
|
||||
package pdfcpu
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user