mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-08-08 16:42:15 +01:00
Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfb30b8fed | ||
|
|
ca3e27937e | ||
|
|
50c34a4186 | ||
|
|
84875aaf82 | ||
|
|
b7a8b95820 | ||
|
|
5cad5ca11a | ||
|
|
7598de29dd | ||
|
|
af6ffe67c8 | ||
|
|
6944c4b3bf | ||
|
|
24817d5707 | ||
|
|
3d855712ee | ||
|
|
872263d253 | ||
|
|
1c491fb1c5 | ||
|
|
29374c579a | ||
|
|
206d7b6ca9 | ||
|
|
ab6ca8c894 | ||
|
|
e7f5fde051 | ||
|
|
030b358b0e | ||
|
|
4b1791d35c | ||
|
|
b788cf823d | ||
|
|
11cbc0288f | ||
|
|
2269c80b02 | ||
|
|
c922cc042e | ||
|
|
9cb6f44f72 | ||
|
|
f376cd5ab1 | ||
|
|
8b8a02b68c | ||
|
|
5759b1336a | ||
|
|
76f17cb6d7 | ||
|
|
7149c421cf | ||
|
|
353c599767 | ||
|
|
285c43dab7 | ||
|
|
bd5933f4c2 | ||
|
|
0faac08237 | ||
|
|
14f94b1566 | ||
|
|
415e2ff265 | ||
|
|
26e97358d1 | ||
|
|
0ed6fb33c5 | ||
|
|
df98e7512b | ||
|
|
99ede6477f | ||
|
|
ee0debe14a | ||
|
|
dd1e11f102 | ||
|
|
ed47493459 | ||
|
|
5b8391067d | ||
|
|
4f41a59ceb | ||
|
|
4525cb38e4 | ||
|
|
704dfc25e6 | ||
|
|
7022ebe4ca | ||
|
|
094863f0ea |
2
.github/workflows/continuous_integration.yml
vendored
2
.github/workflows/continuous_integration.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
- name: Run linters
|
||||
uses: golangci/golangci-lint-action@v2
|
||||
with:
|
||||
version: v1.42
|
||||
version: v1.44
|
||||
|
||||
tests:
|
||||
needs:
|
||||
|
||||
29
Makefile
29
Makefile
@@ -10,9 +10,9 @@ DOCKER_REPOSITORY=gotenberg
|
||||
GOTENBERG_VERSION=snapshot
|
||||
GOTENBERG_USER_GID=1001
|
||||
GOTENBERG_USER_UID=1001
|
||||
NOTO_COLOR_EMOJI_VERSION=v2.028 # See https://github.com/googlefonts/noto-emoji/releases.
|
||||
PDFTK_VERSION=1527259628 # See https://gitlab.com/pdftk-java/pdftk/-/releases - Binary package.
|
||||
GOLANGCI_LINT_VERSION=v1.42.1 # See https://github.com/golangci/golangci-lint/releases.
|
||||
NOTO_COLOR_EMOJI_VERSION=v2.034 # See https://github.com/googlefonts/noto-emoji/releases.
|
||||
PDFTK_VERSION=v3.3.2 # See https://gitlab.com/pdftk-java/pdftk/-/releases - Binary package.
|
||||
GOLANGCI_LINT_VERSION=v1.44.0 # See https://github.com/golangci/golangci-lint/releases.
|
||||
|
||||
.PHONY: build
|
||||
build: ## Build the Gotenberg's Docker image
|
||||
@@ -29,18 +29,18 @@ build: ## Build the Gotenberg's Docker image
|
||||
GOTENBERG_GRACEFUL_SHUTDOWN_DURATION=30s
|
||||
API_PORT=3000
|
||||
API_PORT_FROM_ENV=
|
||||
API_READ_TIMEOUT=30s
|
||||
API_PROCESS_TIMEOUT=30s
|
||||
API_WRITE_TIMEOUT=30s
|
||||
API_TIMEOUT=30s
|
||||
API_ROOT_PATH=/
|
||||
API_TRACE_HEADER=Gotenberg-Trace
|
||||
API_DISABLE_HEALTH_CHECK_LOGGING=false
|
||||
CHROMIUM_USER_AGENT=
|
||||
CHROMIUM_INCOGNITO=false
|
||||
CHROMIUM_IGNORE_CERTIFICATE_ERRORS=false
|
||||
CHROMIUM_DISABLE_WEB_SECURITY=false
|
||||
CHROMIUM_ALLOW_FILE_ACCESS_FROM_FILES=false
|
||||
CHROMIUM_PROXY_SERVER=
|
||||
CHROMIUM_ALLOW_LIST=
|
||||
CHROMIUM_DENY_LIST="^file:///[^tmp].*"
|
||||
CHROMIUM_DISABLE_JAVASCRIPT=false
|
||||
CHROMIUM_DISABLE_ROUTES=false
|
||||
LIBREOFFICE_DISABLES_ROUTES=false
|
||||
LOG_LEVEL=info
|
||||
@@ -51,6 +51,8 @@ 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=
|
||||
@@ -58,6 +60,7 @@ WEBHOOK_ERROR_DENY_LIST=
|
||||
WEBHOOK_MAX_RETRY=4
|
||||
WEBHOOK_RETRY_MIN_WAIT=1s
|
||||
WEBHOOK_RETRY_MAX_WAIT=30s
|
||||
WEBHOOK_CLIENT_TIMEOUT=30s
|
||||
WEBHOOK_DISABLE=false
|
||||
|
||||
.PHONY: run
|
||||
@@ -69,18 +72,18 @@ run: ## Start a Gotenberg container
|
||||
--gotenberg-graceful-shutdown-duration=$(GOTENBERG_GRACEFUL_SHUTDOWN_DURATION) \
|
||||
--api-port=$(API_PORT) \
|
||||
--api-port-from-env=$(API_PORT_FROM_ENV) \
|
||||
--api-read-timeout=$(API_READ_TIMEOUT) \
|
||||
--api-process-timeout=$(API_PROCESS_TIMEOUT) \
|
||||
--api-write-timeout=$(API_WRITE_TIMEOUT) \
|
||||
--api-timeout=$(API_TIMEOUT) \
|
||||
--api-root-path=$(API_ROOT_PATH) \
|
||||
--api-trace-header=$(API_TRACE_HEADER) \
|
||||
--api-disable-health-check-logging=$(API_DISABLE_HEALTH_CHECK_LOGGING) \
|
||||
--chromium-user-agent=$(CHROMIUM_USER_AGENT) \
|
||||
--chromium-incognito=$(CHROMIUM_INCOGNITO) \
|
||||
--chromium-ignore-certificate-errors=$(CHROMIUM_IGNORE_CERTIFICATE_ERRORS) \
|
||||
--chromium-disable-web-security=$(CHROMIUM_DISABLE_WEB_SECURITY) \
|
||||
--chromium-allow-file-access-from-files=$(CHROMIUM_ALLOW_FILE_ACCESS_FROM_FILES) \
|
||||
--chromium-proxy-server=$(CHROMIUM_PROXY_SERVER) \
|
||||
--chromium-allow-list=$(CHROMIUM_ALLOW_LIST) \
|
||||
--chromium-deny-list=$(CHROMIUM_DENY_LIST) \
|
||||
--chromium-disable-javascript=$(CHROMIUM_DISABLE_JAVASCRIPT) \
|
||||
--chromium-disable-routes=$(CHROMIUM_DISABLE_ROUTES) \
|
||||
--libreoffice-disable-routes=$(LIBREOFFICE_DISABLES_ROUTES) \
|
||||
--log-level=$(LOG_LEVEL) \
|
||||
@@ -91,6 +94,8 @@ run: ## Start a Gotenberg container
|
||||
--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-error-allow-list=$(WEBHOOK_ERROR_ALLOW_LIST) \
|
||||
@@ -98,6 +103,7 @@ run: ## Start a Gotenberg container
|
||||
--webhook-max-retry=$(WEBHOOK_MAX_RETRY) \
|
||||
--webhook-retry-min-wait=$(WEBHOOK_RETRY_MIN_WAIT) \
|
||||
--webhook-retry-max-wait=$(WEBHOOK_RETRY_MAX_WAIT) \
|
||||
--webhook-client-timeout=$(WEBHOOK_CLIENT_TIMEOUT) \
|
||||
--webhook-disable=$(WEBHOOK_DISABLE)
|
||||
|
||||
.PHONY: build-tests
|
||||
@@ -141,5 +147,6 @@ release: ## Build the Gotenberg's Docker image for many platforms, then push it
|
||||
$(GOTENBERG_VERSION) \
|
||||
$(GOTENBERG_USER_GID) \
|
||||
$(GOTENBERG_USER_UID) \
|
||||
$(NOTO_COLOR_EMOJI_VERSION) \
|
||||
$(PDFTK_VERSION) \
|
||||
$(DOCKER_REPOSITORY)
|
||||
|
||||
11
README.md
11
README.md
@@ -1,14 +1,14 @@
|
||||
<p align="center">
|
||||
<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://gotenberg.dev/docs/about">Documentation</a> · 🔥 <a href="https://gotenberg.dev/docs/get-started/live-demo">Live Demo</a></p>
|
||||
<h3 align="center">Gotenberg</h3>
|
||||
<p align="center">A Docker-powered stateless API for PDF files</p>
|
||||
<p align="center"><a href="https://gotenberg.dev/docs/about">Documentation</a> · 🔥 <a href="https://gotenberg.dev/docs/get-started/live-demo">Live Demo</a></p>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
Gotenberg provides a developer-friendly API to interact with powerful tools like Chromium and LibreOffice to convert many
|
||||
documents (HTML, Markdown, Word, Excel, etc.) to PDF, transform them, merge them, and more!
|
||||
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
|
||||
|
||||
@@ -43,4 +43,3 @@ Head to the [documentation](https://gotenberg.dev/docs/about) to learn how to in
|
||||
[](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)
|
||||
[](https://goreportcard.com/report/gotenberg/gotenberg)
|
||||
|
||||
@@ -64,10 +64,10 @@ RUN \
|
||||
mkdir /home/gotenberg &&\
|
||||
chown gotenberg: /home/gotenberg &&\
|
||||
# Install dependencies required for the next instructions or debugging.
|
||||
# Note: procps for "top" command (useful when debugging processes).
|
||||
# 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 procps tini python3 default-jre-headless &&\
|
||||
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 &&\
|
||||
# Install fonts.
|
||||
# Credits:
|
||||
# https://github.com/arachnys/athenapdf/blob/master/cli/Dockerfile.
|
||||
@@ -103,6 +103,7 @@ RUN \
|
||||
fonts-liberation \
|
||||
fonts-liberation2 \
|
||||
fonts-linuxlibertine \
|
||||
fonts-noto-cjk \
|
||||
fonts-noto-core \
|
||||
fonts-noto-mono \
|
||||
fonts-noto-ui-core \
|
||||
@@ -117,20 +118,24 @@ RUN \
|
||||
# Install Google Chrome / Chromium.
|
||||
/tmp/install-chromium.sh &&\
|
||||
# Install LibreOffice.
|
||||
# Note: we use the sid distribution to get the latest LibreOffice version.
|
||||
# See https://github.com/gotenberg/gotenberg/pull/322.
|
||||
echo "deb https://httpredir.debian.org/debian/ sid main contrib non-free" >> /etc/apt/sources.list &&\
|
||||
# Note: we use the bullseye-backports distribution to get the latest LibreOffice version.
|
||||
# See:
|
||||
# https://github.com/gotenberg/gotenberg/pull/322.
|
||||
# https://github.com/gotenberg/gotenberg/issues/403.
|
||||
echo "deb https://httpredir.debian.org/debian/ bullseye-backports main contrib non-free" >> /etc/apt/sources.list &&\
|
||||
apt-get update -qq &&\
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends -t sid libreoffice &&\
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends -t bullseye-backports libreoffice &&\
|
||||
# Download unoconv (Python script).
|
||||
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.
|
||||
ln -s /usr/bin/python3 /usr/bin/python &&\
|
||||
# Download PDFtk.
|
||||
# See https://github.com/gotenberg/gotenberg/pull/273.
|
||||
curl -o /usr/bin/pdftk-all.jar "https://gitlab.com/pdftk-java/pdftk/-/jobs/$PDFTK_VERSION/artifacts/raw/build/libs/pdftk-all.jar" &&\
|
||||
# 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 &&\
|
||||
# Download QPDF.
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends qpdf &&\
|
||||
# See https://github.com/nextcloud/docker/issues/380.
|
||||
mkdir -p /usr/share/man/man1mkdir -p /usr/share/man/man1 &&\
|
||||
# Cleanup.
|
||||
@@ -142,7 +147,8 @@ RUN \
|
||||
chromium --version &&\
|
||||
libreoffice --version &&\
|
||||
unoconv --version &&\
|
||||
pdftk --version
|
||||
pdftk --version &&\
|
||||
qpdf --version
|
||||
|
||||
# Copy the Gotenberg binary from the builder stage.
|
||||
COPY --from=builder /home/gotenberg /usr/bin/
|
||||
@@ -151,7 +157,9 @@ COPY --from=builder /home/gotenberg /usr/bin/
|
||||
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
|
||||
|
||||
USER gotenberg
|
||||
WORKDIR /home/gotenberg
|
||||
@@ -160,4 +168,4 @@ WORKDIR /home/gotenberg
|
||||
EXPOSE 3000
|
||||
|
||||
ENTRYPOINT [ "/usr/bin/tini", "--" ]
|
||||
CMD [ "gotenberg" ]
|
||||
CMD [ "gotenberg" ]
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
||||
flag "github.com/spf13/pflag"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
// See https://patorjk.com/software/taag/#p=display&f=Small%20Slant&t=Gotenberg.
|
||||
@@ -33,7 +34,7 @@ var Version = "snapshot"
|
||||
func Run() {
|
||||
fmt.Printf(banner, Version)
|
||||
|
||||
// Creates the roo` FlagSet and adds the modules flags to it.
|
||||
// Create the root FlagSet and adds the modules flags to it.
|
||||
fs := flag.NewFlagSet("gotenberg", flag.ExitOnError)
|
||||
fs.Duration("gotenberg-graceful-shutdown-duration", time.Duration(30)*time.Second, "Set the graceful shutdown duration")
|
||||
|
||||
@@ -46,14 +47,14 @@ func Run() {
|
||||
|
||||
fmt.Printf("[SYSTEM] modules: %s\n", modsInfo)
|
||||
|
||||
// Parses the flags...
|
||||
// Parse the flags...
|
||||
err := fs.Parse(os.Args[1:])
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// ...and creates a wrapper around those.
|
||||
// ...and create a wrapper around those.
|
||||
parsedFlags := gotenberg.ParsedFlags{FlagSet: fs}
|
||||
|
||||
// Get the graceful shutdown duration.
|
||||
@@ -61,7 +62,7 @@ func Run() {
|
||||
|
||||
ctx := gotenberg.NewContext(parsedFlags, descriptors)
|
||||
|
||||
// Starts application modules.
|
||||
// Start application modules.
|
||||
apps, err := ctx.Modules(new(gotenberg.App))
|
||||
if err != nil {
|
||||
fmt.Printf("[FATAL] %s\n", err)
|
||||
@@ -87,7 +88,23 @@ func Run() {
|
||||
|
||||
fmt.Printf("[SYSTEM] %s: %s\n", id, startupMessage)
|
||||
}(a.(gotenberg.App))
|
||||
}
|
||||
|
||||
// Get modules that want to print system messages.
|
||||
sysLoggers, err := ctx.Modules(new(gotenberg.SystemLogger))
|
||||
if err != nil {
|
||||
fmt.Printf("[FATAL] %s\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
for _, l := range sysLoggers {
|
||||
go func(logger gotenberg.SystemLogger) {
|
||||
id := logger.(gotenberg.Module).Descriptor().ID
|
||||
|
||||
for _, message := range logger.SystemMessages() {
|
||||
fmt.Printf("[SYSTEM] %s: %s\n", id, message)
|
||||
}
|
||||
}(l.(gotenberg.SystemLogger))
|
||||
}
|
||||
|
||||
quit := make(chan os.Signal, 1)
|
||||
@@ -101,18 +118,40 @@ func Run() {
|
||||
gracefulShutdownCtx, cancel := context.WithTimeout(context.Background(), gracefulShutdownDuration)
|
||||
defer cancel()
|
||||
|
||||
forceQuit := make(chan os.Signal, 1)
|
||||
signal.Notify(forceQuit, os.Interrupt)
|
||||
|
||||
go func() {
|
||||
// In case of force quit, cancel the context.
|
||||
<-forceQuit
|
||||
cancel()
|
||||
}()
|
||||
|
||||
fmt.Printf("[SYSTEM] graceful shutdown of %s\n", gracefulShutdownDuration)
|
||||
|
||||
eg, _ := errgroup.WithContext(gracefulShutdownCtx)
|
||||
|
||||
for _, a := range apps {
|
||||
id := a.(gotenberg.Module).Descriptor().ID
|
||||
app := a.(gotenberg.App)
|
||||
eg.Go(func(app gotenberg.App) func() error {
|
||||
return func() error {
|
||||
id := app.(gotenberg.Module).Descriptor().ID
|
||||
|
||||
err = app.Stop(gracefulShutdownCtx)
|
||||
if err != nil {
|
||||
fmt.Printf("[ERROR] stopping %s: %s\n", id, err)
|
||||
}
|
||||
err = app.Stop(gracefulShutdownCtx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("stopping %s: %w", id, err)
|
||||
}
|
||||
|
||||
fmt.Printf("[SYSTEM] %s: application stopped\n", id)
|
||||
fmt.Printf("[SYSTEM] %s: application stopped\n", id)
|
||||
|
||||
return nil
|
||||
}
|
||||
}(a.(gotenberg.App)))
|
||||
}
|
||||
|
||||
err = eg.Wait()
|
||||
if err != nil {
|
||||
fmt.Printf("[FATAL] %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
os.Exit(0)
|
||||
|
||||
26
go.mod
26
go.mod
@@ -4,36 +4,36 @@ go 1.17
|
||||
|
||||
require (
|
||||
github.com/alexliesenfeld/health v0.6.0
|
||||
github.com/andybalholm/brotli v1.0.3 // indirect
|
||||
github.com/chromedp/cdproto v0.0.0-20211025030258-2570df970243
|
||||
github.com/chromedp/chromedp v0.7.4
|
||||
github.com/andybalholm/brotli v1.0.4 // indirect
|
||||
github.com/chromedp/cdproto v0.0.0-20220204211718-0109d952cae2
|
||||
github.com/chromedp/chromedp v0.7.7
|
||||
github.com/golang/snappy v0.0.4 // indirect
|
||||
github.com/google/uuid v1.3.0
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
||||
github.com/hashicorp/go-retryablehttp v0.7.0
|
||||
github.com/klauspost/compress v1.13.6 // indirect
|
||||
github.com/klauspost/compress v1.14.2 // indirect
|
||||
github.com/klauspost/pgzip v1.2.5 // indirect
|
||||
github.com/labstack/echo/v4 v4.6.1
|
||||
github.com/labstack/gommon v0.3.0
|
||||
github.com/labstack/echo/v4 v4.6.3
|
||||
github.com/labstack/gommon v0.3.1
|
||||
github.com/mattn/go-isatty v0.0.14 // indirect
|
||||
github.com/mholt/archiver/v3 v3.5.1
|
||||
github.com/microcosm-cc/bluemonday v1.0.16
|
||||
github.com/microcosm-cc/bluemonday v1.0.18
|
||||
github.com/nwaples/rardecode v1.1.2 // indirect
|
||||
github.com/pdfcpu/pdfcpu v0.3.12
|
||||
github.com/pierrec/lz4/v4 v4.1.9 // indirect
|
||||
github.com/prometheus/client_golang v1.11.0
|
||||
github.com/pierrec/lz4/v4 v4.1.14 // indirect
|
||||
github.com/prometheus/client_golang v1.12.1
|
||||
github.com/russross/blackfriday/v2 v2.1.0
|
||||
github.com/spf13/pflag v1.0.5
|
||||
github.com/ulikunitz/xz v0.5.10 // indirect
|
||||
go.uber.org/atomic v1.9.0 // indirect
|
||||
go.uber.org/multierr v1.7.0
|
||||
go.uber.org/zap v1.19.1
|
||||
go.uber.org/zap v1.21.0
|
||||
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 // indirect
|
||||
golang.org/x/image v0.0.0-20211028202545-6944b10bf410 // indirect
|
||||
golang.org/x/net v0.0.0-20210913180222-943fd674d43e
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
|
||||
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6 // indirect
|
||||
golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b
|
||||
golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27 // indirect
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211
|
||||
golang.org/x/text v0.3.7
|
||||
)
|
||||
|
||||
@@ -52,7 +52,7 @@ require (
|
||||
github.com/hhrutter/tiff v0.0.0-20190829141212-736cae8d0bc7 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/mattn/go-colorable v0.1.11 // indirect
|
||||
github.com/mattn/go-colorable v0.1.12 // indirect
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/prometheus/client_model v0.2.0 // indirect
|
||||
|
||||
70
go.sum
70
go.sum
@@ -41,8 +41,8 @@ github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk5
|
||||
github.com/alexliesenfeld/health v0.6.0 h1:HRBTCgybNSe4lqGEk7nU82c3bjwh9W+3b46W6UvD4CQ=
|
||||
github.com/alexliesenfeld/health v0.6.0/go.mod h1:N4NDIeQtlWumG+6z1ne1v62eQxktz5ylEgGgH9emdMw=
|
||||
github.com/andybalholm/brotli v1.0.1/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y=
|
||||
github.com/andybalholm/brotli v1.0.3 h1:fpcw+r1N1h0Poc1F/pHbW40cUm/lMEQslZtCkBQ0UnM=
|
||||
github.com/andybalholm/brotli v1.0.3/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
||||
github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY=
|
||||
github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
||||
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/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
|
||||
@@ -55,11 +55,11 @@ github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA
|
||||
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE=
|
||||
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/chromedp/cdproto v0.0.0-20210713064928-7d28b402946a/go.mod h1:At5TxYYdxkbQL0TSefRjhLE3Q0lgvqKKMSFUglJ7i1U=
|
||||
github.com/chromedp/cdproto v0.0.0-20211025030258-2570df970243 h1:09MpdVvSYOhIByW2Z1kWYkh0cjui9k9Imx28pk01KXA=
|
||||
github.com/chromedp/cdproto v0.0.0-20211025030258-2570df970243/go.mod h1:At5TxYYdxkbQL0TSefRjhLE3Q0lgvqKKMSFUglJ7i1U=
|
||||
github.com/chromedp/chromedp v0.7.4 h1:U+0d3WbB/Oj4mDuBOI0P7S3PJEued5UZIl5AJ3QulwU=
|
||||
github.com/chromedp/chromedp v0.7.4/go.mod h1:dBj+SXuQHznp6ZPwZeDDEBZKwclUwDLbZ0hjMialMYs=
|
||||
github.com/chromedp/cdproto v0.0.0-20220131204822-e6abebe7b8cd/go.mod h1:At5TxYYdxkbQL0TSefRjhLE3Q0lgvqKKMSFUglJ7i1U=
|
||||
github.com/chromedp/cdproto v0.0.0-20220204211718-0109d952cae2 h1:vr9QO2FB/MkXEv8BUja99AUwbJ32TuA+l64bAEmLqQI=
|
||||
github.com/chromedp/cdproto v0.0.0-20220204211718-0109d952cae2/go.mod h1:At5TxYYdxkbQL0TSefRjhLE3Q0lgvqKKMSFUglJ7i1U=
|
||||
github.com/chromedp/chromedp v0.7.7 h1:kRN7G7v4cGpTV2Nth218YR2VOsbpHBN3qClYCb6CBnI=
|
||||
github.com/chromedp/chromedp v0.7.7/go.mod h1:cqexhZWjEbF8/cETF57b5dQ3rqrY6q71HcfYQ5oAy3k=
|
||||
github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic=
|
||||
github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww=
|
||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||
@@ -176,6 +176,7 @@ github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX
|
||||
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
|
||||
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
|
||||
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
|
||||
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
|
||||
@@ -183,8 +184,8 @@ github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
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.13.6 h1:P76CopJELS0TiO2mebmnzgWaajssP/EszplttgQxcgc=
|
||||
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
|
||||
github.com/klauspost/compress v1.14.2 h1:S0OHlFk/Gbon/yauFJ4FfJJF5V0fc5HbBTJazi28pRw=
|
||||
github.com/klauspost/compress v1.14.2/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
|
||||
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
|
||||
github.com/klauspost/pgzip v1.2.5 h1:qnWYvvKqedOF2ulHpMG72XQol4ILEJ8k2wwRl/Km8oE=
|
||||
github.com/klauspost/pgzip v1.2.5/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
|
||||
@@ -196,31 +197,28 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/labstack/echo/v4 v4.6.1 h1:OMVsrnNFzYlGSdaiYGHbgWQnr+JM7NG+B9suCPie14M=
|
||||
github.com/labstack/echo/v4 v4.6.1/go.mod h1:RnjgMWNDB9g/HucVWhQYNQP9PvbYf6adqftqryo7s9k=
|
||||
github.com/labstack/gommon v0.3.0 h1:JEeO0bvc78PKdyHxloTKiF8BD5iGrH8T6MSeGvSgob0=
|
||||
github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k=
|
||||
github.com/labstack/echo/v4 v4.6.3 h1:VhPuIZYxsbPmo4m9KAkMU/el2442eB7EBFFhNTTT9ac=
|
||||
github.com/labstack/echo/v4 v4.6.3/go.mod h1:Hk5OiHj0kDqmFq7aHe7eDqI7CUhuCrfpupQtLGGLm7A=
|
||||
github.com/labstack/gommon v0.3.1 h1:OomWaJXm7xR6L1HmEtGyQf26TEn7V6X88mktX9kee9o=
|
||||
github.com/labstack/gommon v0.3.1/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM=
|
||||
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.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
|
||||
github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
|
||||
github.com/mattn/go-colorable v0.1.11 h1:nQ+aFkoE2TMGc0b68U2OKSexC+eq46+XwZzWXHRmPYs=
|
||||
github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
|
||||
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
||||
github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ=
|
||||
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
||||
github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40=
|
||||
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
|
||||
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
|
||||
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
|
||||
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.16 h1:kHmAq2t7WPWLjiGvzKa5o3HzSfahUKiOq7fAPUiMNIc=
|
||||
github.com/microcosm-cc/bluemonday v1.0.16/go.mod h1:Z0r70sCuXHig8YpBzCc5eGHAap2K7e/u082ZUpDRRqM=
|
||||
github.com/microcosm-cc/bluemonday v1.0.18 h1:6HcxvXDAi3ARt3slx6nTesbvorIc3QeTzBNRvWktHBo=
|
||||
github.com/microcosm-cc/bluemonday v1.0.18/go.mod h1:Z0r70sCuXHig8YpBzCc5eGHAap2K7e/u082ZUpDRRqM=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
||||
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
||||
github.com/nwaples/rardecode v1.1.0/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0=
|
||||
@@ -231,8 +229,8 @@ github.com/orisano/pixelmatch v0.0.0-20210112091706-4fa4c7ba91d5/go.mod h1:nZgzb
|
||||
github.com/pdfcpu/pdfcpu v0.3.12 h1:B+MdKisilWNSk5OCO58Z9U6H93usH73xqk6hMOaZCls=
|
||||
github.com/pdfcpu/pdfcpu v0.3.12/go.mod h1:8XVBtVxuuIuSZL4Ez15Q4QoC+H8zeAaGnuiOEwAk8jA=
|
||||
github.com/pierrec/lz4/v4 v4.1.2/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pierrec/lz4/v4 v4.1.9 h1:xkrjwpOP5xg1k4Nn4GX4a4YFGhscyQL/3EddJ1Xxqm8=
|
||||
github.com/pierrec/lz4/v4 v4.1.9/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pierrec/lz4/v4 v4.1.14 h1:+fL8AQEZtz/ijeNnpduH0bROTu0O3NZAlPjQxGn8LwE=
|
||||
github.com/pierrec/lz4/v4 v4.1.14/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
@@ -242,8 +240,9 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
|
||||
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
|
||||
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
|
||||
github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M=
|
||||
github.com/prometheus/client_golang v1.11.0 h1:HNkLOAEQMIDv/K+04rukrLx6ch7msSRwf3/SASFAGtQ=
|
||||
github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0=
|
||||
github.com/prometheus/client_golang v1.12.1 h1:ZiaPsmm9uiBeaSMRznKsCDNtPCS0T3JVDGF+06gjBzk=
|
||||
github.com/prometheus/client_golang v1.12.1/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY=
|
||||
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
|
||||
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
@@ -282,7 +281,6 @@ github.com/ulikunitz/xz v0.5.10 h1:t92gobL9l3HE202wg3rlk19F6X+JOxl9BBrCCMYEYd8=
|
||||
github.com/ulikunitz/xz v0.5.10/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.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8=
|
||||
github.com/valyala/fasttemplate v1.2.1 h1:TVEnxayobAdVkhQfrfes2IzOB6o+z4roRkPF52WA1u4=
|
||||
github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo=
|
||||
@@ -299,13 +297,13 @@ go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
|
||||
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/goleak v1.1.11-0.20210813005559-691160354723 h1:sHOAIxRGBp443oHZIPB+HsUGaksVCXVQENPxwTfQdH4=
|
||||
go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
|
||||
go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI=
|
||||
go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
|
||||
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
|
||||
go.uber.org/multierr v1.7.0 h1:zaiO/rmgFjbmCXdSYJWQcdvOCsthmdaHfr3Gm2Kx4Ec=
|
||||
go.uber.org/multierr v1.7.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak=
|
||||
go.uber.org/zap v1.19.1 h1:ue41HOKd1vGURxrmeKIgELGb3jPW9DMUDGtsinblHwI=
|
||||
go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI=
|
||||
go.uber.org/zap v1.21.0 h1:WefMeulhovoZ2sYXz7st6K0sLj7bBhpiFaud4r4zST8=
|
||||
go.uber.org/zap v1.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw=
|
||||
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
@@ -404,7 +402,6 @@ golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5h
|
||||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -413,13 +410,11 @@ golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -440,16 +435,17 @@ golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210525143221-35b2ab0089ea/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210910150752-751e447fb3d0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6 h1:foEbQz/B0Oz6YIqu/69kfXPYeFQAuuMYFkjaqXzl5Wo=
|
||||
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-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27 h1:XDXtA5hveEEV8JB2l7nhMTp3t3cHp9ZpwcdjqyEWLlo=
|
||||
golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/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-20210615171337-6886f2dfbf5b h1:9zKuko04nR4gjZ4+DNjHqRlAJqbJETHwiNKDqTfOjfE=
|
||||
golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
|
||||
@@ -31,7 +31,7 @@ func Command(logger *zap.Logger, binPath string, args ...string) Cmd {
|
||||
|
||||
return Cmd{
|
||||
ctx: nil,
|
||||
logger: logger.Named("cmd"),
|
||||
logger: logger.Named(strings.ReplaceAll(binPath, "/", "")),
|
||||
process: cmd,
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,7 @@ func CommandContext(ctx context.Context, logger *zap.Logger, binPath string, arg
|
||||
|
||||
return Cmd{
|
||||
ctx: ctx,
|
||||
logger: logger.Named("cmd"),
|
||||
logger: logger.Named(strings.ReplaceAll(binPath, "/", "")),
|
||||
process: cmd,
|
||||
}, nil
|
||||
}
|
||||
@@ -73,22 +73,37 @@ func (cmd Cmd) Start() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
err := cmd.process.Wait()
|
||||
if err != nil {
|
||||
return fmt.Errorf("wait for unix process: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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() error {
|
||||
func (cmd Cmd) Exec() (int, error) {
|
||||
if cmd.ctx == nil {
|
||||
return errors.New("nil context")
|
||||
return 10, errors.New("nil context")
|
||||
}
|
||||
|
||||
err := cmd.Start()
|
||||
if err != nil {
|
||||
return fmt.Errorf("start command: %w", err)
|
||||
if cmd.process.ProcessState == nil {
|
||||
return 131, fmt.Errorf("start command: %w", err)
|
||||
}
|
||||
|
||||
return cmd.process.ProcessState.ExitCode(), fmt.Errorf("start command: %w", err)
|
||||
}
|
||||
|
||||
errChan := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
errChan <- cmd.process.Wait()
|
||||
errChan <- cmd.Wait()
|
||||
}()
|
||||
|
||||
select {
|
||||
@@ -99,17 +114,21 @@ func (cmd Cmd) Exec() error {
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
return nil
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("unix process error: %w", err)
|
||||
if cmd.process.ProcessState == nil {
|
||||
return 131, fmt.Errorf("unix process error: %w", err)
|
||||
}
|
||||
|
||||
return cmd.process.ProcessState.ExitCode(), fmt.Errorf("unix process error: %w", err)
|
||||
case <-cmd.ctx.Done():
|
||||
errProc := cmd.Kill()
|
||||
if errProc != nil {
|
||||
cmd.logger.Error(errProc.Error())
|
||||
}
|
||||
|
||||
return fmt.Errorf("context done: %w", cmd.ctx.Err())
|
||||
return 62, fmt.Errorf("context done: %w", cmd.ctx.Err())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,209 +12,313 @@ func TestCommand(t *testing.T) {
|
||||
cmd := Command(zap.NewNop(), "foo")
|
||||
|
||||
if !cmd.process.SysProcAttr.Setpgid {
|
||||
t.Error("expected Setpgid to be true")
|
||||
t.Error("expected cmd.process.SysProcAttr.Setpgid to be true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandContext(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
ctx context.Context
|
||||
expectErr bool
|
||||
tests := []struct {
|
||||
name string
|
||||
ctx context.Context
|
||||
expectCommandContextErr bool
|
||||
}{
|
||||
{
|
||||
ctx: nil,
|
||||
expectErr: true,
|
||||
name: "nominal behavior",
|
||||
ctx: context.Background(),
|
||||
},
|
||||
{
|
||||
ctx: context.TODO(),
|
||||
name: "nil context",
|
||||
expectCommandContextErr: true,
|
||||
},
|
||||
} {
|
||||
cmd, err := CommandContext(tc.ctx, zap.NewNop(), "foo")
|
||||
}
|
||||
|
||||
if err == nil && !cmd.process.SysProcAttr.Setpgid {
|
||||
t.Fatalf("test %d: expected Setpgid to be true", i)
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cmd, err := CommandContext(tc.ctx, zap.NewNop(), "foo")
|
||||
|
||||
if tc.expectErr && err == nil {
|
||||
t.Errorf("test %d: expected error but got: %v", i, err)
|
||||
}
|
||||
if err == nil && !cmd.process.SysProcAttr.Setpgid {
|
||||
t.Fatal("expected cmd.process.SysProcAttr.Setpgid to be true")
|
||||
}
|
||||
|
||||
if !tc.expectErr && err != nil {
|
||||
t.Errorf("test %d: expected no error but got: %v", i, err)
|
||||
}
|
||||
if tc.expectCommandContextErr && err == nil {
|
||||
t.Error("expected error from CommandContext(), but got none")
|
||||
}
|
||||
|
||||
if !tc.expectCommandContextErr && err != nil {
|
||||
t.Errorf("expected no error from CommandContext(), but got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmd_Start(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
cmd Cmd
|
||||
expectErr bool
|
||||
tests := []struct {
|
||||
name string
|
||||
cmd Cmd
|
||||
expectStartErr bool
|
||||
}{
|
||||
{
|
||||
cmd: Command(zap.NewNop(), "foo"),
|
||||
expectErr: true,
|
||||
name: "nominal behavior",
|
||||
cmd: Command(zap.NewNop(), "echo", "Hello", "World"),
|
||||
},
|
||||
{
|
||||
cmd: Command(zap.NewNop(), "echo", "Hello", "World"),
|
||||
name: "start error",
|
||||
cmd: Command(zap.NewNop(), "foo"),
|
||||
expectStartErr: true,
|
||||
},
|
||||
} {
|
||||
err := tc.cmd.Start()
|
||||
}
|
||||
|
||||
if tc.expectErr && err == nil {
|
||||
t.Errorf("test %d: expected error but got: %v", i, err)
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := tc.cmd.Start()
|
||||
|
||||
if !tc.expectErr && err != nil {
|
||||
t.Errorf("test %d: expected no error but got: %v", i, err)
|
||||
}
|
||||
if tc.expectStartErr && err == nil {
|
||||
t.Error("expected error from cmd.Start(), but got none")
|
||||
}
|
||||
|
||||
if !tc.expectStartErr && err != nil {
|
||||
t.Errorf("expected no error from cmd.Start(), but got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmd_Wait(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cmd Cmd
|
||||
expectWaitErr bool
|
||||
}{
|
||||
{
|
||||
name: "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)
|
||||
}
|
||||
|
||||
return cmd
|
||||
}(),
|
||||
},
|
||||
{
|
||||
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,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, 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.expectWaitErr && err != nil {
|
||||
t.Errorf("expected no error from cmd.Wait(), but got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmd_Exec(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
cmd Cmd
|
||||
timeout time.Duration
|
||||
expectErr bool
|
||||
tests := []struct {
|
||||
name string
|
||||
cmd Cmd
|
||||
timeout time.Duration
|
||||
expectExecErr bool
|
||||
}{
|
||||
{
|
||||
cmd: Command(zap.NewNop(), "foo"),
|
||||
expectErr: true,
|
||||
name: "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
|
||||
}(),
|
||||
},
|
||||
{
|
||||
cmd: Command(zap.NewNop(), "foo"),
|
||||
timeout: time.Duration(5) * time.Second,
|
||||
expectErr: true,
|
||||
name: "nil context",
|
||||
cmd: Command(zap.NewNop(), "echo", "Hello", "World"),
|
||||
expectExecErr: true,
|
||||
},
|
||||
{
|
||||
cmd: Command(zap.NewNop(), "echo", "Hello", "World"),
|
||||
timeout: time.Duration(5) * time.Second,
|
||||
name: "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)
|
||||
}
|
||||
|
||||
return cmd
|
||||
}(),
|
||||
expectExecErr: true,
|
||||
},
|
||||
{
|
||||
cmd: Command(zap.NewNop(), "sleep", "3"),
|
||||
timeout: time.Duration(2) * time.Second,
|
||||
expectErr: true,
|
||||
name: "context done",
|
||||
cmd: Command(zap.NewNop(), "sleep", "2"),
|
||||
timeout: time.Duration(1) * time.Second,
|
||||
expectExecErr: true,
|
||||
},
|
||||
} {
|
||||
if tc.timeout > 0 {
|
||||
ctx, cancel := context.WithTimeout(context.TODO(), tc.timeout)
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
tc.cmd.ctx = ctx
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if tc.timeout > 0 {
|
||||
ctx, cancel := context.WithTimeout(context.TODO(), tc.timeout)
|
||||
defer cancel()
|
||||
|
||||
err := tc.cmd.Exec()
|
||||
tc.cmd.ctx = ctx
|
||||
}
|
||||
|
||||
if tc.expectErr && err == nil {
|
||||
t.Errorf("test %d: expected error but got: %v", i, err)
|
||||
}
|
||||
_, err := tc.cmd.Exec()
|
||||
|
||||
if !tc.expectErr && err != nil {
|
||||
t.Errorf("test %d: expected no error but got: %v", i, err)
|
||||
}
|
||||
if tc.expectExecErr && err == nil {
|
||||
t.Error("expected error from cmd.Exec(), but got none")
|
||||
}
|
||||
|
||||
if !tc.expectExecErr && err != nil {
|
||||
t.Errorf("expected no error from cmd.Exec(), but got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmd_pipeOutput(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
cmd Cmd
|
||||
run bool
|
||||
expectErr bool
|
||||
tests := []struct {
|
||||
name string
|
||||
cmd Cmd
|
||||
run bool
|
||||
expectPipeOutputErr bool
|
||||
}{
|
||||
{
|
||||
cmd: Command(zap.NewNop(), "echo", "Hello", "World"),
|
||||
name: "nominal behavior",
|
||||
cmd: Command(zap.NewExample(), "echo", "Hello", "World"),
|
||||
run: true,
|
||||
},
|
||||
{
|
||||
name: "no debug, no pipe",
|
||||
cmd: Command(zap.NewNop(), "echo", "Hello", "World"),
|
||||
},
|
||||
{
|
||||
name: "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 but got: %v", err)
|
||||
t.Fatalf("expected no error from cmd.process.StdoutPipe(), but got: %v", err)
|
||||
}
|
||||
|
||||
return cmd
|
||||
}(),
|
||||
expectErr: true,
|
||||
expectPipeOutputErr: true,
|
||||
},
|
||||
{
|
||||
name: "stderr already piped",
|
||||
cmd: func() Cmd {
|
||||
cmd := Command(zap.NewExample(), "echo", "Hello", "World")
|
||||
_, err := cmd.process.StderrPipe()
|
||||
|
||||
_, err := cmd.process.StderrPipe()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
t.Fatalf("expected no error from cmd.process.StderrPipe(), but got: %v", err)
|
||||
}
|
||||
|
||||
return cmd
|
||||
}(),
|
||||
expectErr: true,
|
||||
expectPipeOutputErr: true,
|
||||
},
|
||||
{
|
||||
cmd: Command(zap.NewExample(), "echo", "Hello", "World"),
|
||||
run: true,
|
||||
},
|
||||
} {
|
||||
err := tc.cmd.pipeOutput()
|
||||
}
|
||||
|
||||
if tc.run {
|
||||
errStart := tc.cmd.process.Start()
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := tc.cmd.pipeOutput()
|
||||
|
||||
if errStart != nil {
|
||||
t.Fatalf("test %d: expected no error but got: %v", i, err)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if tc.expectErr && err == nil {
|
||||
t.Errorf("test %d: expected error but got: %v", i, err)
|
||||
}
|
||||
if tc.expectPipeOutputErr && err == nil {
|
||||
t.Error("expected error from cmd.pipeOutput(), but got none")
|
||||
}
|
||||
|
||||
if !tc.expectErr && err != nil {
|
||||
t.Errorf("test %d: expected no error but got: %v", i, err)
|
||||
}
|
||||
if !tc.expectPipeOutputErr && err != nil {
|
||||
t.Errorf("expected no error from cmd.pipeOutput(), but got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmd_Kill(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
cmd Cmd
|
||||
expectErr bool
|
||||
tests := []struct {
|
||||
name string
|
||||
cmd Cmd
|
||||
}{
|
||||
{
|
||||
cmd: Cmd{logger: zap.NewNop()},
|
||||
},
|
||||
{
|
||||
name: "nominal behavior",
|
||||
cmd: func() Cmd {
|
||||
cmd := Command(zap.NewNop(), "sleep", "60")
|
||||
err := cmd.process.Start()
|
||||
|
||||
err := cmd.process.Start()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
t.Fatalf("expected no error from cmd.process.Start(), but got: %v", err)
|
||||
}
|
||||
|
||||
return cmd
|
||||
}(),
|
||||
},
|
||||
{
|
||||
name: "no process",
|
||||
cmd: Cmd{logger: zap.NewNop()},
|
||||
},
|
||||
{
|
||||
name: "process already killed",
|
||||
cmd: func() Cmd {
|
||||
cmd := Command(zap.NewNop(), "echo", "Hello", "World")
|
||||
err := cmd.process.Run()
|
||||
cmd := Command(zap.NewNop(), "sleep", "60")
|
||||
|
||||
err := cmd.process.Start()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
t.Fatalf("expected no error from cmd.process.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
|
||||
}(),
|
||||
},
|
||||
} {
|
||||
err := tc.cmd.Kill()
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := tc.cmd.Kill()
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from cmd.Kill(), but got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
66
pkg/gotenberg/mocks.go
Normal file
66
pkg/gotenberg/mocks.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// ModuleMock is a mock for the Module interface.
|
||||
type ModuleMock struct {
|
||||
DescriptorMock func() ModuleDescriptor
|
||||
}
|
||||
|
||||
func (mod ModuleMock) Descriptor() ModuleDescriptor {
|
||||
return mod.DescriptorMock()
|
||||
}
|
||||
|
||||
// ValidatorMock is a mock for the Validator interface.
|
||||
type ValidatorMock struct {
|
||||
ValidateMock func() 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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// PDFEngineProviderMock is a mock for the PDFEngineProvider interface.
|
||||
type PDFEngineProviderMock struct {
|
||||
PDFEngineMock func() (PDFEngine, error)
|
||||
}
|
||||
|
||||
func (provider PDFEngineProviderMock) PDFEngine() (PDFEngine, error) {
|
||||
return provider.PDFEngineMock()
|
||||
}
|
||||
|
||||
// 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) {
|
||||
return provider.LoggerMock(mod)
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ Module = (*ModuleMock)(nil)
|
||||
_ Validator = (*ValidatorMock)(nil)
|
||||
_ PDFEngine = (*PDFEngineMock)(nil)
|
||||
_ PDFEngineProvider = (*PDFEngineProviderMock)(nil)
|
||||
_ LoggerProvider = (*LoggerProviderMock)(nil)
|
||||
)
|
||||
82
pkg/gotenberg/mocks_test.go
Normal file
82
pkg/gotenberg/mocks_test.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestModuleMock(t *testing.T) {
|
||||
mock := ModuleMock{
|
||||
DescriptorMock: func() ModuleDescriptor {
|
||||
return ModuleDescriptor{ID: "foo", New: func() Module {
|
||||
return nil
|
||||
}}
|
||||
},
|
||||
}
|
||||
|
||||
if mock.Descriptor().ID != "foo" {
|
||||
t.Errorf("expected ID '%s' from mock.Descriptor(), but got '%s'", "foo", mock.Descriptor().ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatorMock(t *testing.T) {
|
||||
mock := ValidatorMock{
|
||||
ValidateMock: func() error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
err := mock.Validate()
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from mock.Validate(), but got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPDFEngineMock(t *testing.T) {
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
err = mock.Convert(context.Background(), zap.NewNop(), "", "", "")
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from mock.Convert(), but got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPDFEngineProvider(t *testing.T) {
|
||||
mock := PDFEngineProviderMock{
|
||||
PDFEngineMock: func() (PDFEngine, error) {
|
||||
return PDFEngineMock{}, nil
|
||||
},
|
||||
}
|
||||
|
||||
_, err := mock.PDFEngine()
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from mock.PDFEngine(), but got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoggerProviderMock(t *testing.T) {
|
||||
mock := LoggerProviderMock{
|
||||
LoggerMock: func(mod Module) (*zap.Logger, error) {
|
||||
return nil, nil
|
||||
},
|
||||
}
|
||||
|
||||
_, err := mock.Logger(ModuleMock{})
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from mock.Logger(), but got: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,12 @@ type App interface {
|
||||
Stop(ctx context.Context) error
|
||||
}
|
||||
|
||||
// SystemLogger is a module interface for modules which want to display
|
||||
// messages on startup.
|
||||
type SystemLogger interface {
|
||||
SystemMessages() []string
|
||||
}
|
||||
|
||||
// MustRegisterModule registers a module.
|
||||
//
|
||||
// To register a module, create an init() method in the module main go file:
|
||||
|
||||
@@ -30,8 +30,8 @@ func init() {
|
||||
type API struct {
|
||||
port int
|
||||
readTimeout time.Duration
|
||||
processTimeout time.Duration
|
||||
writeTimeout time.Duration
|
||||
timeout time.Duration
|
||||
rootPath string
|
||||
traceHeader string
|
||||
disableHealthCheckLogging bool
|
||||
@@ -166,10 +166,20 @@ func (API) Descriptor() gotenberg.ModuleDescriptor {
|
||||
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.Duration("api-timeout", time.Duration(30)*time.Second, "Set the time limit for requests")
|
||||
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-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) },
|
||||
@@ -180,9 +190,9 @@ func (API) Descriptor() gotenberg.ModuleDescriptor {
|
||||
func (a *API) Provision(ctx *gotenberg.Context) error {
|
||||
flags := ctx.ParsedFlags()
|
||||
a.port = flags.MustInt("api-port")
|
||||
a.readTimeout = flags.MustDuration("api-read-timeout")
|
||||
a.processTimeout = flags.MustDuration("api-process-timeout")
|
||||
a.writeTimeout = flags.MustDuration("api-write-timeout")
|
||||
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.rootPath = flags.MustString("api-root-path")
|
||||
a.traceHeader = flags.MustString("api-trace-header")
|
||||
a.disableHealthCheckLogging = flags.MustBool("api-disable-health-check-logging")
|
||||
@@ -274,7 +284,7 @@ func (a *API) Provision(ctx *gotenberg.Context) error {
|
||||
}
|
||||
|
||||
// Grace duration.
|
||||
a.gcGraceDuration = a.readTimeout + a.processTimeout + a.writeTimeout
|
||||
a.gcGraceDuration = a.timeout
|
||||
|
||||
mods, err = ctx.Modules(new(GarbageCollectorGraceDurationIncrementer))
|
||||
if err != nil {
|
||||
@@ -378,7 +388,9 @@ func (a *API) Start() error {
|
||||
a.srv.HideBanner = true
|
||||
a.srv.HidePort = true
|
||||
a.srv.Server.ReadTimeout = a.readTimeout
|
||||
a.srv.Server.WriteTimeout = a.writeTimeout
|
||||
a.srv.Server.IdleTimeout = a.timeout
|
||||
// See https://github.com/gotenberg/gotenberg/issues/396.
|
||||
a.srv.Server.WriteTimeout = a.writeTimeout + a.writeTimeout
|
||||
a.srv.HTTPErrorHandler = httpErrorHandler()
|
||||
|
||||
// Let's prepare the modules' routes.
|
||||
@@ -402,7 +414,6 @@ func (a *API) Start() error {
|
||||
latencyMiddleware(),
|
||||
rootPathMiddleware(a.rootPath),
|
||||
traceMiddleware(a.traceHeader),
|
||||
timeoutsMiddleware(a.readTimeout, a.processTimeout, a.writeTimeout),
|
||||
loggerMiddleware(a.logger, disableLoggingForPaths),
|
||||
)
|
||||
|
||||
@@ -419,14 +430,14 @@ func (a *API) Start() error {
|
||||
}
|
||||
}
|
||||
|
||||
hardTimeout := a.processTimeout + (time.Duration(5) * time.Second)
|
||||
hardTimeout := a.timeout + (time.Duration(5) * time.Second)
|
||||
|
||||
// Add the modules' routes and their specific middlewares.
|
||||
for _, route := range a.routes {
|
||||
var middlewares []echo.MiddlewareFunc
|
||||
|
||||
if route.IsMultipart {
|
||||
middlewares = append(middlewares, contextMiddleware(a.processTimeout))
|
||||
middlewares = append(middlewares, contextMiddleware(a.timeout))
|
||||
|
||||
for _, externalMultipartMiddleware := range externalMultipartMiddlewares {
|
||||
middlewares = append(middlewares, externalMultipartMiddleware.Handler)
|
||||
@@ -447,7 +458,7 @@ func (a *API) Start() error {
|
||||
a.srv.GET(
|
||||
fmt.Sprintf("%s%s", a.rootPath, "health"),
|
||||
func() echo.HandlerFunc {
|
||||
checks := append(a.healthChecks, health.WithTimeout(a.processTimeout))
|
||||
checks := append(a.healthChecks, health.WithTimeout(a.timeout))
|
||||
checker := health.NewChecker(checks...)
|
||||
|
||||
return echo.WrapHandler(health.NewHandler(checker))
|
||||
|
||||
@@ -386,7 +386,7 @@ func TestAPI_Provision(t *testing.T) {
|
||||
},
|
||||
)
|
||||
}(),
|
||||
expectGraceDuration: time.Duration(93) * time.Second,
|
||||
expectGraceDuration: time.Duration(33) * time.Second,
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -277,6 +277,8 @@ func (ctx Context) OutputFilename(outputPath string) string {
|
||||
return fmt.Sprintf("%s%s", filename, filepath.Ext(outputPath))
|
||||
}
|
||||
|
||||
// TODO: move MockContext to mocks.go (and rename it ContextMock).
|
||||
|
||||
// MockContext is a helper for tests.
|
||||
//
|
||||
// ctx := &api.MockContext{Context: &api.Context{}}
|
||||
|
||||
@@ -14,7 +14,7 @@ type SentinelHTTPError struct {
|
||||
}
|
||||
|
||||
// NewSentinelHTTPError creates a SentinelHTTPError. The message will be sent
|
||||
// as the response's body if returned from an handler, so make sure to not leak
|
||||
// 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{
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -119,25 +120,6 @@ func traceMiddleware(header string) echo.MiddlewareFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// timeoutsMiddleware sets the read, process and write timeouts in the
|
||||
// echo.Context under "readTimeout", "processTimeout" and "writeTimeout".
|
||||
//
|
||||
// readTimeout := c.Get("readTimeout").(time.Duration)
|
||||
// processTimeout := c.Get("processTimeout").(time.Duration)
|
||||
// writeTimeout := c.Get("writeTimeout").(time.Duration)
|
||||
func timeoutsMiddleware(readTimeout, processTimeout, writeTimeout time.Duration) echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
c.Set("readTimeout", readTimeout)
|
||||
c.Set("processTimeout", processTimeout)
|
||||
c.Set("writeTimeout", writeTimeout)
|
||||
|
||||
// Call the next middleware in the chain.
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// loggerMiddleware sets the logger in the echo.Context under "logger" and logs
|
||||
// a synchronous request result.
|
||||
//
|
||||
@@ -147,10 +129,17 @@ func loggerMiddleware(logger *zap.Logger, disableLoggingForPaths []string) echo.
|
||||
return func(c echo.Context) error {
|
||||
startTime := c.Get("startTime").(time.Time)
|
||||
trace := c.Get("trace").(string)
|
||||
rootPath := c.Get("rootPath").(string)
|
||||
|
||||
// Create the request logger and add it to our locals.
|
||||
reqLogger := logger.With(zap.String("trace", trace))
|
||||
c.Set("logger", reqLogger)
|
||||
c.Set("logger", reqLogger.Named(func() string {
|
||||
return strings.ReplaceAll(
|
||||
strings.ReplaceAll(c.Request().URL.Path, rootPath, ""),
|
||||
"/",
|
||||
"",
|
||||
)
|
||||
}()))
|
||||
|
||||
// Call the next middleware in the chain.
|
||||
err := next(c)
|
||||
@@ -159,7 +148,6 @@ func loggerMiddleware(logger *zap.Logger, disableLoggingForPaths []string) echo.
|
||||
}
|
||||
|
||||
for _, path := range disableLoggingForPaths {
|
||||
rootPath := c.Get("rootPath").(string)
|
||||
URI := fmt.Sprintf("%s%s", rootPath, path)
|
||||
|
||||
if c.Request().RequestURI == URI {
|
||||
@@ -211,14 +199,14 @@ func loggerMiddleware(logger *zap.Logger, disableLoggingForPaths []string) echo.
|
||||
//
|
||||
// ctx := c.Get("context").(*api.Context)
|
||||
// cancel := c.Get("cancel").(context.CancelFunc)
|
||||
func contextMiddleware(processTimeout time.Duration) echo.MiddlewareFunc {
|
||||
func contextMiddleware(timeout time.Duration) echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
logger := c.Get("logger").(*zap.Logger)
|
||||
|
||||
// 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, processTimeout)
|
||||
ctx, cancel, err := newContext(c, logger, timeout)
|
||||
if err != nil {
|
||||
cancel()
|
||||
|
||||
|
||||
@@ -222,47 +222,6 @@ func TestTraceMiddleware(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeoutsMiddleware(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/foo", nil)
|
||||
|
||||
srv := echo.New()
|
||||
srv.HideBanner = true
|
||||
srv.HidePort = true
|
||||
|
||||
c := srv.NewContext(request, recorder)
|
||||
|
||||
expectReadTimeout := time.Duration(1) * time.Second
|
||||
expectProcessTimeout := time.Duration(2) * time.Second
|
||||
expectWriteTimeout := time.Duration(3) * time.Second
|
||||
|
||||
err := timeoutsMiddleware(expectReadTimeout, expectProcessTimeout, expectWriteTimeout)(
|
||||
func(c echo.Context) error {
|
||||
return nil
|
||||
},
|
||||
)(c)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
actualReadTimeout := c.Get("readTimeout").(time.Duration)
|
||||
actualProcessTimeout := c.Get("processTimeout").(time.Duration)
|
||||
actualWriteTimeout := c.Get("writeTimeout").(time.Duration)
|
||||
|
||||
if actualReadTimeout != expectReadTimeout {
|
||||
t.Errorf("expected '%s' but got '%s", expectReadTimeout, actualReadTimeout)
|
||||
}
|
||||
|
||||
if actualProcessTimeout != expectProcessTimeout {
|
||||
t.Errorf("expected '%s' but got '%s", expectProcessTimeout, actualProcessTimeout)
|
||||
}
|
||||
|
||||
if actualWriteTimeout != expectWriteTimeout {
|
||||
t.Errorf("expected '%s' but got '%s", actualWriteTimeout, expectWriteTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoggerMiddleware(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
request *http.Request
|
||||
|
||||
@@ -11,14 +11,17 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/chromedp/cdproto/emulation"
|
||||
"github.com/chromedp/cdproto/fetch"
|
||||
"github.com/chromedp/cdproto/network"
|
||||
"github.com/chromedp/cdproto/page"
|
||||
"github.com/chromedp/cdproto/runtime"
|
||||
"github.com/chromedp/chromedp"
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/modules/api"
|
||||
flag "github.com/spf13/pflag"
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -30,6 +33,14 @@ var (
|
||||
// allowed/denied lists.
|
||||
ErrURLNotAuthorized = errors.New("URL not authorized")
|
||||
|
||||
// ErrInvalidEmulatedMediaType happens if the emulated media type is not
|
||||
// "screen" nor "print". Empty value are allowed though.
|
||||
ErrInvalidEmulatedMediaType = errors.New("invalid emulated media type")
|
||||
|
||||
// ErrInvalidEvaluationExpression happens if an evaluation expression
|
||||
// returns an exception or undefined.
|
||||
ErrInvalidEvaluationExpression = errors.New("invalid evaluation expression")
|
||||
|
||||
// ErrInvalidPrinterSettings happens if the Options have one or more
|
||||
// aberrant values.
|
||||
ErrInvalidPrinterSettings = errors.New("invalid printer settings")
|
||||
@@ -41,6 +52,11 @@ var (
|
||||
// ErrRpccMessageTooLarge happens when the messages received by
|
||||
// ChromeDevTools are larger than 100 MB.
|
||||
ErrRpccMessageTooLarge = errors.New("rpcc message too large")
|
||||
|
||||
// ErrConsoleExceptions happens when there are exceptions in the Chromium
|
||||
// console. It also happens only if the Options.FailOnConsoleExceptions is
|
||||
// set to true.
|
||||
ErrConsoleExceptions = errors.New("console exceptions")
|
||||
)
|
||||
|
||||
// Chromium is a module which provides both an API and routes for converting
|
||||
@@ -51,14 +67,36 @@ type Chromium struct {
|
||||
userAgent string
|
||||
incognito bool
|
||||
ignoreCertificateErrors bool
|
||||
disableWebSecurity bool
|
||||
allowFileAccessFromFiles bool
|
||||
proxyServer string
|
||||
allowList *regexp.Regexp
|
||||
denyList *regexp.Regexp
|
||||
disableJavaScript bool
|
||||
disableRoutes bool
|
||||
}
|
||||
|
||||
// LinkTag represents an HTML <link> element.
|
||||
type LinkTag struct {
|
||||
// Href is the "href" attribute of the HTML <link> element.
|
||||
// Required.
|
||||
Href string `json:"href"`
|
||||
}
|
||||
|
||||
// ScriptTag represents an HTML <script> element.
|
||||
type ScriptTag struct {
|
||||
// Src is the "src" attribute of the HTML <script> element.
|
||||
// Required.
|
||||
Src string `json:"src"`
|
||||
}
|
||||
|
||||
// Options are the available options for converting HTML document to PDF.
|
||||
type Options struct {
|
||||
// FailOnConsoleExceptions sets if the conversion should fail if there are
|
||||
// exceptions in the Chromium console.
|
||||
// Optional.
|
||||
FailOnConsoleExceptions bool
|
||||
|
||||
// WaitDelay is the duration to wait when loading an HTML document before
|
||||
// converting it to PDF.
|
||||
// Optional.
|
||||
@@ -69,11 +107,33 @@ type Options struct {
|
||||
// Optional.
|
||||
WaitWindowStatus string
|
||||
|
||||
// WaitForExpression is the custom JavaScript expression to wait before
|
||||
// converting an HTML document to PDF until it returns true
|
||||
// Optional.
|
||||
WaitForExpression string
|
||||
|
||||
// UserAgent overrides the default User-Agent header.
|
||||
// Optional.
|
||||
UserAgent string
|
||||
|
||||
// ExtraHTTPHeaders are the HTTP headers to send by Chromium while loading
|
||||
// the HTML document.
|
||||
// Optional.
|
||||
ExtraHTTPHeaders map[string]string
|
||||
|
||||
// ExtraLinkTags are HTML <link> attributes that are added on the fly.
|
||||
// Optional.
|
||||
ExtraLinkTags []LinkTag
|
||||
|
||||
// EmulatedMediaType is the media type to emulate, either "screen" or
|
||||
// "print".
|
||||
// Optional.
|
||||
EmulatedMediaType string
|
||||
|
||||
// ExtraScriptTags are HTML <script> attributes that are added on the fly.
|
||||
// Optional.
|
||||
ExtraScriptTags []ScriptTag
|
||||
|
||||
// Landscape sets the paper orientation.
|
||||
// Optional.
|
||||
Landscape bool
|
||||
@@ -141,22 +201,28 @@ type Options struct {
|
||||
// DefaultOptions returns the default values for Options.
|
||||
func DefaultOptions() Options {
|
||||
return Options{
|
||||
WaitDelay: 0,
|
||||
WaitWindowStatus: "",
|
||||
ExtraHTTPHeaders: nil,
|
||||
Landscape: false,
|
||||
PrintBackground: false,
|
||||
Scale: 1.0,
|
||||
PaperWidth: 8.5,
|
||||
PaperHeight: 11,
|
||||
MarginTop: 0.39,
|
||||
MarginBottom: 0.39,
|
||||
MarginLeft: 0.39,
|
||||
MarginRight: 0.39,
|
||||
PageRanges: "",
|
||||
HeaderTemplate: "<html><head></head><body></body></html>",
|
||||
FooterTemplate: "<html><head></head><body></body></html>",
|
||||
PreferCSSPageSize: false,
|
||||
FailOnConsoleExceptions: false,
|
||||
WaitDelay: 0,
|
||||
WaitWindowStatus: "",
|
||||
WaitForExpression: "",
|
||||
UserAgent: "",
|
||||
ExtraHTTPHeaders: nil,
|
||||
ExtraLinkTags: nil,
|
||||
EmulatedMediaType: "",
|
||||
ExtraScriptTags: nil,
|
||||
Landscape: false,
|
||||
PrintBackground: false,
|
||||
Scale: 1.0,
|
||||
PaperWidth: 8.5,
|
||||
PaperHeight: 11,
|
||||
MarginTop: 0.39,
|
||||
MarginBottom: 0.39,
|
||||
MarginLeft: 0.39,
|
||||
MarginRight: 0.39,
|
||||
PageRanges: "",
|
||||
HeaderTemplate: "<html><head></head><body></body></html>",
|
||||
FooterTemplate: "<html><head></head><body></body></html>",
|
||||
PreferCSSPageSize: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,11 +251,19 @@ func (mod Chromium) Descriptor() gotenberg.ModuleDescriptor {
|
||||
fs.String("chromium-user-agent", "", "Override the default User-Agent header")
|
||||
fs.Bool("chromium-incognito", false, "Start Chromium with incognito mode")
|
||||
fs.Bool("chromium-ignore-certificate-errors", false, "Ignore the certificate errors")
|
||||
fs.Bool("chromium-disable-web-security", false, "Don't enforce the same-origin policy")
|
||||
fs.Bool("chromium-allow-file-access-from-files", false, "Allow file:// URIs to read other file:// URIs")
|
||||
fs.String("chromium-proxy-server", "", "Set the outbound proxy server; this switch only affects HTTP and HTTPS requests")
|
||||
fs.String("chromium-allow-list", "", "Set the allowed URLs for Chromium using a regular expression")
|
||||
fs.String("chromium-deny-list", "^file:///[^tmp].*", "Set the denied URLs for Chromium using a regular expression")
|
||||
fs.Bool("chromium-disable-javascript", false, "Disable JavaScript")
|
||||
fs.Bool("chromium-disable-routes", false, "Disable the routes")
|
||||
|
||||
err := fs.MarkDeprecated("chromium-user-agent", "use the userAgent form field instead")
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("create deprecated flags for chromium module: %v", err))
|
||||
}
|
||||
|
||||
return fs
|
||||
}(),
|
||||
New: func() gotenberg.Module { return new(Chromium) },
|
||||
@@ -199,10 +273,14 @@ func (mod Chromium) Descriptor() gotenberg.ModuleDescriptor {
|
||||
// Provision sets the module properties.
|
||||
func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
|
||||
flags := ctx.ParsedFlags()
|
||||
mod.userAgent = flags.MustString("chromium-user-agent")
|
||||
mod.ignoreCertificateErrors = flags.MustBool("chromium-ignore-certificate-errors")
|
||||
mod.disableWebSecurity = flags.MustBool("chromium-disable-web-security")
|
||||
mod.allowFileAccessFromFiles = flags.MustBool("chromium-allow-file-access-from-files")
|
||||
mod.proxyServer = flags.MustString("chromium-proxy-server")
|
||||
mod.allowList = flags.MustRegexp("chromium-allow-list")
|
||||
mod.denyList = flags.MustRegexp("chromium-deny-list")
|
||||
mod.disableJavaScript = flags.MustBool("chromium-disable-javascript")
|
||||
mod.disableRoutes = flags.MustBool("chromium-disable-routes")
|
||||
|
||||
binPath, ok := os.LookupEnv("CHROMIUM_BIN_PATH")
|
||||
@@ -277,7 +355,7 @@ func (mod Chromium) Routes() ([]api.Route, error) {
|
||||
// drastically. In such a scenario, the given context may also be done before
|
||||
// the end of the conversion.
|
||||
func (mod Chromium) PDF(ctx context.Context, logger *zap.Logger, URL, outputPath string, options Options) error {
|
||||
debug := debugLogger{logger: logger.Named("chromium.debug")}
|
||||
debug := debugLogger{logger: logger.Named("browser")}
|
||||
userProfileDirPath := gotenberg.NewDirPath()
|
||||
|
||||
args := append(chromedp.DefaultExecAllocatorOptions[:],
|
||||
@@ -295,7 +373,8 @@ func (mod Chromium) PDF(ctx context.Context, logger *zap.Logger, URL, outputPath
|
||||
chromedp.UserDataDir(userProfileDirPath),
|
||||
)
|
||||
|
||||
if mod.userAgent != "" {
|
||||
if mod.userAgent != "" && options.UserAgent == "" {
|
||||
// Deprecated.
|
||||
args = append(args, chromedp.UserAgent(mod.userAgent))
|
||||
}
|
||||
|
||||
@@ -307,11 +386,24 @@ func (mod Chromium) PDF(ctx context.Context, logger *zap.Logger, URL, outputPath
|
||||
args = append(args, chromedp.IgnoreCertErrors)
|
||||
}
|
||||
|
||||
if mod.disableWebSecurity {
|
||||
args = append(args, chromedp.Flag("disable-web-security", true))
|
||||
}
|
||||
|
||||
if mod.allowFileAccessFromFiles {
|
||||
// See https://github.com/gotenberg/gotenberg/issues/356.
|
||||
args = append(args, chromedp.Flag("allow-file-access-from-files", true))
|
||||
}
|
||||
|
||||
if mod.proxyServer != "" {
|
||||
// See https://github.com/gotenberg/gotenberg/issues/376.
|
||||
args = append(args, chromedp.ProxyServer(mod.proxyServer))
|
||||
}
|
||||
|
||||
if options.UserAgent != "" {
|
||||
args = append(args, chromedp.UserAgent(options.UserAgent))
|
||||
}
|
||||
|
||||
allocatorCtx, cancel := chromedp.NewExecAllocator(ctx, args...)
|
||||
defer cancel()
|
||||
|
||||
@@ -329,14 +421,42 @@ func (mod Chromium) PDF(ctx context.Context, logger *zap.Logger, URL, outputPath
|
||||
return fmt.Errorf("'%s' matches the expression from the denied list: %w", URL, ErrURLNotAuthorized)
|
||||
}
|
||||
|
||||
var (
|
||||
consoleExceptions error
|
||||
consoleExceptionsMu sync.RWMutex
|
||||
)
|
||||
|
||||
printToPDF := func(URL string, options Options, result *[]byte) chromedp.Tasks {
|
||||
// We validate the underlying requests against our allow / deny lists.
|
||||
// If a request does not pass the validation, we make it fail.
|
||||
listenForEventRequestPaused(taskCtx, logger, mod.allowList, mod.denyList)
|
||||
|
||||
// See https://github.com/gotenberg/gotenberg/issues/262.
|
||||
if options.FailOnConsoleExceptions && !mod.disableJavaScript {
|
||||
listenForEventExceptionThrown(taskCtx, logger, &consoleExceptions, &consoleExceptionsMu)
|
||||
}
|
||||
|
||||
return chromedp.Tasks{
|
||||
network.Enable(),
|
||||
fetch.Enable(),
|
||||
runtime.Enable(),
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
// See https://github.com/gotenberg/gotenberg/issues/175.
|
||||
if !mod.disableJavaScript {
|
||||
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)
|
||||
}),
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
if len(options.ExtraHTTPHeaders) == 0 {
|
||||
logger.Debug("no extra HTTP headers")
|
||||
@@ -406,60 +526,210 @@ func (mod Chromium) PDF(ctx context.Context, logger *zap.Logger, URL, outputPath
|
||||
return fmt.Errorf("add CSS for exact colors: %w", err)
|
||||
}),
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
if options.WaitDelay > 0 {
|
||||
// We wait for a given amount of time so that JavaScript
|
||||
// scripts have a chance to finish before printing the page
|
||||
// to PDF.
|
||||
logger.Debug(fmt.Sprintf("wait '%s' before print", options.WaitDelay))
|
||||
if len(options.ExtraLinkTags) == 0 {
|
||||
logger.Debug("no extra link tags")
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("wait delay: %w", ctx.Err())
|
||||
case <-time.After(options.WaitDelay):
|
||||
return nil
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.Debug(fmt.Sprintf("extra link tags: %+v", options.ExtraLinkTags))
|
||||
|
||||
addLinkTag := func(i int, linkTag LinkTag) func() error {
|
||||
return func() error {
|
||||
script := `
|
||||
(() => {
|
||||
const link = document.createElement('link');
|
||||
link.href = '%s';
|
||||
link.rel = 'stylesheet'
|
||||
document.head.appendChild(link);
|
||||
})();
|
||||
`
|
||||
|
||||
evaluate := chromedp.Evaluate(fmt.Sprintf(script, linkTag.Href), nil)
|
||||
err := evaluate.Do(ctx)
|
||||
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("add extra link tag %d: %w", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
eg, _ := errgroup.WithContext(ctx)
|
||||
|
||||
for i, linkTag := range options.ExtraLinkTags {
|
||||
eg.Go(addLinkTag(i, linkTag))
|
||||
}
|
||||
|
||||
err := eg.Wait()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("add extra link tags: %w", err)
|
||||
}),
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
if options.EmulatedMediaType == "" {
|
||||
logger.Debug("no emulated media type")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if options.EmulatedMediaType != "screen" && options.EmulatedMediaType != "print" {
|
||||
return fmt.Errorf("validate emulated media type '%s': %w", options.EmulatedMediaType, ErrInvalidEmulatedMediaType)
|
||||
}
|
||||
|
||||
logger.Debug(fmt.Sprintf("emulate media type '%s'", options.EmulatedMediaType))
|
||||
|
||||
emulatedMedia := emulation.SetEmulatedMedia()
|
||||
|
||||
err := emulatedMedia.WithMedia(options.EmulatedMediaType).Do(ctx)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("emulate media type '%s': %w", options.EmulatedMediaType, err)
|
||||
}),
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
if mod.disableJavaScript {
|
||||
logger.Debug("JavaScript disabled, skipping extra script tags")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(options.ExtraScriptTags) == 0 {
|
||||
logger.Debug("no extra script tags")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.Debug(fmt.Sprintf("extra script tags: %+v", options.ExtraScriptTags))
|
||||
|
||||
addScriptTag := func(i int, scriptTag ScriptTag) func() error {
|
||||
return func() error {
|
||||
script := `
|
||||
(() => {
|
||||
const script = document.createElement('script');
|
||||
script.src = '%s';
|
||||
document.head.appendChild(script);
|
||||
})();
|
||||
`
|
||||
|
||||
evaluate := chromedp.Evaluate(fmt.Sprintf(script, scriptTag.Src), nil)
|
||||
err := evaluate.Do(ctx)
|
||||
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("add extra script tag %d: %w", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
eg, _ := errgroup.WithContext(ctx)
|
||||
|
||||
for i, scriptTag := range options.ExtraScriptTags {
|
||||
eg.Go(addScriptTag(i, scriptTag))
|
||||
}
|
||||
|
||||
err := eg.Wait()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("add extra script tags: %w", err)
|
||||
}),
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
if mod.disableJavaScript {
|
||||
logger.Debug("JavaScript disabled, skipping wait delay")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if options.WaitDelay <= 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
|
||||
// to PDF.
|
||||
logger.Debug(fmt.Sprintf("wait '%s' before print", options.WaitDelay))
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("wait delay: %w", ctx.Err())
|
||||
case <-time.After(options.WaitDelay):
|
||||
return nil
|
||||
}
|
||||
}),
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
if mod.disableJavaScript {
|
||||
logger.Debug("JavaScript disabled, skipping wait for window status / wait for expression")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if options.WaitWindowStatus == "" && options.WaitForExpression == "" {
|
||||
logger.Debug("no wait for window status nor wait for expression")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
evaluate := func(expression string) error {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if options.WaitWindowStatus != "" {
|
||||
logger.Warn("option 'WaitWindowStatus' is deprecated; prefer 'WaitForExpression' instead")
|
||||
|
||||
err := evaluate(fmt.Sprintf("window.status === '%s'", options.WaitWindowStatus))
|
||||
if err != nil {
|
||||
return fmt.Errorf("wait for window.status === '%s': %w", options.WaitWindowStatus, err)
|
||||
}
|
||||
}
|
||||
|
||||
if options.WaitForExpression != "" {
|
||||
err := evaluate(options.WaitForExpression)
|
||||
if err != nil {
|
||||
return fmt.Errorf("wait for expression '%s': %w", options.WaitForExpression, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}),
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
if options.WaitWindowStatus == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// We wait until the evaluation of
|
||||
// "window.status === options.WaitWindowStatus" is true or
|
||||
// until the context is done.
|
||||
logger.Debug(fmt.Sprintf("wait for window.status === '%s' before print", options.WaitWindowStatus))
|
||||
|
||||
ticker := time.NewTicker(time.Duration(100) * time.Millisecond)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
ticker.Stop()
|
||||
|
||||
return fmt.Errorf("wait for window.status === '%s': %w", options.WaitWindowStatus, ctx.Err())
|
||||
case <-ticker.C:
|
||||
var ok bool
|
||||
|
||||
evaluate := chromedp.Evaluate(fmt.Sprintf("window.status === '%s'", options.WaitWindowStatus), &ok)
|
||||
err := evaluate.Do(ctx)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("evaluate: %w", err)
|
||||
}
|
||||
|
||||
if ok {
|
||||
ticker.Stop()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
}
|
||||
}),
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
printToPDF := page.PrintToPDF().
|
||||
WithLandscape(options.Landscape).
|
||||
@@ -531,6 +801,14 @@ func (mod Chromium) PDF(ctx context.Context, logger *zap.Logger, URL, outputPath
|
||||
return fmt.Errorf("chromium PDF: %w", err)
|
||||
}
|
||||
|
||||
// See https://github.com/gotenberg/gotenberg/issues/262.
|
||||
consoleExceptionsMu.RLock()
|
||||
defer consoleExceptionsMu.RUnlock()
|
||||
|
||||
if consoleExceptions != nil {
|
||||
return fmt.Errorf("%v: %w", consoleExceptions, ErrConsoleExceptions)
|
||||
}
|
||||
|
||||
err = ioutil.WriteFile(outputPath, buffer, 0600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("write result to output path: %w", err)
|
||||
|
||||
@@ -176,11 +176,11 @@ func TestChromium_Validate(t *testing.T) {
|
||||
func TestChromium_Metrics(t *testing.T) {
|
||||
metrics, err := new(Chromium).Metrics()
|
||||
if err != nil {
|
||||
t.Errorf("expected no error but got: %v", err)
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if len(metrics) != 1 {
|
||||
t.Errorf("expected %d metrics, but got %d", 1, len(metrics))
|
||||
t.Fatalf("expected %d metrics, but got %d", 1, len(metrics))
|
||||
}
|
||||
|
||||
actual := metrics[0].Read()
|
||||
@@ -233,9 +233,12 @@ func TestChromium_PDF(t *testing.T) {
|
||||
userAgent string
|
||||
incognito bool
|
||||
ignoreCertificateErrors bool
|
||||
disableWebSecurity bool
|
||||
allowFileAccessFromFiles bool
|
||||
proxyServer string
|
||||
allowList *regexp.Regexp
|
||||
denyList *regexp.Regexp
|
||||
disableJavaScript bool
|
||||
expectErr bool
|
||||
}{
|
||||
{
|
||||
@@ -248,6 +251,23 @@ func TestChromium_PDF(t *testing.T) {
|
||||
denyList: regexp.MustCompile("file:///tests/*"),
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html",
|
||||
options: Options{
|
||||
UserAgent: "foo",
|
||||
},
|
||||
},
|
||||
{
|
||||
URL: "file:///tests/test/testdata/chromium/html/sample10/index.html",
|
||||
options: Options{
|
||||
FailOnConsoleExceptions: true,
|
||||
},
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
URL: "file:///tests/test/testdata/chromium/html/sample9/index.html",
|
||||
disableJavaScript: true,
|
||||
},
|
||||
{
|
||||
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html",
|
||||
options: Options{
|
||||
@@ -256,6 +276,48 @@ func TestChromium_PDF(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
URL: "file:///tests/test/testdata/chromium/html/sample11/index.html",
|
||||
options: Options{
|
||||
ExtraLinkTags: []LinkTag{
|
||||
{
|
||||
Href: "font.woff",
|
||||
},
|
||||
{
|
||||
Href: "style.css",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
URL: "file:///tests/test/testdata/chromium/html/sample8/index.html",
|
||||
options: Options{
|
||||
EmulatedMediaType: "foo",
|
||||
},
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
URL: "file:///tests/test/testdata/chromium/html/sample8/index.html",
|
||||
options: Options{
|
||||
EmulatedMediaType: "screen",
|
||||
},
|
||||
},
|
||||
{
|
||||
URL: "file:///tests/test/testdata/chromium/html/sample8/index.html",
|
||||
options: Options{
|
||||
EmulatedMediaType: "print",
|
||||
},
|
||||
},
|
||||
{
|
||||
URL: "file:///tests/test/testdata/chromium/html/sample11/index.html",
|
||||
options: Options{
|
||||
ExtraScriptTags: []ScriptTag{
|
||||
{
|
||||
Src: "script.js",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html",
|
||||
options: Options{
|
||||
@@ -277,6 +339,28 @@ func TestChromium_PDF(t *testing.T) {
|
||||
WaitWindowStatus: "ready",
|
||||
},
|
||||
},
|
||||
{
|
||||
timeout: time.Duration(3) * time.Second,
|
||||
URL: "file:///tests/test/testdata/chromium/html/sample2/index.html",
|
||||
options: Options{
|
||||
WaitForExpression: "window.status === 'foo'",
|
||||
},
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
timeout: time.Duration(3) * time.Second,
|
||||
URL: "file:///tests/test/testdata/chromium/html/sample2/index.html",
|
||||
options: Options{
|
||||
WaitForExpression: "window.status === 'ready'",
|
||||
},
|
||||
},
|
||||
{
|
||||
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html",
|
||||
options: Options{
|
||||
WaitForExpression: "return undefined",
|
||||
},
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html",
|
||||
options: Options{
|
||||
@@ -296,7 +380,9 @@ func TestChromium_PDF(t *testing.T) {
|
||||
userAgent: "foo",
|
||||
incognito: true,
|
||||
ignoreCertificateErrors: true,
|
||||
disableWebSecurity: true,
|
||||
allowFileAccessFromFiles: true,
|
||||
proxyServer: "foo",
|
||||
},
|
||||
{
|
||||
URL: "file:///tests/test/testdata/chromium/html/sample1/index.html",
|
||||
@@ -347,7 +433,9 @@ func TestChromium_PDF(t *testing.T) {
|
||||
mod.userAgent = tc.userAgent
|
||||
mod.incognito = tc.incognito
|
||||
mod.ignoreCertificateErrors = tc.ignoreCertificateErrors
|
||||
mod.disableWebSecurity = tc.disableWebSecurity
|
||||
mod.allowFileAccessFromFiles = tc.allowFileAccessFromFiles
|
||||
mod.proxyServer = tc.proxyServer
|
||||
|
||||
if tc.allowList == nil {
|
||||
tc.allowList = regexp.MustCompile("")
|
||||
@@ -359,6 +447,7 @@ func TestChromium_PDF(t *testing.T) {
|
||||
|
||||
mod.allowList = tc.allowList
|
||||
mod.denyList = tc.denyList
|
||||
mod.disableJavaScript = tc.disableJavaScript
|
||||
|
||||
outputDir, err := gotenberg.MkdirAll()
|
||||
if err != nil {
|
||||
|
||||
@@ -4,12 +4,15 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sync"
|
||||
|
||||
"github.com/chromedp/cdproto/cdp"
|
||||
"github.com/chromedp/cdproto/fetch"
|
||||
"github.com/chromedp/cdproto/network"
|
||||
"github.com/chromedp/cdproto/page"
|
||||
"github.com/chromedp/cdproto/runtime"
|
||||
"github.com/chromedp/chromedp"
|
||||
"go.uber.org/multierr"
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
@@ -59,6 +62,23 @@ func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, allowL
|
||||
})
|
||||
}
|
||||
|
||||
// listenForEventExceptionThrown listens for exceptions in the console and
|
||||
// appends those exceptions to the given error pointer.
|
||||
// See https://github.com/gotenberg/gotenberg/issues/262.
|
||||
func listenForEventExceptionThrown(ctx context.Context, logger *zap.Logger, consoleExceptions *error, consoleExceptionsMu *sync.RWMutex) {
|
||||
chromedp.ListenTarget(ctx, func(ev interface{}) {
|
||||
switch ev := ev.(type) {
|
||||
case *runtime.EventExceptionThrown:
|
||||
logger.Debug(fmt.Sprintf("event EventExceptionThrown fired: %+v", ev.ExceptionDetails))
|
||||
|
||||
consoleExceptionsMu.Lock()
|
||||
defer consoleExceptionsMu.Unlock()
|
||||
|
||||
*consoleExceptions = multierr.Append(*consoleExceptions, fmt.Errorf("\n%+v", ev.ExceptionDetails))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// waitForEventDomContentEventFired waits until the event DomContentEventFired
|
||||
// is fired or the context timeout.
|
||||
func waitForEventDomContentEventFired(ctx context.Context, logger *zap.Logger) func() error {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
||||
@@ -26,9 +27,13 @@ func FormDataChromiumPDFOptions(ctx *api.Context) (*api.FormData, Options) {
|
||||
defaultOptions := DefaultOptions()
|
||||
|
||||
var (
|
||||
failOnConsoleExceptions bool
|
||||
waitDelay time.Duration
|
||||
waitWindowStatus string
|
||||
waitForExpression string
|
||||
userAgent string
|
||||
extraHTTPHeaders map[string]string
|
||||
emulatedMediaType string
|
||||
landscape, printBackground bool
|
||||
scale, paperWidth, paperHeight float64
|
||||
marginTop, marginBottom, marginLeft, marginRight float64
|
||||
@@ -38,8 +43,11 @@ func FormDataChromiumPDFOptions(ctx *api.Context) (*api.FormData, Options) {
|
||||
)
|
||||
|
||||
form := ctx.FormData().
|
||||
Bool("failOnConsoleExceptions", &failOnConsoleExceptions, defaultOptions.FailOnConsoleExceptions).
|
||||
Duration("waitDelay", &waitDelay, defaultOptions.WaitDelay).
|
||||
String("waitWindowStatus", &waitWindowStatus, defaultOptions.WaitWindowStatus).
|
||||
String("waitForExpression", &waitForExpression, defaultOptions.WaitForExpression).
|
||||
String("userAgent", &userAgent, defaultOptions.UserAgent).
|
||||
Custom("extraHttpHeaders", func(value string) error {
|
||||
if value == "" {
|
||||
extraHTTPHeaders = defaultOptions.ExtraHTTPHeaders
|
||||
@@ -54,6 +62,21 @@ func FormDataChromiumPDFOptions(ctx *api.Context) (*api.FormData, Options) {
|
||||
|
||||
return nil
|
||||
}).
|
||||
Custom("emulatedMediaType", func(value string) error {
|
||||
if value == "" {
|
||||
emulatedMediaType = defaultOptions.EmulatedMediaType
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if value != "screen" && value != "print" {
|
||||
return fmt.Errorf("wrong value, expected either 'screen', 'print' or empty")
|
||||
}
|
||||
|
||||
emulatedMediaType = value
|
||||
|
||||
return nil
|
||||
}).
|
||||
Bool("landscape", &landscape, defaultOptions.Landscape).
|
||||
Bool("printBackground", &printBackground, defaultOptions.PrintBackground).
|
||||
Float64("scale", &scale, defaultOptions.Scale).
|
||||
@@ -69,22 +92,28 @@ func FormDataChromiumPDFOptions(ctx *api.Context) (*api.FormData, Options) {
|
||||
Bool("preferCssPageSize", &preferCSSPageSize, defaultOptions.PreferCSSPageSize)
|
||||
|
||||
options := Options{
|
||||
WaitDelay: waitDelay,
|
||||
WaitWindowStatus: waitWindowStatus,
|
||||
ExtraHTTPHeaders: extraHTTPHeaders,
|
||||
Landscape: landscape,
|
||||
PrintBackground: printBackground,
|
||||
Scale: scale,
|
||||
PaperWidth: paperWidth,
|
||||
PaperHeight: paperHeight,
|
||||
MarginTop: marginTop,
|
||||
MarginBottom: marginBottom,
|
||||
MarginLeft: marginLeft,
|
||||
MarginRight: marginRight,
|
||||
PageRanges: pageRanges,
|
||||
HeaderTemplate: headerTemplate,
|
||||
FooterTemplate: footerTemplate,
|
||||
PreferCSSPageSize: preferCSSPageSize,
|
||||
FailOnConsoleExceptions: failOnConsoleExceptions,
|
||||
WaitDelay: waitDelay,
|
||||
WaitWindowStatus: waitWindowStatus,
|
||||
WaitForExpression: waitForExpression,
|
||||
UserAgent: userAgent,
|
||||
ExtraHTTPHeaders: extraHTTPHeaders,
|
||||
ExtraLinkTags: defaultOptions.ExtraLinkTags,
|
||||
EmulatedMediaType: emulatedMediaType,
|
||||
ExtraScriptTags: defaultOptions.ExtraScriptTags,
|
||||
Landscape: landscape,
|
||||
PrintBackground: printBackground,
|
||||
Scale: scale,
|
||||
PaperWidth: paperWidth,
|
||||
PaperHeight: paperHeight,
|
||||
MarginTop: marginTop,
|
||||
MarginBottom: marginBottom,
|
||||
MarginLeft: marginLeft,
|
||||
MarginRight: marginRight,
|
||||
PageRanges: pageRanges,
|
||||
HeaderTemplate: headerTemplate,
|
||||
FooterTemplate: footerTemplate,
|
||||
PreferCSSPageSize: preferCSSPageSize,
|
||||
}
|
||||
|
||||
return form, options
|
||||
@@ -108,6 +137,30 @@ func convertURLRoute(chromium API, engine gotenberg.PDFEngine) api.Route {
|
||||
err := form.
|
||||
MandatoryString("url", &URL).
|
||||
String("pdfFormat", &PDFformat, "").
|
||||
Custom("extraLinkTags", func(value string) error {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := json.Unmarshal([]byte(value), &options.ExtraLinkTags)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unmarshal extra link tags: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}).
|
||||
Custom("extraScriptTags", func(value string) error {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := json.Unmarshal([]byte(value), &options.ExtraScriptTags)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unmarshal extra script tags: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}).
|
||||
Validate()
|
||||
|
||||
if err != nil {
|
||||
@@ -288,6 +341,23 @@ func convertURL(ctx *api.Context, chromium API, engine gotenberg.PDFEngine, URL,
|
||||
)
|
||||
}
|
||||
|
||||
if errors.Is(err, ErrInvalidEvaluationExpression) {
|
||||
if options.WaitForExpression == "" {
|
||||
// We do not expect the 'waitWindowStatus' form field to return
|
||||
// an ErrInvalidEvaluationExpression error. In such a scenario,
|
||||
// we return a 500.
|
||||
return fmt.Errorf("convert to PDF: %w", err)
|
||||
}
|
||||
|
||||
return api.WrapError(
|
||||
fmt.Errorf("convert to PDF: %w", err),
|
||||
api.NewSentinelHTTPError(
|
||||
http.StatusBadRequest,
|
||||
fmt.Sprintf("The expression '%s' (waitForExpression) returned an exception or undefined", options.WaitForExpression),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if errors.Is(err, ErrInvalidPrinterSettings) {
|
||||
return api.WrapError(
|
||||
fmt.Errorf("convert to PDF: %w", err),
|
||||
@@ -308,6 +378,16 @@ func convertURL(ctx *api.Context, chromium API, engine gotenberg.PDFEngine, URL,
|
||||
)
|
||||
}
|
||||
|
||||
if errors.Is(err, ErrConsoleExceptions) {
|
||||
return api.WrapError(
|
||||
fmt.Errorf("convert to PDF: %w", err),
|
||||
api.NewSentinelHTTPError(
|
||||
http.StatusConflict,
|
||||
fmt.Sprintf("Chromium console exceptions:\n %s", strings.ReplaceAll(err.Error(), ErrConsoleExceptions.Error(), "")),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return fmt.Errorf("convert to PDF: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,50 @@ func TestFormDataChromiumPDFOptions(t *testing.T) {
|
||||
"foo": "bar",
|
||||
}
|
||||
|
||||
return options
|
||||
}(),
|
||||
},
|
||||
{
|
||||
ctx: func() *api.MockContext {
|
||||
ctx := &api.MockContext{Context: &api.Context{}}
|
||||
ctx.SetValues(map[string][]string{
|
||||
"extraHttpHeaders": {
|
||||
"foo",
|
||||
},
|
||||
})
|
||||
|
||||
return ctx
|
||||
}(),
|
||||
options: DefaultOptions(),
|
||||
},
|
||||
{
|
||||
ctx: func() *api.MockContext {
|
||||
ctx := &api.MockContext{Context: &api.Context{}}
|
||||
ctx.SetValues(map[string][]string{
|
||||
"emulatedMediaType": {
|
||||
"foo",
|
||||
},
|
||||
})
|
||||
|
||||
return ctx
|
||||
}(),
|
||||
options: DefaultOptions(),
|
||||
},
|
||||
{
|
||||
ctx: func() *api.MockContext {
|
||||
ctx := &api.MockContext{Context: &api.Context{}}
|
||||
ctx.SetValues(map[string][]string{
|
||||
"emulatedMediaType": {
|
||||
"screen",
|
||||
},
|
||||
})
|
||||
|
||||
return ctx
|
||||
}(),
|
||||
options: func() Options {
|
||||
options := DefaultOptions()
|
||||
options.EmulatedMediaType = "screen"
|
||||
|
||||
return options
|
||||
}(),
|
||||
},
|
||||
@@ -60,7 +104,7 @@ func TestFormDataChromiumPDFOptions(t *testing.T) {
|
||||
_, actual := FormDataChromiumPDFOptions(tc.ctx.Context)
|
||||
|
||||
if !reflect.DeepEqual(actual, tc.options) {
|
||||
t.Errorf("test %d: expected %v but got: %v", i, tc.options, actual)
|
||||
t.Errorf("test %d: expected %+v but got: %+v", i, tc.options, actual)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -137,6 +181,85 @@ func TestConvertURLHandler(t *testing.T) {
|
||||
}(),
|
||||
expectOutputPathsCount: 1,
|
||||
},
|
||||
{
|
||||
ctx: func() *api.MockContext {
|
||||
ctx := &api.MockContext{Context: &api.Context{}}
|
||||
ctx.SetValues(map[string][]string{
|
||||
"url": {
|
||||
"foo",
|
||||
},
|
||||
"extraLinkTags": {
|
||||
"foo",
|
||||
},
|
||||
})
|
||||
|
||||
return ctx
|
||||
}(),
|
||||
api: func() API {
|
||||
chromiumAPI := struct{ ProtoAPI }{}
|
||||
chromiumAPI.pdf = func(_ context.Context, _ *zap.Logger, _, _ string, _ Options) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
return chromiumAPI
|
||||
}(),
|
||||
expectErr: true,
|
||||
expectHTTPErr: true,
|
||||
expectHTTPStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
ctx: func() *api.MockContext {
|
||||
ctx := &api.MockContext{Context: &api.Context{}}
|
||||
ctx.SetValues(map[string][]string{
|
||||
"url": {
|
||||
"foo",
|
||||
},
|
||||
"extraScriptTags": {
|
||||
"foo",
|
||||
},
|
||||
})
|
||||
|
||||
return ctx
|
||||
}(),
|
||||
api: func() API {
|
||||
chromiumAPI := struct{ ProtoAPI }{}
|
||||
chromiumAPI.pdf = func(_ context.Context, _ *zap.Logger, _, _ string, _ Options) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
return chromiumAPI
|
||||
}(),
|
||||
expectErr: true,
|
||||
expectHTTPErr: true,
|
||||
expectHTTPStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
ctx: func() *api.MockContext {
|
||||
ctx := &api.MockContext{Context: &api.Context{}}
|
||||
ctx.SetValues(map[string][]string{
|
||||
"url": {
|
||||
"foo",
|
||||
},
|
||||
"extraLinkTags": {
|
||||
`[{"href":"https://cdn.foo/foo.css"},{"href":"https://cdn.bar/bar.css"}]`,
|
||||
},
|
||||
"extraScriptTags": {
|
||||
`[{"src":"https://cdn.foo/foo.js"},{"src":"https://cdn.bar/bar.js"}]`,
|
||||
},
|
||||
})
|
||||
|
||||
return ctx
|
||||
}(),
|
||||
api: func() API {
|
||||
chromiumAPI := struct{ ProtoAPI }{}
|
||||
chromiumAPI.pdf = func(_ context.Context, _ *zap.Logger, _, _ string, _ Options) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
return chromiumAPI
|
||||
}(),
|
||||
expectOutputPathsCount: 1,
|
||||
},
|
||||
} {
|
||||
c := echo.New().NewContext(nil, nil)
|
||||
c.Set("context", tc.ctx.Context)
|
||||
@@ -523,6 +646,7 @@ func TestConvertURL(t *testing.T) {
|
||||
api API
|
||||
engine gotenberg.PDFEngine
|
||||
PDFformat string
|
||||
options Options
|
||||
expectErr bool
|
||||
expectHTTPErr bool
|
||||
expectHTTPStatus int
|
||||
@@ -538,10 +662,44 @@ func TestConvertURL(t *testing.T) {
|
||||
|
||||
return chromiumAPI
|
||||
}(),
|
||||
options: DefaultOptions(),
|
||||
expectErr: true,
|
||||
expectHTTPErr: true,
|
||||
expectHTTPStatus: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
ctx: &api.MockContext{Context: &api.Context{}},
|
||||
api: func() API {
|
||||
chromiumAPI := struct{ ProtoAPI }{}
|
||||
chromiumAPI.pdf = func(_ context.Context, _ *zap.Logger, _, _ string, _ Options) error {
|
||||
return ErrInvalidEvaluationExpression
|
||||
}
|
||||
|
||||
return chromiumAPI
|
||||
}(),
|
||||
options: DefaultOptions(),
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
ctx: &api.MockContext{Context: &api.Context{}},
|
||||
api: func() API {
|
||||
chromiumAPI := struct{ ProtoAPI }{}
|
||||
chromiumAPI.pdf = func(_ context.Context, _ *zap.Logger, _, _ string, _ Options) error {
|
||||
return ErrInvalidEvaluationExpression
|
||||
}
|
||||
|
||||
return chromiumAPI
|
||||
}(),
|
||||
options: func() Options {
|
||||
options := DefaultOptions()
|
||||
options.WaitForExpression = "foo"
|
||||
|
||||
return options
|
||||
}(),
|
||||
expectErr: true,
|
||||
expectHTTPErr: true,
|
||||
expectHTTPStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
ctx: &api.MockContext{Context: &api.Context{}},
|
||||
api: func() API {
|
||||
@@ -552,6 +710,7 @@ func TestConvertURL(t *testing.T) {
|
||||
|
||||
return chromiumAPI
|
||||
}(),
|
||||
options: DefaultOptions(),
|
||||
expectErr: true,
|
||||
expectHTTPErr: true,
|
||||
expectHTTPStatus: http.StatusBadRequest,
|
||||
@@ -566,10 +725,26 @@ func TestConvertURL(t *testing.T) {
|
||||
|
||||
return chromiumAPI
|
||||
}(),
|
||||
options: DefaultOptions(),
|
||||
expectErr: true,
|
||||
expectHTTPErr: true,
|
||||
expectHTTPStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
ctx: &api.MockContext{Context: &api.Context{}},
|
||||
api: func() API {
|
||||
chromiumAPI := struct{ ProtoAPI }{}
|
||||
chromiumAPI.pdf = func(_ context.Context, _ *zap.Logger, _, _ string, _ Options) error {
|
||||
return ErrConsoleExceptions
|
||||
}
|
||||
|
||||
return chromiumAPI
|
||||
}(),
|
||||
options: DefaultOptions(),
|
||||
expectErr: true,
|
||||
expectHTTPErr: true,
|
||||
expectHTTPStatus: http.StatusConflict,
|
||||
},
|
||||
{
|
||||
ctx: &api.MockContext{Context: &api.Context{}},
|
||||
api: func() API {
|
||||
@@ -580,6 +755,7 @@ func TestConvertURL(t *testing.T) {
|
||||
|
||||
return chromiumAPI
|
||||
}(),
|
||||
options: DefaultOptions(),
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
@@ -600,6 +776,7 @@ func TestConvertURL(t *testing.T) {
|
||||
}
|
||||
}(),
|
||||
PDFformat: "foo",
|
||||
options: DefaultOptions(),
|
||||
expectErr: true,
|
||||
expectHTTPErr: true,
|
||||
expectHTTPStatus: http.StatusBadRequest,
|
||||
@@ -622,6 +799,7 @@ func TestConvertURL(t *testing.T) {
|
||||
}
|
||||
}(),
|
||||
PDFformat: "foo",
|
||||
options: DefaultOptions(),
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
@@ -642,6 +820,7 @@ func TestConvertURL(t *testing.T) {
|
||||
}
|
||||
}(),
|
||||
PDFformat: "foo",
|
||||
options: DefaultOptions(),
|
||||
expectOutputPathsCount: 1,
|
||||
},
|
||||
{
|
||||
@@ -659,6 +838,7 @@ func TestConvertURL(t *testing.T) {
|
||||
|
||||
return chromiumAPI
|
||||
}(),
|
||||
options: DefaultOptions(),
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
@@ -671,10 +851,11 @@ func TestConvertURL(t *testing.T) {
|
||||
|
||||
return chromiumAPI
|
||||
}(),
|
||||
options: DefaultOptions(),
|
||||
expectOutputPathsCount: 1,
|
||||
},
|
||||
} {
|
||||
err := convertURL(tc.ctx.Context, tc.api, tc.engine, "", tc.PDFformat, DefaultOptions())
|
||||
err := convertURL(tc.ctx.Context, tc.api, tc.engine, "", tc.PDFformat, tc.options)
|
||||
|
||||
if tc.expectErr && err == nil {
|
||||
t.Errorf("test %d: expected error but got: %v", i, err)
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -202,19 +201,10 @@ func (gc *GarbageCollector) Stop(ctx context.Context) error {
|
||||
}
|
||||
|
||||
// Block until the context is done so that other module may gracefully stop
|
||||
// before we do a shutdown cleanup. We skip this step if we receive a
|
||||
// SIGINT in the meantime.
|
||||
// before we do a shutdown cleanup.
|
||||
gc.logger.Debug("wait for the end of grace duration")
|
||||
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, os.Interrupt)
|
||||
|
||||
select {
|
||||
case <-quit:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
break
|
||||
}
|
||||
<-ctx.Done()
|
||||
|
||||
gc.ticker.Stop()
|
||||
gc.done <- true
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
// Package libreoffice provides a module which adds a route for converting
|
||||
// document to PDF with LibreOffice.
|
||||
// documents to PDF with LibreOffice.
|
||||
package libreoffice
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/modules/api"
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/unoconv"
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/uno"
|
||||
flag "github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
@@ -16,7 +16,7 @@ func init() {
|
||||
// LibreOffice is a module which provides a route for converting documents to
|
||||
// PDF with LibreOffice.
|
||||
type LibreOffice struct {
|
||||
unoconv unoconv.API
|
||||
unoAPI uno.API
|
||||
engine gotenberg.PDFEngine
|
||||
disableRoutes bool
|
||||
}
|
||||
@@ -40,17 +40,17 @@ func (mod *LibreOffice) Provision(ctx *gotenberg.Context) error {
|
||||
flags := ctx.ParsedFlags()
|
||||
mod.disableRoutes = flags.MustBool("libreoffice-disable-routes")
|
||||
|
||||
provider, err := ctx.Module(new(unoconv.Provider))
|
||||
provider, err := ctx.Module(new(uno.Provider))
|
||||
if err != nil {
|
||||
return fmt.Errorf("get unoconv provider: %w", err)
|
||||
return fmt.Errorf("get unoAPI provider: %w", err)
|
||||
}
|
||||
|
||||
uno, err := provider.(unoconv.Provider).Unoconv()
|
||||
unoAPI, err := provider.(uno.Provider).UNO()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get unoconv API: %w", err)
|
||||
return fmt.Errorf("get unoAPI API: %w", err)
|
||||
}
|
||||
|
||||
mod.unoconv = uno
|
||||
mod.unoAPI = unoAPI
|
||||
|
||||
provider, err = ctx.Module(new(gotenberg.PDFEngineProvider))
|
||||
if err != nil {
|
||||
@@ -74,7 +74,7 @@ func (mod LibreOffice) Routes() ([]api.Route, error) {
|
||||
}
|
||||
|
||||
return []api.Route{
|
||||
convertRoute(mod.unoconv, mod.engine),
|
||||
convertRoute(mod.unoAPI, mod.engine),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,68 +1,14 @@
|
||||
package libreoffice
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/unoconv"
|
||||
"go.uber.org/zap"
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/uno"
|
||||
)
|
||||
|
||||
type ProtoModule struct {
|
||||
descriptor func() gotenberg.ModuleDescriptor
|
||||
}
|
||||
|
||||
func (mod ProtoModule) Descriptor() gotenberg.ModuleDescriptor {
|
||||
return mod.descriptor()
|
||||
}
|
||||
|
||||
type ProtoUnoconvProvider struct {
|
||||
ProtoModule
|
||||
unoconv func() (unoconv.API, error)
|
||||
}
|
||||
|
||||
func (mod ProtoUnoconvProvider) Unoconv() (unoconv.API, error) {
|
||||
return mod.unoconv()
|
||||
}
|
||||
|
||||
type ProtoUnoconvAPI struct {
|
||||
pdf func(_ context.Context, _ *zap.Logger, _, _ string, _ unoconv.Options) error
|
||||
extensions func() []string
|
||||
}
|
||||
|
||||
func (mod ProtoUnoconvAPI) PDF(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options unoconv.Options) error {
|
||||
return mod.pdf(ctx, logger, inputPath, outputPath, options)
|
||||
}
|
||||
|
||||
func (mod ProtoUnoconvAPI) Extensions() []string {
|
||||
return mod.extensions()
|
||||
}
|
||||
|
||||
type ProtoPDFEngineProvider struct {
|
||||
ProtoModule
|
||||
pdfEngine func() (gotenberg.PDFEngine, error)
|
||||
}
|
||||
|
||||
func (mod ProtoPDFEngineProvider) PDFEngine() (gotenberg.PDFEngine, error) {
|
||||
return mod.pdfEngine()
|
||||
}
|
||||
|
||||
type ProtoPDFEngine struct {
|
||||
merge func(_ context.Context, _ *zap.Logger, _ []string, _ string) error
|
||||
convert func(_ context.Context, _ *zap.Logger, _, _, _ string) error
|
||||
}
|
||||
|
||||
func (mod ProtoPDFEngine) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
||||
return mod.merge(ctx, logger, inputPaths, outputPath)
|
||||
}
|
||||
|
||||
func (mod ProtoPDFEngine) Convert(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error {
|
||||
return mod.convert(ctx, logger, format, inputPath, outputPath)
|
||||
}
|
||||
|
||||
func TestLibreOffice_Descriptor(t *testing.T) {
|
||||
descriptor := LibreOffice{}.Descriptor()
|
||||
|
||||
@@ -75,15 +21,38 @@ func TestLibreOffice_Descriptor(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLibreOffice_Provision(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
ctx *gotenberg.Context
|
||||
expectErr bool
|
||||
tests := []struct {
|
||||
name string
|
||||
ctx *gotenberg.Context
|
||||
expectProvisionErr bool
|
||||
}{
|
||||
{
|
||||
name: "nominal behavior",
|
||||
ctx: func() *gotenberg.Context {
|
||||
mod := struct{ ProtoModule }{}
|
||||
mod.descriptor = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }}
|
||||
provider1 := struct {
|
||||
gotenberg.ModuleMock
|
||||
uno.ProviderMock
|
||||
}{}
|
||||
provider1.DescriptorMock = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
|
||||
return provider1
|
||||
}}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
return gotenberg.NewContext(
|
||||
@@ -91,41 +60,36 @@ func TestLibreOffice_Provision(t *testing.T) {
|
||||
FlagSet: new(LibreOffice).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{
|
||||
mod.Descriptor(),
|
||||
provider1.Descriptor(),
|
||||
provider2.Descriptor(),
|
||||
},
|
||||
)
|
||||
}(),
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
ctx: func() *gotenberg.Context {
|
||||
mod := struct{ ProtoUnoconvProvider }{}
|
||||
mod.descriptor = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }}
|
||||
}
|
||||
mod.unoconv = func() (unoconv.API, error) {
|
||||
return nil, errors.New("foo")
|
||||
}
|
||||
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: new(LibreOffice).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{
|
||||
mod.Descriptor(),
|
||||
},
|
||||
)
|
||||
}(),
|
||||
expectErr: 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",
|
||||
ctx: func() *gotenberg.Context {
|
||||
mod := struct{ ProtoUnoconvProvider }{}
|
||||
mod.descriptor = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }}
|
||||
provider := struct {
|
||||
gotenberg.ModuleMock
|
||||
uno.ProviderMock
|
||||
}{}
|
||||
provider.DescriptorMock = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
|
||||
return provider
|
||||
}}
|
||||
}
|
||||
mod.unoconv = func() (unoconv.API, error) {
|
||||
return struct{ ProtoUnoconvAPI }{}, nil
|
||||
provider.UNOMock = func() (uno.API, error) {
|
||||
return uno.APIMock{}, errors.New("foo")
|
||||
}
|
||||
|
||||
return gotenberg.NewContext(
|
||||
@@ -133,28 +97,26 @@ func TestLibreOffice_Provision(t *testing.T) {
|
||||
FlagSet: new(LibreOffice).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{
|
||||
mod.Descriptor(),
|
||||
provider.Descriptor(),
|
||||
},
|
||||
)
|
||||
}(),
|
||||
expectErr: true,
|
||||
expectProvisionErr: true,
|
||||
},
|
||||
{
|
||||
name: "no PDF engine provider",
|
||||
ctx: func() *gotenberg.Context {
|
||||
mod1 := struct{ ProtoUnoconvProvider }{}
|
||||
mod1.descriptor = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod1 }}
|
||||
provider := struct {
|
||||
gotenberg.ModuleMock
|
||||
uno.ProviderMock
|
||||
}{}
|
||||
provider.DescriptorMock = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
|
||||
return provider
|
||||
}}
|
||||
}
|
||||
mod1.unoconv = func() (unoconv.API, error) {
|
||||
return struct{ ProtoUnoconvAPI }{}, nil
|
||||
}
|
||||
|
||||
mod2 := struct{ ProtoPDFEngineProvider }{}
|
||||
mod2.descriptor = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod2 }}
|
||||
}
|
||||
mod2.pdfEngine = func() (gotenberg.PDFEngine, error) {
|
||||
return nil, errors.New("foo")
|
||||
provider.UNOMock = func() (uno.API, error) {
|
||||
return uno.APIMock{}, nil
|
||||
}
|
||||
|
||||
return gotenberg.NewContext(
|
||||
@@ -162,29 +124,39 @@ func TestLibreOffice_Provision(t *testing.T) {
|
||||
FlagSet: new(LibreOffice).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{
|
||||
mod1.Descriptor(),
|
||||
mod2.Descriptor(),
|
||||
provider.Descriptor(),
|
||||
},
|
||||
)
|
||||
}(),
|
||||
expectErr: true,
|
||||
expectProvisionErr: true,
|
||||
},
|
||||
{
|
||||
name: "no PDF engine from PDF engine provider",
|
||||
ctx: func() *gotenberg.Context {
|
||||
mod1 := struct{ ProtoUnoconvProvider }{}
|
||||
mod1.descriptor = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod1 }}
|
||||
provider1 := struct {
|
||||
gotenberg.ModuleMock
|
||||
uno.ProviderMock
|
||||
}{}
|
||||
provider1.DescriptorMock = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
|
||||
return provider1
|
||||
}}
|
||||
}
|
||||
mod1.unoconv = func() (unoconv.API, error) {
|
||||
return struct{ ProtoUnoconvAPI }{}, nil
|
||||
provider1.UNOMock = func() (uno.API, error) {
|
||||
return uno.APIMock{}, nil
|
||||
}
|
||||
|
||||
mod2 := struct{ ProtoPDFEngineProvider }{}
|
||||
mod2.descriptor = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod2 }}
|
||||
provider2 := struct {
|
||||
gotenberg.ModuleMock
|
||||
gotenberg.PDFEngineProviderMock
|
||||
}{}
|
||||
provider2.DescriptorMock = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module {
|
||||
return provider2
|
||||
}}
|
||||
}
|
||||
mod2.pdfEngine = func() (gotenberg.PDFEngine, error) {
|
||||
return struct{ ProtoPDFEngine }{}, nil
|
||||
provider2.PDFEngineMock = func() (gotenberg.PDFEngine, error) {
|
||||
return gotenberg.PDFEngineMock{}, errors.New("foo")
|
||||
}
|
||||
|
||||
return gotenberg.NewContext(
|
||||
@@ -192,59 +164,60 @@ func TestLibreOffice_Provision(t *testing.T) {
|
||||
FlagSet: new(LibreOffice).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{
|
||||
mod1.Descriptor(),
|
||||
mod2.Descriptor(),
|
||||
provider1.Descriptor(),
|
||||
provider2.Descriptor(),
|
||||
},
|
||||
)
|
||||
}(),
|
||||
expectProvisionErr: true,
|
||||
},
|
||||
} {
|
||||
mod := new(LibreOffice)
|
||||
err := mod.Provision(tc.ctx)
|
||||
}
|
||||
|
||||
if tc.expectErr && err == nil {
|
||||
t.Errorf("test %d: expected error but got: %v", i, err)
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
mod := new(LibreOffice)
|
||||
err := mod.Provision(tc.ctx)
|
||||
|
||||
if !tc.expectErr && err != nil {
|
||||
t.Errorf("test %d: expected no error but got: %v", i, err)
|
||||
}
|
||||
if tc.expectProvisionErr && err == nil {
|
||||
t.Error("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 TestLibreOffice_Routes(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
expectRoutes int
|
||||
disableRoutes bool
|
||||
tests := []struct {
|
||||
name string
|
||||
mod LibreOffice
|
||||
expectRoutesCount int
|
||||
}{
|
||||
{
|
||||
expectRoutes: 1,
|
||||
name: "route not disabled",
|
||||
mod: LibreOffice{},
|
||||
expectRoutesCount: 1,
|
||||
},
|
||||
{
|
||||
disableRoutes: true,
|
||||
name: "route disabled",
|
||||
mod: LibreOffice{
|
||||
disableRoutes: true,
|
||||
},
|
||||
},
|
||||
} {
|
||||
mod := new(LibreOffice)
|
||||
mod.disableRoutes = tc.disableRoutes
|
||||
}
|
||||
|
||||
routes, err := mod.Routes()
|
||||
if err != nil {
|
||||
t.Fatalf("test %d: expected no error but got: %v", i, err)
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
routes, err := tc.mod.Routes()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error from mod.Routes(), but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectRoutes != len(routes) {
|
||||
t.Errorf("test %d: expected %d routes but got %d", i, tc.expectRoutes, len(routes))
|
||||
}
|
||||
if tc.expectRoutesCount != len(routes) {
|
||||
t.Errorf("expected %d routes from mod.Routes(), but got %d", tc.expectRoutesCount, len(routes))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ gotenberg.Module = (*ProtoModule)(nil)
|
||||
_ unoconv.Provider = (*ProtoUnoconvProvider)(nil)
|
||||
_ gotenberg.Module = (*ProtoUnoconvProvider)(nil)
|
||||
_ unoconv.API = (*ProtoUnoconvAPI)(nil)
|
||||
_ gotenberg.PDFEngineProvider = (*ProtoPDFEngineProvider)(nil)
|
||||
_ gotenberg.Module = (*ProtoPDFEngineProvider)(nil)
|
||||
_ gotenberg.PDFEngine = (*ProtoPDFEngine)(nil)
|
||||
)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Package pdfengine provides a module which abstracts the CLI tool unoconv and
|
||||
// implements the gotenberg.PDFEngine interface.
|
||||
// Package pdfengine provides a module which interacts with the UNO
|
||||
// (Universal Network Objects) API and implements the gotenberg.PDFEngine
|
||||
// interface.
|
||||
package pdfengine
|
||||
|
||||
@@ -2,75 +2,76 @@ package pdfengine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/unoconv"
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/uno"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func init() {
|
||||
gotenberg.MustRegisterModule(UnoconvPDFEngine{})
|
||||
gotenberg.MustRegisterModule(UNO{})
|
||||
}
|
||||
|
||||
// UnoconvPDFEngine abstracts the CLI tool unoconv and implements the
|
||||
// gotenberg.PDFEngine interface.
|
||||
type UnoconvPDFEngine struct {
|
||||
unoconv unoconv.API
|
||||
// UNO interacts with the UNO (Universal Network Objects) API and implements
|
||||
// the gotenberg.PDFEngine interface.
|
||||
type UNO struct {
|
||||
unoAPI uno.API
|
||||
}
|
||||
|
||||
// Descriptor returns a UnoconvPDFEngine's module descriptor.
|
||||
func (UnoconvPDFEngine) Descriptor() gotenberg.ModuleDescriptor {
|
||||
// Descriptor returns a UNO's module descriptor.
|
||||
func (UNO) Descriptor() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{
|
||||
ID: "unoconv-pdfengine",
|
||||
New: func() gotenberg.Module { return new(UnoconvPDFEngine) },
|
||||
ID: "uno-pdfengine",
|
||||
New: func() gotenberg.Module { return new(UNO) },
|
||||
}
|
||||
}
|
||||
|
||||
// Provision sets the module properties.
|
||||
func (engine *UnoconvPDFEngine) Provision(ctx *gotenberg.Context) error {
|
||||
provider, err := ctx.Module(new(unoconv.Provider))
|
||||
func (engine *UNO) Provision(ctx *gotenberg.Context) error {
|
||||
provider, err := ctx.Module(new(uno.Provider))
|
||||
if err != nil {
|
||||
return fmt.Errorf("get unoconv provider: %w", err)
|
||||
}
|
||||
|
||||
uno, err := provider.(unoconv.Provider).Unoconv()
|
||||
unoAPI, err := provider.(uno.Provider).UNO()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get unoconv API: %w", err)
|
||||
}
|
||||
|
||||
engine.unoconv = uno
|
||||
engine.unoAPI = unoAPI
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Merge is not available for this PDF engine.
|
||||
func (engine UnoconvPDFEngine) Merge(_ context.Context, _ *zap.Logger, _ []string, _ string) error {
|
||||
func (engine UNO) Merge(_ context.Context, _ *zap.Logger, _ []string, _ string) error {
|
||||
return fmt.Errorf("merge PDFs with unoconv: %w", gotenberg.ErrPDFEngineMethodNotAvailable)
|
||||
}
|
||||
|
||||
// Convert converts the given PDF to a specific PDF format. Currently, only the
|
||||
// PDF/A-1 format is available. If another PDF format is requested, it returns
|
||||
// a gotenberg.ErrPDFFormatNotAvailable error.
|
||||
func (engine UnoconvPDFEngine) Convert(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error {
|
||||
if format != gotenberg.FormatPDFA1a {
|
||||
return fmt.Errorf("convert PDF to '%s' with unoconv: %w", format, gotenberg.ErrPDFFormatNotAvailable)
|
||||
}
|
||||
|
||||
err := engine.unoconv.PDF(ctx, logger, inputPath, outputPath, unoconv.Options{
|
||||
PDFArchive: true,
|
||||
// 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,
|
||||
})
|
||||
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if errors.Is(err, uno.ErrInvalidPDFformat) {
|
||||
return fmt.Errorf("convert PDF to '%s' with unoconv: %w", format, gotenberg.ErrPDFFormatNotAvailable)
|
||||
}
|
||||
|
||||
return fmt.Errorf("convert PDF to '%s' with unoconv: %w", format, err)
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ gotenberg.Module = (*UnoconvPDFEngine)(nil)
|
||||
_ gotenberg.Provisioner = (*UnoconvPDFEngine)(nil)
|
||||
_ gotenberg.PDFEngine = (*UnoconvPDFEngine)(nil)
|
||||
_ gotenberg.Module = (*UNO)(nil)
|
||||
_ gotenberg.Provisioner = (*UNO)(nil)
|
||||
_ gotenberg.PDFEngine = (*UNO)(nil)
|
||||
)
|
||||
|
||||
@@ -7,191 +7,168 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/unoconv"
|
||||
flag "github.com/spf13/pflag"
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/uno"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type ProtoModule struct {
|
||||
descriptor func() gotenberg.ModuleDescriptor
|
||||
}
|
||||
|
||||
func (mod ProtoModule) Descriptor() gotenberg.ModuleDescriptor {
|
||||
return mod.descriptor()
|
||||
}
|
||||
|
||||
type ProtoUnoconvProvider struct {
|
||||
ProtoModule
|
||||
unoconv func() (unoconv.API, error)
|
||||
}
|
||||
|
||||
func (mod ProtoUnoconvProvider) Unoconv() (unoconv.API, error) {
|
||||
return mod.unoconv()
|
||||
}
|
||||
|
||||
type ProtoUnoconvAPI struct {
|
||||
pdf func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options unoconv.Options) error
|
||||
}
|
||||
|
||||
func (mod ProtoUnoconvAPI) PDF(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options unoconv.Options) error {
|
||||
return mod.pdf(ctx, logger, inputPath, outputPath, options)
|
||||
}
|
||||
|
||||
func (mod ProtoUnoconvAPI) Extensions() []string {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestUnoconvPDFEngine_Descriptor(t *testing.T) {
|
||||
descriptor := UnoconvPDFEngine{}.Descriptor()
|
||||
func TestUNO_Descriptor(t *testing.T) {
|
||||
descriptor := UNO{}.Descriptor()
|
||||
|
||||
actual := reflect.TypeOf(descriptor.New())
|
||||
expect := reflect.TypeOf(new(UnoconvPDFEngine))
|
||||
expect := reflect.TypeOf(new(UNO))
|
||||
|
||||
if actual != expect {
|
||||
t.Errorf("expected '%s' but got '%s'", expect, actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnoconvPDFEngine_Provision(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
ctx *gotenberg.Context
|
||||
expectErr bool
|
||||
func TestUNO_Provider(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ctx *gotenberg.Context
|
||||
expectProvisionErr bool
|
||||
}{
|
||||
{
|
||||
name: "nominal behavior",
|
||||
ctx: func() *gotenberg.Context {
|
||||
mod := struct{ ProtoModule }{}
|
||||
mod.descriptor = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }}
|
||||
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: flag.NewFlagSet("foo", flag.ExitOnError),
|
||||
FlagSet: new(UNO).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{
|
||||
mod.Descriptor(),
|
||||
provider.Descriptor(),
|
||||
},
|
||||
)
|
||||
}(),
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
ctx: func() *gotenberg.Context {
|
||||
mod := struct{ ProtoUnoconvProvider }{}
|
||||
mod.descriptor = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }}
|
||||
}
|
||||
mod.unoconv = func() (unoconv.API, error) {
|
||||
return nil, errors.New("foo")
|
||||
}
|
||||
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: flag.NewFlagSet("foo", flag.ExitOnError),
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{
|
||||
mod.Descriptor(),
|
||||
},
|
||||
)
|
||||
}(),
|
||||
expectErr: true,
|
||||
name: "no UNO API provider",
|
||||
ctx: gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: new(UNO).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{},
|
||||
),
|
||||
expectProvisionErr: true,
|
||||
},
|
||||
{
|
||||
name: "no API from UNO API provider",
|
||||
ctx: func() *gotenberg.Context {
|
||||
mod := struct{ ProtoUnoconvProvider }{}
|
||||
mod.descriptor = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }}
|
||||
provider := struct {
|
||||
gotenberg.ModuleMock
|
||||
uno.ProviderMock
|
||||
}{}
|
||||
provider.DescriptorMock = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
|
||||
return provider
|
||||
}}
|
||||
}
|
||||
mod.unoconv = func() (unoconv.API, error) {
|
||||
return struct{ ProtoUnoconvAPI }{}, nil
|
||||
provider.UNOMock = func() (uno.API, error) {
|
||||
return uno.APIMock{}, errors.New("foo")
|
||||
}
|
||||
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: flag.NewFlagSet("foo", flag.ExitOnError),
|
||||
FlagSet: new(UNO).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{
|
||||
mod.Descriptor(),
|
||||
provider.Descriptor(),
|
||||
},
|
||||
)
|
||||
}(),
|
||||
expectProvisionErr: true,
|
||||
},
|
||||
} {
|
||||
mod := new(UnoconvPDFEngine)
|
||||
err := mod.Provision(tc.ctx)
|
||||
}
|
||||
|
||||
if tc.expectErr && err == nil {
|
||||
t.Errorf("test %d: expected error but got: %v", i, err)
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
mod := new(UNO)
|
||||
err := mod.Provision(tc.ctx)
|
||||
|
||||
if !tc.expectErr && err != nil {
|
||||
t.Errorf("test %d: expected no error but got: %v", i, err)
|
||||
}
|
||||
if tc.expectProvisionErr && err == nil {
|
||||
t.Error("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 TestUnoconvPDFEngine_Merge(t *testing.T) {
|
||||
mod := new(UnoconvPDFEngine)
|
||||
err := mod.Merge(context.TODO(), zap.NewNop(), nil, "")
|
||||
func TestUNO_Merge(t *testing.T) {
|
||||
mod := new(UNO)
|
||||
err := mod.Merge(context.Background(), zap.NewNop(), nil, "")
|
||||
|
||||
if !errors.Is(err, gotenberg.ErrPDFEngineMethodNotAvailable) {
|
||||
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPDFEngineMethodNotAvailable, err)
|
||||
t.Errorf("expected error %v from mod.Merge(), but got: %v", gotenberg.ErrPDFEngineMethodNotAvailable, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnoconvPDFEngine_Convert(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
api unoconv.API
|
||||
format string
|
||||
expectErr bool
|
||||
func TestUNO_Convert(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mod UNO
|
||||
expectConvertErr bool
|
||||
}{
|
||||
{
|
||||
format: "",
|
||||
expectErr: true,
|
||||
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
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
api: func() unoconv.API {
|
||||
unoconvAPI := struct{ ProtoUnoconvAPI }{}
|
||||
unoconvAPI.pdf = func(_ context.Context, _ *zap.Logger, _, __ string, _ unoconv.Options) error {
|
||||
return errors.New("foo")
|
||||
}
|
||||
|
||||
return unoconvAPI
|
||||
}(),
|
||||
format: gotenberg.FormatPDFA1a,
|
||||
expectErr: true,
|
||||
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
|
||||
},
|
||||
},
|
||||
},
|
||||
expectConvertErr: true,
|
||||
},
|
||||
{
|
||||
api: func() unoconv.API {
|
||||
unoconvAPI := struct{ ProtoUnoconvAPI }{}
|
||||
unoconvAPI.pdf = func(_ context.Context, _ *zap.Logger, _, __ string, _ unoconv.Options) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
return unoconvAPI
|
||||
}(),
|
||||
format: gotenberg.FormatPDFA1a,
|
||||
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")
|
||||
},
|
||||
},
|
||||
},
|
||||
expectConvertErr: true,
|
||||
},
|
||||
} {
|
||||
mod := new(UnoconvPDFEngine)
|
||||
mod.unoconv = tc.api
|
||||
}
|
||||
|
||||
err := mod.Convert(context.TODO(), zap.NewNop(), tc.format, "", "")
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := tc.mod.Convert(context.Background(), zap.NewNop(), "", "", "")
|
||||
|
||||
if tc.expectErr && err == nil {
|
||||
t.Errorf("test %d: expected error but got: %v", i, err)
|
||||
}
|
||||
if tc.expectConvertErr && err == nil {
|
||||
t.Errorf("expected mod.Convert() error, but got none")
|
||||
}
|
||||
|
||||
if !tc.expectErr && err != nil {
|
||||
t.Errorf("test %d: expected no error but got: %v", i, err)
|
||||
}
|
||||
if !tc.expectConvertErr && err != nil {
|
||||
t.Fatalf("expected no error from mod.Convert(), but got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ gotenberg.Module = (*ProtoModule)(nil)
|
||||
_ unoconv.Provider = (*ProtoUnoconvProvider)(nil)
|
||||
_ gotenberg.Module = (*ProtoUnoconvProvider)(nil)
|
||||
_ unoconv.API = (*ProtoUnoconvAPI)(nil)
|
||||
)
|
||||
|
||||
@@ -7,13 +7,13 @@ import (
|
||||
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/modules/api"
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/unoconv"
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/uno"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// convertRoute returns an api.Route which can convert LibreOffice documents
|
||||
// to PDF.
|
||||
func convertRoute(uno unoconv.API, engine gotenberg.PDFEngine) api.Route {
|
||||
func convertRoute(unoAPI uno.API, engine gotenberg.PDFEngine) api.Route {
|
||||
return api.Route{
|
||||
Method: http.MethodPost,
|
||||
Path: "/forms/libreoffice/convert",
|
||||
@@ -27,15 +27,17 @@ func convertRoute(uno unoconv.API, engine gotenberg.PDFEngine) api.Route {
|
||||
landscape bool
|
||||
nativePageRanges string
|
||||
nativePDFA1aFormat bool
|
||||
nativePDFformat string
|
||||
PDFformat string
|
||||
merge bool
|
||||
)
|
||||
|
||||
err := ctx.FormData().
|
||||
MandatoryPaths(uno.Extensions(), &inputPaths).
|
||||
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()
|
||||
@@ -44,13 +46,35 @@ func convertRoute(uno unoconv.API, engine gotenberg.PDFEngine) api.Route {
|
||||
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 values"),
|
||||
api.NewSentinelHTTPError(http.StatusBadRequest, "Both 'pdfFormat' and 'nativePdfA1aFormat' form values are provided"),
|
||||
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
|
||||
}
|
||||
|
||||
// Alright, let's convert each document to PDF.
|
||||
|
||||
outputPaths := make([]string, len(inputPaths))
|
||||
@@ -58,16 +82,16 @@ func convertRoute(uno unoconv.API, engine gotenberg.PDFEngine) api.Route {
|
||||
for i, inputPath := range inputPaths {
|
||||
outputPaths[i] = ctx.GeneratePath(".pdf")
|
||||
|
||||
options := unoconv.Options{
|
||||
options := uno.Options{
|
||||
Landscape: landscape,
|
||||
PageRanges: nativePageRanges,
|
||||
PDFArchive: nativePDFA1aFormat,
|
||||
PDFformat: nativePDFformat,
|
||||
}
|
||||
|
||||
err = uno.PDF(ctx, ctx.Log(), inputPath, outputPaths[i], options)
|
||||
err = unoAPI.PDF(ctx, ctx.Log(), inputPath, outputPaths[i], options)
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, unoconv.ErrMalformedPageRanges) {
|
||||
if errors.Is(err, uno.ErrMalformedPageRanges) {
|
||||
return api.WrapError(
|
||||
fmt.Errorf("convert to PDF: %w", err),
|
||||
api.NewSentinelHTTPError(http.StatusBadRequest, fmt.Sprintf("Malformed page ranges '%s' (nativePageRanges)", options.PageRanges)),
|
||||
@@ -92,10 +116,8 @@ func convertRoute(uno unoconv.API, engine gotenberg.PDFEngine) api.Route {
|
||||
// Now, let's check if the client want to convert this result
|
||||
// PDF to a specific PDF format.
|
||||
|
||||
// Note: nativePdfA1aFormat has not been specified if we reach
|
||||
// this part of the code. Indeed, the handler returns early on
|
||||
// an error if both nativePdfA1aFormat and pdfFormat are
|
||||
// present.
|
||||
// Note: nativePdfA1aFormat/nativePdfFormat have not been
|
||||
// specified if PDFformat is not empty.
|
||||
|
||||
if PDFformat != "" {
|
||||
convertInputPath := outputPath
|
||||
@@ -135,9 +157,8 @@ func convertRoute(uno unoconv.API, engine gotenberg.PDFEngine) api.Route {
|
||||
// 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 has not been specified if we reach this
|
||||
// part of the code. Indeed, the handler returns early on an error
|
||||
// if both nativePdfA1aFormat and pdfFormat are present.
|
||||
// Note: nativePdfA1aFormat/nativePdfFormat have not been
|
||||
// specified if PDFformat is not empty.
|
||||
|
||||
if PDFformat != "" {
|
||||
convertOutputPaths := make([]string, len(outputPaths))
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
3
pkg/modules/libreoffice/uno/doc.go
Normal file
3
pkg/modules/libreoffice/uno/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package uno provides a module which interacts with the UNO
|
||||
// (Universal Network Objects) API.
|
||||
package uno
|
||||
31
pkg/modules/libreoffice/uno/freeport.go
Normal file
31
pkg/modules/libreoffice/uno/freeport.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package uno
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func freePort(logger *zap.Logger) (int, error) {
|
||||
netListener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("listen on the local network address: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
err := netListener.Close()
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("close network listener: %s", err.Error()))
|
||||
}
|
||||
}()
|
||||
|
||||
addr := netListener.Addr().String()
|
||||
|
||||
_, portStr, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("get free port from host: %w", err)
|
||||
}
|
||||
|
||||
return strconv.Atoi(portStr)
|
||||
}
|
||||
291
pkg/modules/libreoffice/uno/listener.go
Normal file
291
pkg/modules/libreoffice/uno/listener.go
Normal file
@@ -0,0 +1,291 @@
|
||||
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
|
||||
}
|
||||
|
||||
type libreOfficeListener struct {
|
||||
binPath string
|
||||
startTimeout time.Duration
|
||||
threshold int
|
||||
|
||||
socketPort int
|
||||
userProfileDirPath string
|
||||
cmd gotenberg.Cmd
|
||||
cfgMu sync.RWMutex
|
||||
|
||||
usage int
|
||||
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 {
|
||||
port, err := freePort(logger)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get free port: %w", err)
|
||||
}
|
||||
|
||||
// Good to know: when the supervisor manages the LibreOffice listener,
|
||||
// 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)
|
||||
}
|
||||
|
||||
// As the LibreOffice socket may take some time to be available, we have to
|
||||
// ensure that it is indeed accepting connections.
|
||||
logger.Debug("waiting for the LibreOffice listener socket to be available...")
|
||||
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return fmt.Errorf("waiting for the LibreOffice listener socket to be available: %w", ctx.Err())
|
||||
}
|
||||
|
||||
conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), time.Duration(1)*time.Second)
|
||||
if err == nil {
|
||||
err = conn.Close()
|
||||
if err != nil {
|
||||
logger.Debug(fmt.Sprintf("close connection after health checking the LibreOffice listener: %v", err))
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
logger.Debug("LibreOffice listener socket available")
|
||||
|
||||
listener.cfgMu.Lock()
|
||||
listener.socketPort = port
|
||||
listener.userProfileDirPath = userProfileDirPath
|
||||
listener.cmd = cmd
|
||||
listener.cfgMu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// Let's wait to make sure the process is no more.
|
||||
err = listener.cmd.Wait()
|
||||
if err != nil {
|
||||
logger.Debug(fmt.Sprintf("wait for the LibreOffice listener: %v", 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()
|
||||
|
||||
select {
|
||||
case listener.lockChan <- struct{}{}:
|
||||
logger.Debug("LibreOffice listener lock acquired")
|
||||
|
||||
listener.queueLengthMu.Lock()
|
||||
listener.queueLength -= 1
|
||||
listener.queueLengthMu.Unlock()
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
logger.Debug("failed to acquire LibreOffice listener lock before deadline")
|
||||
|
||||
listener.queueLengthMu.Lock()
|
||||
listener.queueLength -= 1
|
||||
listener.queueLengthMu.Unlock()
|
||||
|
||||
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.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)
|
||||
)
|
||||
416
pkg/modules/libreoffice/uno/listener_test.go
Normal file
416
pkg/modules/libreoffice/uno/listener_test.go
Normal file
@@ -0,0 +1,416 @@
|
||||
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: "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(),
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
if listener.healthy() {
|
||||
t.Errorf("expected a non-healthy LibreOffice listener")
|
||||
}
|
||||
|
||||
listener.restarting = true
|
||||
|
||||
if !listener.healthy() {
|
||||
t.Error("expected an healthy LibreOffice listener")
|
||||
}
|
||||
}
|
||||
36
pkg/modules/libreoffice/uno/mocks.go
Normal file
36
pkg/modules/libreoffice/uno/mocks.go
Normal file
@@ -0,0 +1,36 @@
|
||||
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)
|
||||
)
|
||||
42
pkg/modules/libreoffice/uno/mocks_test.go
Normal file
42
pkg/modules/libreoffice/uno/mocks_test.go
Normal file
@@ -0,0 +1,42 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
520
pkg/modules/libreoffice/uno/uno.go
Normal file
520
pkg/modules/libreoffice/uno/uno.go
Normal file
@@ -0,0 +1,520 @@
|
||||
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 starting 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 starts the long-running LibreOffice listener if the threshold is
|
||||
// superior to zero.
|
||||
func (mod UNO) Start() error {
|
||||
if mod.libreOfficeRestartThreshold == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := mod.listener.start(mod.logger)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("start long-running LibreOffice listener: %w", err)
|
||||
}
|
||||
|
||||
// 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 started"
|
||||
}
|
||||
|
||||
// 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 supervisor")
|
||||
}
|
||||
|
||||
// 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",
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
)
|
||||
870
pkg/modules/libreoffice/uno/uno_test.go
Normal file
870
pkg/modules/libreoffice/uno/uno_test.go
Normal file
@@ -0,0 +1,870 @@
|
||||
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) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mod UNO
|
||||
expectStartErr bool
|
||||
}{
|
||||
{
|
||||
name: "nominal behavior",
|
||||
mod: UNO{
|
||||
libreOfficeRestartThreshold: 10,
|
||||
listener: listenerMock{
|
||||
startMock: func(logger *zap.Logger) error {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no long-running LibreOffice listener",
|
||||
mod: UNO{
|
||||
libreOfficeRestartThreshold: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "start error",
|
||||
mod: UNO{
|
||||
libreOfficeRestartThreshold: 10,
|
||||
listener: listenerMock{
|
||||
startMock: func(logger *zap.Logger) error {
|
||||
return errors.New("foo")
|
||||
},
|
||||
},
|
||||
},
|
||||
expectStartErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := tc.mod.Start()
|
||||
|
||||
if tc.expectStartErr && err == nil {
|
||||
t.Errorf("expected mod.Start() error, but got none")
|
||||
}
|
||||
|
||||
if !tc.expectStartErr && err != nil {
|
||||
t.Errorf("expected no error from mod.Start(), but got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUNO_StartupMessage(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mod UNO
|
||||
expectMessage string
|
||||
}{
|
||||
{
|
||||
name: "long-running LibreOffice listener started",
|
||||
mod: UNO{
|
||||
libreOfficeRestartThreshold: 10,
|
||||
},
|
||||
expectMessage: "long-running LibreOffice listener started",
|
||||
},
|
||||
{
|
||||
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,
|
||||
)
|
||||
|
||||
err := mod.Start()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error from mod.Start(), but got: %v", err)
|
||||
}
|
||||
|
||||
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 := 76
|
||||
|
||||
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,2 +0,0 @@
|
||||
// Package unoconv provides a module which abstracts the CLI tool unoconv.
|
||||
package unoconv
|
||||
@@ -1,321 +0,0 @@
|
||||
package unoconv
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func init() {
|
||||
gotenberg.MustRegisterModule(Unoconv{})
|
||||
}
|
||||
|
||||
// ErrMalformedPageRanges happens if the page ranges option cannot be
|
||||
// interpreted by LibreOffice.
|
||||
var ErrMalformedPageRanges = errors.New("page ranges are malformed")
|
||||
|
||||
// Unoconv is a module which provides an API to interact with unoconv.
|
||||
type Unoconv struct {
|
||||
binPath string
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
// PDFArchive allows to convert the resulting PDF to PDF/A-1a.
|
||||
// In a module, prefer the Convert method from the gotenberg.PDFEngine
|
||||
// interface.
|
||||
// Optional.
|
||||
PDFArchive bool
|
||||
}
|
||||
|
||||
// API is an abstraction on top of unoconv.
|
||||
//
|
||||
// See https://github.com/unoconv/unoconv.
|
||||
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(unoconv.Provider))
|
||||
// uno, _ := provider.(unoconv.Provider).Unoconv()
|
||||
// }
|
||||
type Provider interface {
|
||||
Unoconv() (API, error)
|
||||
}
|
||||
|
||||
// Descriptor returns a Unoconv's module descriptor.
|
||||
func (Unoconv) Descriptor() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{
|
||||
ID: "unoconv",
|
||||
New: func() gotenberg.Module { return new(Unoconv) },
|
||||
}
|
||||
}
|
||||
|
||||
// Provision sets the module properties. It returns an error if the environment
|
||||
// variable UNOCONV_BIN_PATH is not set.
|
||||
func (mod *Unoconv) Provision(_ *gotenberg.Context) error {
|
||||
binPath, ok := os.LookupEnv("UNOCONV_BIN_PATH")
|
||||
if !ok {
|
||||
return errors.New("UNOCONV_BIN_PATH environment variable is not set")
|
||||
}
|
||||
|
||||
mod.binPath = binPath
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate validates the module properties.
|
||||
func (mod Unoconv) Validate() error {
|
||||
_, err := os.Stat(mod.binPath)
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("unoconv binary path does not exist: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Metrics returns the metrics.
|
||||
func (mod Unoconv) Metrics() ([]gotenberg.Metric, error) {
|
||||
return []gotenberg.Metric{
|
||||
{
|
||||
Name: "unoconv_active_instances_count",
|
||||
Description: "Current number of active LibreOffice instances.",
|
||||
Read: func() float64 {
|
||||
activeInstancesCountMu.RLock()
|
||||
defer activeInstancesCountMu.RUnlock()
|
||||
|
||||
return activeInstancesCount
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Unoconv returns an API for interacting with unoconv.
|
||||
func (mod Unoconv) Unoconv() (API, error) {
|
||||
return mod, nil
|
||||
}
|
||||
|
||||
// PDF converts a document to PDF. It creates a dedicated LibreOffice instance
|
||||
// thanks to a custom user profile directory and a free port. Substantial calls
|
||||
// to this method may increase CPU and memory usage drastically. In such a
|
||||
// scenario, the given context may also be done before the end of the
|
||||
// conversion.
|
||||
func (mod Unoconv) PDF(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error {
|
||||
port, err := func() (int, error) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("listen on the local network address: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
err := listener.Close()
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("close listener: %s", err.Error()))
|
||||
}
|
||||
}()
|
||||
|
||||
addr := listener.Addr().String()
|
||||
|
||||
_, portStr, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("get free port from host: %w", err)
|
||||
}
|
||||
|
||||
return strconv.Atoi(portStr)
|
||||
}()
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("get free port: %w", err)
|
||||
}
|
||||
|
||||
userProfileDirPath := gotenberg.NewDirPath()
|
||||
|
||||
args := []string{
|
||||
"--user-profile",
|
||||
fmt.Sprintf("//%s", userProfileDirPath),
|
||||
"--port",
|
||||
fmt.Sprintf("%d", port),
|
||||
"--format",
|
||||
"pdf",
|
||||
}
|
||||
|
||||
if options.Landscape {
|
||||
args = append(args, "--printer", "PaperOrientation=landscape")
|
||||
}
|
||||
|
||||
if options.PageRanges != "" {
|
||||
args = append(args, "--export", fmt.Sprintf("PageRange=%s", options.PageRanges))
|
||||
}
|
||||
|
||||
if options.PDFArchive {
|
||||
args = append(args, "--export", "SelectPdfVersion=1")
|
||||
}
|
||||
|
||||
args = append(args, "--output", outputPath, inputPath)
|
||||
|
||||
cmd, err := gotenberg.CommandContext(ctx, logger, mod.binPath, 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()
|
||||
|
||||
err = cmd.Exec()
|
||||
|
||||
activeInstancesCountMu.Lock()
|
||||
activeInstancesCount -= 1
|
||||
activeInstancesCountMu.Unlock()
|
||||
|
||||
// Always remove the user profile directory created by LibreOffice.
|
||||
// See https://github.com/gotenberg/gotenberg/issues/192.
|
||||
go func() {
|
||||
logger.Debug(fmt.Sprintf("remove user profile directory '%s'", userProfileDirPath))
|
||||
|
||||
err := os.RemoveAll(userProfileDirPath)
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("remove user profile directory: %s", err))
|
||||
}
|
||||
}()
|
||||
|
||||
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 strings.Contains(err.Error(), "exit status 5") && options.PageRanges != "" {
|
||||
return ErrMalformedPageRanges
|
||||
}
|
||||
|
||||
// Possible errors:
|
||||
// 1. Unoconv/LibreOffice failed for some reason.
|
||||
// 2. Context done.
|
||||
//
|
||||
// On the second scenario, LibreOffice might not had 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 with unoconv.
|
||||
func (mod Unoconv) 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",
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
activeInstancesCount float64
|
||||
activeInstancesCountMu sync.RWMutex
|
||||
)
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ gotenberg.Module = (*Unoconv)(nil)
|
||||
_ gotenberg.Provisioner = (*Unoconv)(nil)
|
||||
_ gotenberg.Validator = (*Unoconv)(nil)
|
||||
_ gotenberg.MetricsProvider = (*Unoconv)(nil)
|
||||
_ API = (*Unoconv)(nil)
|
||||
_ Provider = (*Unoconv)(nil)
|
||||
)
|
||||
@@ -3,213 +3,178 @@ package pdfengines
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestNewMultiPDFEngines(t *testing.T) {
|
||||
engine1 := &ProtoPDFEngine{
|
||||
merge: func(_ context.Context, _ *zap.Logger, _ []string, _ string) error {
|
||||
return nil
|
||||
},
|
||||
convert: func(_ context.Context, _ *zap.Logger, _, _, _ string) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
engine2 := &ProtoPDFEngine{
|
||||
merge: func(_ context.Context, _ *zap.Logger, _ []string, _ string) error {
|
||||
return errors.New("foo")
|
||||
},
|
||||
convert: func(_ context.Context, _ *zap.Logger, _, _, _ string) error {
|
||||
return errors.New("foo")
|
||||
},
|
||||
}
|
||||
|
||||
multi := newMultiPDFEngines(engine1, engine2)
|
||||
|
||||
if len(multi.engines) != 2 {
|
||||
t.Fatalf("expected %d engines but got %d", 2, len(multi.engines))
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(engine1, multi.engines[0]) {
|
||||
t.Errorf("expected %v, but got: %v", engine1, multi.engines[0])
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(engine2, multi.engines[1]) {
|
||||
t.Errorf("expected %v, but got: %v", engine2, multi.engines[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultiPDFEngines_Merge(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
ctx context.Context
|
||||
engines []gotenberg.PDFEngine
|
||||
expectErr bool
|
||||
tests := []struct {
|
||||
name string
|
||||
engine *multiPDFEngines
|
||||
ctx context.Context
|
||||
expectMergeErr bool
|
||||
}{
|
||||
{
|
||||
ctx: context.TODO(),
|
||||
engines: func() []gotenberg.PDFEngine {
|
||||
return []gotenberg.PDFEngine{
|
||||
ProtoPDFEngine{
|
||||
merge: func(_ context.Context, _ *zap.Logger, _ []string, _ string) error {
|
||||
return nil
|
||||
},
|
||||
name: "nominal behavior",
|
||||
engine: newMultiPDFEngines(
|
||||
gotenberg.PDFEngineMock{
|
||||
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}(),
|
||||
},
|
||||
),
|
||||
ctx: context.Background(),
|
||||
},
|
||||
{
|
||||
ctx: context.TODO(),
|
||||
engines: func() []gotenberg.PDFEngine {
|
||||
return []gotenberg.PDFEngine{
|
||||
ProtoPDFEngine{
|
||||
merge: func(_ context.Context, _ *zap.Logger, _ []string, _ string) error {
|
||||
return errors.New("foo")
|
||||
},
|
||||
name: "at least one engine does not return an error",
|
||||
engine: newMultiPDFEngines(
|
||||
gotenberg.PDFEngineMock{
|
||||
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
||||
return errors.New("foo")
|
||||
},
|
||||
ProtoPDFEngine{
|
||||
merge: func(_ context.Context, _ *zap.Logger, _ []string, _ string) error {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
gotenberg.PDFEngineMock{
|
||||
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}(),
|
||||
},
|
||||
),
|
||||
ctx: context.Background(),
|
||||
},
|
||||
{
|
||||
ctx: context.TODO(),
|
||||
engines: func() []gotenberg.PDFEngine {
|
||||
return []gotenberg.PDFEngine{
|
||||
ProtoPDFEngine{
|
||||
merge: func(_ context.Context, _ *zap.Logger, _ []string, _ string) error {
|
||||
return errors.New("foo")
|
||||
},
|
||||
name: "all engines return an error",
|
||||
engine: newMultiPDFEngines(
|
||||
gotenberg.PDFEngineMock{
|
||||
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
||||
return errors.New("foo")
|
||||
},
|
||||
ProtoPDFEngine{
|
||||
merge: func(_ context.Context, _ *zap.Logger, _ []string, _ string) error {
|
||||
return errors.New("bar")
|
||||
},
|
||||
},
|
||||
gotenberg.PDFEngineMock{
|
||||
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
||||
return errors.New("foo")
|
||||
},
|
||||
}
|
||||
}(),
|
||||
expectErr: true,
|
||||
},
|
||||
),
|
||||
ctx: context.Background(),
|
||||
expectMergeErr: true,
|
||||
},
|
||||
{
|
||||
name: "context expired",
|
||||
engine: newMultiPDFEngines(
|
||||
gotenberg.PDFEngineMock{
|
||||
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
),
|
||||
ctx: func() context.Context {
|
||||
ctx, cancel := context.WithCancel(context.TODO())
|
||||
defer cancel()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
return ctx
|
||||
}(),
|
||||
engines: func() []gotenberg.PDFEngine {
|
||||
return []gotenberg.PDFEngine{
|
||||
ProtoPDFEngine{
|
||||
merge: func(_ context.Context, _ *zap.Logger, _ []string, _ string) error {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
}
|
||||
}(),
|
||||
expectErr: true,
|
||||
expectMergeErr: true,
|
||||
},
|
||||
} {
|
||||
multi := newMultiPDFEngines(tc.engines...)
|
||||
err := multi.Merge(tc.ctx, nil, nil, "")
|
||||
}
|
||||
|
||||
if tc.expectErr && err == nil {
|
||||
t.Errorf("test %d: expected error but got: %v", i, err)
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := tc.engine.Merge(tc.ctx, zap.NewNop(), nil, "")
|
||||
|
||||
if !tc.expectErr && err != nil {
|
||||
t.Errorf("test %d: expected no error but got: %v", i, err)
|
||||
}
|
||||
if tc.expectMergeErr && err == nil {
|
||||
t.Errorf("expected engine.Merge() error, but got none")
|
||||
}
|
||||
|
||||
if !tc.expectMergeErr && err != nil {
|
||||
t.Errorf("expected no error from engine.Merge(), but got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultiPDFEngines_Convert(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
ctx context.Context
|
||||
engines []gotenberg.PDFEngine
|
||||
expectErr bool
|
||||
tests := []struct {
|
||||
name string
|
||||
engine *multiPDFEngines
|
||||
ctx context.Context
|
||||
expectConvertErr bool
|
||||
}{
|
||||
{
|
||||
ctx: context.TODO(),
|
||||
engines: func() []gotenberg.PDFEngine {
|
||||
return []gotenberg.PDFEngine{
|
||||
ProtoPDFEngine{
|
||||
convert: func(_ context.Context, _ *zap.Logger, _, _, _ string) error {
|
||||
return nil
|
||||
},
|
||||
name: "nominal behavior",
|
||||
engine: newMultiPDFEngines(
|
||||
gotenberg.PDFEngineMock{
|
||||
ConvertMock: func(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}(),
|
||||
},
|
||||
),
|
||||
ctx: context.Background(),
|
||||
},
|
||||
{
|
||||
ctx: context.TODO(),
|
||||
engines: func() []gotenberg.PDFEngine {
|
||||
return []gotenberg.PDFEngine{
|
||||
ProtoPDFEngine{
|
||||
convert: func(_ context.Context, _ *zap.Logger, _, _, _ string) error {
|
||||
return errors.New("foo")
|
||||
},
|
||||
name: "at least one engine does not return an error",
|
||||
engine: newMultiPDFEngines(
|
||||
gotenberg.PDFEngineMock{
|
||||
ConvertMock: func(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error {
|
||||
return errors.New("foo")
|
||||
},
|
||||
ProtoPDFEngine{
|
||||
convert: func(_ context.Context, _ *zap.Logger, _, _, _ string) error {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
gotenberg.PDFEngineMock{
|
||||
ConvertMock: func(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}(),
|
||||
},
|
||||
),
|
||||
ctx: context.Background(),
|
||||
},
|
||||
{
|
||||
ctx: context.TODO(),
|
||||
engines: func() []gotenberg.PDFEngine {
|
||||
return []gotenberg.PDFEngine{
|
||||
ProtoPDFEngine{
|
||||
convert: func(_ context.Context, _ *zap.Logger, _, _, _ string) error {
|
||||
return errors.New("foo")
|
||||
},
|
||||
name: "all engines return an error",
|
||||
engine: newMultiPDFEngines(
|
||||
gotenberg.PDFEngineMock{
|
||||
ConvertMock: func(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error {
|
||||
return errors.New("foo")
|
||||
},
|
||||
ProtoPDFEngine{
|
||||
convert: func(_ context.Context, _ *zap.Logger, _, _, _ string) error {
|
||||
return errors.New("bar")
|
||||
},
|
||||
},
|
||||
gotenberg.PDFEngineMock{
|
||||
ConvertMock: func(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error {
|
||||
return errors.New("foo")
|
||||
},
|
||||
}
|
||||
}(),
|
||||
expectErr: true,
|
||||
},
|
||||
),
|
||||
ctx: context.Background(),
|
||||
expectConvertErr: true,
|
||||
},
|
||||
{
|
||||
name: "context expired",
|
||||
engine: newMultiPDFEngines(
|
||||
gotenberg.PDFEngineMock{
|
||||
ConvertMock: func(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
),
|
||||
ctx: func() context.Context {
|
||||
ctx, cancel := context.WithCancel(context.TODO())
|
||||
defer cancel()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
return ctx
|
||||
}(),
|
||||
engines: func() []gotenberg.PDFEngine {
|
||||
return []gotenberg.PDFEngine{
|
||||
ProtoPDFEngine{
|
||||
convert: func(_ context.Context, _ *zap.Logger, _, _, _ string) error {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
}
|
||||
}(),
|
||||
expectErr: true,
|
||||
expectConvertErr: true,
|
||||
},
|
||||
} {
|
||||
multi := newMultiPDFEngines(tc.engines...)
|
||||
err := multi.Convert(tc.ctx, nil, "", "", "")
|
||||
}
|
||||
|
||||
if tc.expectErr && err == nil {
|
||||
t.Errorf("test %d: expected error but got: %v", i, err)
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := tc.engine.Convert(tc.ctx, zap.NewNop(), "", "", "")
|
||||
|
||||
if !tc.expectErr && err != nil {
|
||||
t.Errorf("test %d: expected no error but got: %v", i, err)
|
||||
}
|
||||
if tc.expectConvertErr && err == nil {
|
||||
t.Errorf("expected engine.Convert() error, but got none")
|
||||
}
|
||||
|
||||
if !tc.expectConvertErr && err != nil {
|
||||
t.Errorf("expected no error from engine.Convert(), but got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package pdfengines
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/modules/api"
|
||||
@@ -36,7 +37,7 @@ func (PDFEngines) Descriptor() gotenberg.ModuleDescriptor {
|
||||
ID: "pdfengines",
|
||||
FlagSet: func() *flag.FlagSet {
|
||||
fs := flag.NewFlagSet("pdfengines", flag.ExitOnError)
|
||||
fs.StringSlice("pdfengines-engines", make([]string, 0), "Set the PDF engines - all by default")
|
||||
fs.StringSlice("pdfengines-engines", make([]string, 0), "Set the PDF engines and their order - all by default")
|
||||
fs.Bool("pdfengines-disable-routes", false, "Disable the routes")
|
||||
|
||||
return fs
|
||||
@@ -52,6 +53,18 @@ func (mod *PDFEngines) Provision(ctx *gotenberg.Context) error {
|
||||
names := flags.MustStringSlice("pdfengines-engines")
|
||||
mod.disableRoutes = flags.MustBool("pdfengines-disable-routes")
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
logger = logger.Named("pdfengines")
|
||||
|
||||
engines, err := ctx.Modules(new(gotenberg.PDFEngine))
|
||||
if err != nil {
|
||||
return fmt.Errorf("get PDF engines: %w", err)
|
||||
@@ -67,6 +80,13 @@ func (mod *PDFEngines) Provision(ctx *gotenberg.Context) error {
|
||||
// Selection from user.
|
||||
mod.names = names
|
||||
|
||||
for i, name := range names {
|
||||
logger.Warn("unoconv-pdfengine is deprecated; prefer uno-pdfengine instead")
|
||||
if name == "unoconv-pdfengine" {
|
||||
mod.names[i] = "uno-pdfengine"
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -118,14 +138,25 @@ func (mod PDFEngines) Validate() error {
|
||||
return fmt.Errorf("non-existing PDF engine(s): %s - available PDF engine(s): %s", nonExistingEngines, availableEngines)
|
||||
}
|
||||
|
||||
// SystemMessages returns one message with the selected gotenberg.PDFEngine
|
||||
// modules.
|
||||
func (mod PDFEngines) SystemMessages() []string {
|
||||
return []string{
|
||||
strings.Join(mod.names[:], " "),
|
||||
}
|
||||
}
|
||||
|
||||
// PDFEngine returns a gotenberg.PDFEngine.
|
||||
func (mod PDFEngines) PDFEngine() (gotenberg.PDFEngine, error) {
|
||||
engines := make([]gotenberg.PDFEngine, len(mod.engines))
|
||||
engines := make([]gotenberg.PDFEngine, len(mod.names))
|
||||
|
||||
i := 0
|
||||
for _, engine := range mod.engines {
|
||||
engines[i] = engine
|
||||
i++
|
||||
for i, name := range mod.names {
|
||||
for _, engine := range mod.engines {
|
||||
if name == engine.(gotenberg.Module).Descriptor().ID {
|
||||
engines[i] = engine
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return newMultiPDFEngines(engines...), nil
|
||||
@@ -155,6 +186,7 @@ var (
|
||||
_ gotenberg.Module = (*PDFEngines)(nil)
|
||||
_ gotenberg.Provisioner = (*PDFEngines)(nil)
|
||||
_ gotenberg.Validator = (*PDFEngines)(nil)
|
||||
_ gotenberg.SystemLogger = (*PDFEngines)(nil)
|
||||
_ gotenberg.PDFEngineProvider = (*PDFEngines)(nil)
|
||||
_ api.Router = (*PDFEngines)(nil)
|
||||
)
|
||||
|
||||
@@ -1,47 +1,16 @@
|
||||
package pdfengines
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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 ProtoPDFEngine struct {
|
||||
ProtoValidator
|
||||
merge func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error
|
||||
convert func(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error
|
||||
}
|
||||
|
||||
func (mod ProtoPDFEngine) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
||||
return mod.merge(ctx, logger, inputPaths, outputPath)
|
||||
}
|
||||
|
||||
func (mod ProtoPDFEngine) Convert(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error {
|
||||
return mod.convert(ctx, logger, format, inputPath, outputPath)
|
||||
}
|
||||
|
||||
func TestPDFEngine_Descriptor(t *testing.T) {
|
||||
func TestPDFEngines_Descriptor(t *testing.T) {
|
||||
descriptor := PDFEngines{}.Descriptor()
|
||||
|
||||
actual := reflect.TypeOf(descriptor.New())
|
||||
@@ -52,71 +21,98 @@ func TestPDFEngine_Descriptor(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPDFEngine_Provision(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
ctx *gotenberg.Context
|
||||
expectNames []string
|
||||
expectEnginesCount int
|
||||
expectErr bool
|
||||
func TestPDFEngines_Provision(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ctx *gotenberg.Context
|
||||
expectPDFEngineNames []string
|
||||
expectProvisionErr bool
|
||||
}{
|
||||
{
|
||||
name: "no selection from user",
|
||||
ctx: func() *gotenberg.Context {
|
||||
engine := struct{ ProtoPDFEngine }{}
|
||||
engine.descriptor = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return engine }}
|
||||
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
|
||||
}
|
||||
|
||||
engine := struct {
|
||||
gotenberg.ModuleMock
|
||||
gotenberg.ValidatorMock
|
||||
gotenberg.PDFEngineMock
|
||||
}{}
|
||||
engine.DescriptorMock = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return engine }}
|
||||
}
|
||||
engine.ValidateMock = func() error {
|
||||
return nil
|
||||
}
|
||||
engine.validate = func() error { return errors.New("foo") }
|
||||
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: new(PDFEngines).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{
|
||||
provider.Descriptor(),
|
||||
engine.Descriptor(),
|
||||
},
|
||||
)
|
||||
}(),
|
||||
expectErr: true,
|
||||
expectPDFEngineNames: []string{"bar"},
|
||||
},
|
||||
{
|
||||
name: "selection from user",
|
||||
ctx: func() *gotenberg.Context {
|
||||
engine := struct{ ProtoPDFEngine }{}
|
||||
engine.descriptor = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return engine }}
|
||||
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
|
||||
}
|
||||
engine.validate = func() error { return nil }
|
||||
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: new(PDFEngines).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{
|
||||
engine.Descriptor(),
|
||||
},
|
||||
)
|
||||
}(),
|
||||
expectNames: []string{"foo"},
|
||||
expectEnginesCount: 1,
|
||||
},
|
||||
{
|
||||
ctx: func() *gotenberg.Context {
|
||||
engine1 := struct{ ProtoPDFEngine }{}
|
||||
engine1.descriptor = func() gotenberg.ModuleDescriptor {
|
||||
engine1 := struct {
|
||||
gotenberg.ModuleMock
|
||||
gotenberg.ValidatorMock
|
||||
gotenberg.PDFEngineMock
|
||||
}{}
|
||||
engine1.DescriptorMock = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "a", New: func() gotenberg.Module { return engine1 }}
|
||||
}
|
||||
engine1.validate = func() error { return nil }
|
||||
engine1.ValidateMock = func() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
engine2 := struct{ ProtoPDFEngine }{}
|
||||
engine2.descriptor = func() gotenberg.ModuleDescriptor {
|
||||
engine2 := struct {
|
||||
gotenberg.ModuleMock
|
||||
gotenberg.ValidatorMock
|
||||
gotenberg.PDFEngineMock
|
||||
}{}
|
||||
engine2.DescriptorMock = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "b", New: func() gotenberg.Module { return engine2 }}
|
||||
}
|
||||
engine2.validate = func() error { return nil }
|
||||
engine2.ValidateMock = func() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
fs := new(PDFEngines).Descriptor().FlagSet
|
||||
err := fs.Parse([]string{"--pdfengines-engines=b", "--pdfengines-engines=a"})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
t.Fatalf("expected no error from fs.Parse(), but got: %v", err)
|
||||
}
|
||||
|
||||
return gotenberg.NewContext(
|
||||
@@ -124,33 +120,47 @@ func TestPDFEngine_Provision(t *testing.T) {
|
||||
FlagSet: fs,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{
|
||||
provider.Descriptor(),
|
||||
engine1.Descriptor(),
|
||||
engine2.Descriptor(),
|
||||
},
|
||||
)
|
||||
}(),
|
||||
expectNames: []string{"b", "a"},
|
||||
expectEnginesCount: 2,
|
||||
expectPDFEngineNames: []string{"b", "a"},
|
||||
},
|
||||
{
|
||||
name: "user select deprecated unoconv-pdfengine",
|
||||
ctx: func() *gotenberg.Context {
|
||||
engine1 := struct{ ProtoPDFEngine }{}
|
||||
engine1.descriptor = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "a", New: func() gotenberg.Module { return engine1 }}
|
||||
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
|
||||
}
|
||||
engine1.validate = func() error { return nil }
|
||||
|
||||
engine2 := struct{ ProtoPDFEngine }{}
|
||||
engine2.descriptor = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "b", New: func() gotenberg.Module { return engine2 }}
|
||||
engine := struct {
|
||||
gotenberg.ModuleMock
|
||||
gotenberg.ValidatorMock
|
||||
gotenberg.PDFEngineMock
|
||||
}{}
|
||||
engine.DescriptorMock = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "uno-pdfengine", New: func() gotenberg.Module { return engine }}
|
||||
}
|
||||
engine.ValidateMock = func() error {
|
||||
return nil
|
||||
}
|
||||
engine2.validate = func() error { return nil }
|
||||
|
||||
fs := new(PDFEngines).Descriptor().FlagSet
|
||||
err := fs.Parse([]string{"--pdfengines-engines=b"})
|
||||
err := fs.Parse([]string{"--pdfengines-engines=unoconv-pdfengine"})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("expected error but got: %v", err)
|
||||
t.Fatalf("expected no error from fs.Parse(), but got: %v", err)
|
||||
}
|
||||
|
||||
return gotenberg.NewContext(
|
||||
@@ -158,56 +168,136 @@ func TestPDFEngine_Provision(t *testing.T) {
|
||||
FlagSet: fs,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{
|
||||
engine1.Descriptor(),
|
||||
engine2.Descriptor(),
|
||||
provider.Descriptor(),
|
||||
engine.Descriptor(),
|
||||
},
|
||||
)
|
||||
}(),
|
||||
expectNames: []string{"b"},
|
||||
expectEnginesCount: 2,
|
||||
expectPDFEngineNames: []string{"uno-pdfengine"},
|
||||
},
|
||||
} {
|
||||
mod := new(PDFEngines)
|
||||
err := mod.Provision(tc.ctx)
|
||||
{
|
||||
name: "no logger provider",
|
||||
ctx: func() *gotenberg.Context {
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: new(PDFEngines).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")
|
||||
}
|
||||
|
||||
if tc.expectErr && err == nil {
|
||||
t.Errorf("test %d: expected error but got: %v", i, err)
|
||||
}
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: new(PDFEngines).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{
|
||||
provider.Descriptor(),
|
||||
},
|
||||
)
|
||||
}(),
|
||||
expectProvisionErr: true,
|
||||
},
|
||||
{
|
||||
name: "no valid PDF engines",
|
||||
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
|
||||
}
|
||||
|
||||
if !tc.expectErr && err != nil {
|
||||
t.Errorf("test %d: expected no error but got: %v", i, err)
|
||||
}
|
||||
engine := struct {
|
||||
gotenberg.ModuleMock
|
||||
gotenberg.ValidatorMock
|
||||
gotenberg.PDFEngineMock
|
||||
}{}
|
||||
engine.DescriptorMock = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return engine }}
|
||||
}
|
||||
engine.ValidateMock = func() error {
|
||||
return errors.New("foo")
|
||||
}
|
||||
|
||||
if len(tc.expectNames) != len(mod.names) {
|
||||
t.Errorf("test %d: expected %d names but got %d", i, len(tc.expectNames), len(mod.names))
|
||||
}
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: new(PDFEngines).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{
|
||||
provider.Descriptor(),
|
||||
engine.Descriptor(),
|
||||
},
|
||||
)
|
||||
}(),
|
||||
expectProvisionErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
if tc.expectEnginesCount != len(mod.engines) {
|
||||
t.Errorf("test %d: expected %d engines but got %d", i, tc.expectEnginesCount, len(mod.engines))
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
mod := new(PDFEngines)
|
||||
err := mod.Provision(tc.ctx)
|
||||
|
||||
for index, name := range mod.names {
|
||||
if name != tc.expectNames[index] {
|
||||
t.Errorf("test %d: expected name at index %d to be %s, but got: %s", i, index, name, tc.expectNames[index])
|
||||
if tc.expectProvisionErr && err == nil {
|
||||
t.Fatal("expected mod.Provision() error, but got none")
|
||||
}
|
||||
}
|
||||
|
||||
if !tc.expectProvisionErr && err != nil {
|
||||
t.Fatalf("expected no error from mod.Provision(), but got: %v", err)
|
||||
}
|
||||
|
||||
if len(tc.expectPDFEngineNames) != len(mod.names) {
|
||||
t.Errorf("expected %d names but got %d", len(tc.expectPDFEngineNames), len(mod.names))
|
||||
}
|
||||
|
||||
for index, name := range mod.names {
|
||||
if name != tc.expectPDFEngineNames[index] {
|
||||
t.Errorf("expected name at index %d to be %s, but got: %s", index, name, tc.expectPDFEngineNames[index])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPDFEngine_Validate(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
names []string
|
||||
engines []gotenberg.PDFEngine
|
||||
expectErr bool
|
||||
func TestPDFEngines_Validate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
names []string
|
||||
engines []gotenberg.PDFEngine
|
||||
expectValidateErr bool
|
||||
}{
|
||||
{
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
name: "existing PDF engine",
|
||||
names: []string{"foo"},
|
||||
engines: func() []gotenberg.PDFEngine {
|
||||
engine := struct{ ProtoPDFEngine }{}
|
||||
engine.descriptor = func() gotenberg.ModuleDescriptor {
|
||||
engine := struct {
|
||||
gotenberg.ModuleMock
|
||||
gotenberg.PDFEngineMock
|
||||
}{}
|
||||
engine.DescriptorMock = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return engine }}
|
||||
}
|
||||
|
||||
@@ -217,15 +307,22 @@ func TestPDFEngine_Validate(t *testing.T) {
|
||||
}(),
|
||||
},
|
||||
{
|
||||
name: "non-existing bar PDF engine",
|
||||
names: []string{"foo", "bar", "baz"},
|
||||
engines: func() []gotenberg.PDFEngine {
|
||||
engine1 := struct{ ProtoPDFEngine }{}
|
||||
engine1.descriptor = func() gotenberg.ModuleDescriptor {
|
||||
engine1 := struct {
|
||||
gotenberg.ModuleMock
|
||||
gotenberg.PDFEngineMock
|
||||
}{}
|
||||
engine1.DescriptorMock = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return engine1 }}
|
||||
}
|
||||
|
||||
engine2 := struct{ ProtoPDFEngine }{}
|
||||
engine2.descriptor = func() gotenberg.ModuleDescriptor {
|
||||
engine2 := struct {
|
||||
gotenberg.ModuleMock
|
||||
gotenberg.PDFEngineMock
|
||||
}{}
|
||||
engine2.DescriptorMock = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "baz", New: func() gotenberg.Module { return engine2 }}
|
||||
}
|
||||
|
||||
@@ -234,71 +331,115 @@ func TestPDFEngine_Validate(t *testing.T) {
|
||||
engine2,
|
||||
}
|
||||
}(),
|
||||
expectErr: true,
|
||||
expectValidateErr: true,
|
||||
},
|
||||
} {
|
||||
mod := new(PDFEngines)
|
||||
mod.names = tc.names
|
||||
mod.engines = tc.engines
|
||||
err := mod.Validate()
|
||||
{
|
||||
name: "no PDF engine",
|
||||
expectValidateErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
if tc.expectErr && err == nil {
|
||||
t.Errorf("test %d: expected error but got: %v", i, err)
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
mod := PDFEngines{
|
||||
names: tc.names,
|
||||
engines: tc.engines,
|
||||
}
|
||||
|
||||
if !tc.expectErr && err != nil {
|
||||
t.Errorf("test %d: expected no error but got: %v", i, err)
|
||||
}
|
||||
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 TestPDFEngine_PDFEngine(t *testing.T) {
|
||||
func TestPDFEngines_SystemMessages(t *testing.T) {
|
||||
mod := new(PDFEngines)
|
||||
mod.engines = []gotenberg.PDFEngine{
|
||||
struct{ ProtoPDFEngine }{},
|
||||
mod.names = []string{"foo", "bar"}
|
||||
|
||||
messages := mod.SystemMessages()
|
||||
if len(messages) != 1 {
|
||||
t.Errorf("expected one and only one message from mod.SystemMessages(), but got %d", len(messages))
|
||||
}
|
||||
|
||||
expect := strings.Join(mod.names[:], " ")
|
||||
if messages[0] != expect {
|
||||
t.Errorf("expected message '%s' from mod.SystemMessages(), but got '%s'", expect, messages[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPDFEngines_PDFEngine(t *testing.T) {
|
||||
mod := PDFEngines{
|
||||
names: []string{"foo", "bar"},
|
||||
engines: func() []gotenberg.PDFEngine {
|
||||
engine1 := struct {
|
||||
gotenberg.ModuleMock
|
||||
gotenberg.PDFEngineMock
|
||||
}{}
|
||||
engine1.DescriptorMock = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return engine1 }}
|
||||
}
|
||||
|
||||
engine2 := struct {
|
||||
gotenberg.ModuleMock
|
||||
gotenberg.PDFEngineMock
|
||||
}{}
|
||||
engine2.DescriptorMock = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return engine2 }}
|
||||
}
|
||||
|
||||
return []gotenberg.PDFEngine{
|
||||
engine1,
|
||||
engine2,
|
||||
}
|
||||
}(),
|
||||
}
|
||||
|
||||
_, err := mod.PDFEngine()
|
||||
if err != nil {
|
||||
t.Errorf("expected no error but got: %v", err)
|
||||
t.Errorf("expected no error from mod.PDFEngine, but got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPDFEngine_Routes(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
expectRoutes int
|
||||
disableRoutes bool
|
||||
func TestPDFEngines_Routes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mod PDFEngines
|
||||
expectRoutesCount int
|
||||
}{
|
||||
{
|
||||
expectRoutes: 2,
|
||||
name: "route not disabled",
|
||||
mod: PDFEngines{
|
||||
engines: []gotenberg.PDFEngine{
|
||||
gotenberg.PDFEngineMock{},
|
||||
},
|
||||
},
|
||||
expectRoutesCount: 2,
|
||||
},
|
||||
{
|
||||
disableRoutes: true,
|
||||
name: "route disabled",
|
||||
mod: PDFEngines{
|
||||
disableRoutes: true,
|
||||
},
|
||||
},
|
||||
} {
|
||||
mod := new(PDFEngines)
|
||||
mod.engines = []gotenberg.PDFEngine{
|
||||
struct{ ProtoPDFEngine }{},
|
||||
}
|
||||
mod.disableRoutes = tc.disableRoutes
|
||||
}
|
||||
|
||||
routes, err := mod.Routes()
|
||||
if err != nil {
|
||||
t.Fatalf("test %d: expected no error but got: %v", i, err)
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
routes, err := tc.mod.Routes()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error from mod.Routes(), but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectRoutes != len(routes) {
|
||||
t.Errorf("test %d: expected %d routes but got %d", i, tc.expectRoutes, len(routes))
|
||||
}
|
||||
if tc.expectRoutesCount != len(routes) {
|
||||
t.Errorf("expected %d routes from mod.Routes(), but got %d", tc.expectRoutesCount, len(routes))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ gotenberg.Module = (*ProtoModule)(nil)
|
||||
_ gotenberg.Validator = (*ProtoValidator)(nil)
|
||||
_ gotenberg.Module = (*ProtoValidator)(nil)
|
||||
_ gotenberg.PDFEngine = (*ProtoPDFEngine)(nil)
|
||||
_ gotenberg.Module = (*ProtoPDFEngine)(nil)
|
||||
_ gotenberg.Validator = (*ProtoPDFEngine)(nil)
|
||||
)
|
||||
|
||||
@@ -13,7 +13,8 @@ import (
|
||||
)
|
||||
|
||||
func TestMergeHandler(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
tests := []struct {
|
||||
name string
|
||||
ctx *api.MockContext
|
||||
engine gotenberg.PDFEngine
|
||||
expectErr bool
|
||||
@@ -22,34 +23,75 @@ func TestMergeHandler(t *testing.T) {
|
||||
expectOutputPathsCount int
|
||||
}{
|
||||
{
|
||||
name: "nominal behavior",
|
||||
ctx: func() *api.MockContext {
|
||||
ctx := &api.MockContext{Context: &api.Context{}}
|
||||
ctx.SetFiles(map[string]string{
|
||||
"foo.pdf": "/foo/foo.pdf",
|
||||
})
|
||||
|
||||
return ctx
|
||||
}(),
|
||||
engine: gotenberg.PDFEngineMock{
|
||||
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
expectOutputPathsCount: 1,
|
||||
},
|
||||
{
|
||||
name: "invalid form data: no PDF",
|
||||
ctx: &api.MockContext{Context: &api.Context{}},
|
||||
expectErr: true,
|
||||
expectHTTPErr: true,
|
||||
expectHTTPStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "merge fail",
|
||||
ctx: func() *api.MockContext {
|
||||
ctx := &api.MockContext{Context: &api.Context{}}
|
||||
|
||||
ctx.SetFiles(map[string]string{
|
||||
"foo.pdf": "/foo/foo.pdf",
|
||||
})
|
||||
|
||||
return ctx
|
||||
}(),
|
||||
engine: func() gotenberg.PDFEngine {
|
||||
return &ProtoPDFEngine{
|
||||
merge: func(_ context.Context, _ *zap.Logger, _ []string, _ string) error {
|
||||
return errors.New("foo")
|
||||
},
|
||||
}
|
||||
}(),
|
||||
engine: gotenberg.PDFEngineMock{
|
||||
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
||||
return errors.New("foo")
|
||||
},
|
||||
},
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
name: "nominal behavior with a PDF format",
|
||||
ctx: func() *api.MockContext {
|
||||
ctx := &api.MockContext{Context: &api.Context{}}
|
||||
ctx.SetFiles(map[string]string{
|
||||
"foo.pdf": "/foo/foo.pdf",
|
||||
})
|
||||
ctx.SetValues(map[string][]string{
|
||||
"pdfFormat": {
|
||||
gotenberg.FormatPDFA1a,
|
||||
},
|
||||
})
|
||||
|
||||
return ctx
|
||||
}(),
|
||||
engine: gotenberg.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 {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
expectOutputPathsCount: 1,
|
||||
},
|
||||
{
|
||||
name: "convert to PDF format fail",
|
||||
ctx: func() *api.MockContext {
|
||||
ctx := &api.MockContext{Context: &api.Context{}}
|
||||
ctx.SetFiles(map[string]string{
|
||||
"foo.pdf": "/foo/foo.pdf",
|
||||
})
|
||||
@@ -61,153 +103,106 @@ func TestMergeHandler(t *testing.T) {
|
||||
|
||||
return ctx
|
||||
}(),
|
||||
engine: func() gotenberg.PDFEngine {
|
||||
return &ProtoPDFEngine{
|
||||
merge: func(_ context.Context, _ *zap.Logger, _ []string, _ string) error {
|
||||
return nil
|
||||
engine: gotenberg.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 {
|
||||
return errors.New("foo")
|
||||
},
|
||||
},
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid PDF format",
|
||||
ctx: func() *api.MockContext {
|
||||
ctx := &api.MockContext{Context: &api.Context{}}
|
||||
ctx.SetFiles(map[string]string{
|
||||
"foo.pdf": "/foo/foo.pdf",
|
||||
})
|
||||
ctx.SetValues(map[string][]string{
|
||||
"pdfFormat": {
|
||||
"foo",
|
||||
},
|
||||
convert: func(_ context.Context, _ *zap.Logger, _, _, _ string) error {
|
||||
return gotenberg.ErrPDFFormatNotAvailable
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
return ctx
|
||||
}(),
|
||||
engine: gotenberg.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 {
|
||||
return gotenberg.ErrPDFFormatNotAvailable
|
||||
},
|
||||
},
|
||||
expectErr: true,
|
||||
expectHTTPErr: true,
|
||||
expectHTTPStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "cannot add output paths",
|
||||
ctx: func() *api.MockContext {
|
||||
ctx := &api.MockContext{Context: &api.Context{}}
|
||||
|
||||
ctx.SetFiles(map[string]string{
|
||||
"foo.pdf": "/foo/foo.pdf",
|
||||
})
|
||||
ctx.SetValues(map[string][]string{
|
||||
"pdfFormat": {
|
||||
"foo",
|
||||
},
|
||||
})
|
||||
|
||||
return ctx
|
||||
}(),
|
||||
engine: func() gotenberg.PDFEngine {
|
||||
return &ProtoPDFEngine{
|
||||
merge: func(_ context.Context, _ *zap.Logger, _ []string, _ string) error {
|
||||
return nil
|
||||
},
|
||||
convert: func(_ context.Context, _ *zap.Logger, _, _, _ string) error {
|
||||
return errors.New("foo")
|
||||
},
|
||||
}
|
||||
}(),
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
ctx: func() *api.MockContext {
|
||||
ctx := &api.MockContext{Context: &api.Context{}}
|
||||
|
||||
ctx.SetCancelled(true)
|
||||
ctx.SetFiles(map[string]string{
|
||||
"foo.pdf": "/foo/foo.pdf",
|
||||
})
|
||||
|
||||
return ctx
|
||||
}(),
|
||||
engine: func() gotenberg.PDFEngine {
|
||||
return &ProtoPDFEngine{
|
||||
merge: func(_ context.Context, _ *zap.Logger, _ []string, _ string) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}(),
|
||||
engine: gotenberg.PDFEngineMock{
|
||||
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
ctx: func() *api.MockContext {
|
||||
ctx := &api.MockContext{Context: &api.Context{}}
|
||||
}
|
||||
|
||||
ctx.SetFiles(map[string]string{
|
||||
"foo.pdf": "/foo/foo.pdf",
|
||||
})
|
||||
ctx.SetValues(map[string][]string{
|
||||
"pdfFormat": {
|
||||
"foo",
|
||||
},
|
||||
})
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
c := echo.New().NewContext(nil, nil)
|
||||
c.Set("context", tc.ctx.Context)
|
||||
|
||||
return ctx
|
||||
}(),
|
||||
engine: func() gotenberg.PDFEngine {
|
||||
return &ProtoPDFEngine{
|
||||
merge: func(_ context.Context, _ *zap.Logger, _ []string, _ string) error {
|
||||
return nil
|
||||
},
|
||||
convert: func(_ context.Context, _ *zap.Logger, _, _, _ string) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}(),
|
||||
expectOutputPathsCount: 1,
|
||||
},
|
||||
{
|
||||
ctx: func() *api.MockContext {
|
||||
ctx := &api.MockContext{Context: &api.Context{}}
|
||||
err := mergeRoute(tc.engine).Handler(c)
|
||||
|
||||
ctx.SetFiles(map[string]string{
|
||||
"foo.pdf": "/foo/foo.pdf",
|
||||
})
|
||||
|
||||
return ctx
|
||||
}(),
|
||||
engine: func() gotenberg.PDFEngine {
|
||||
return &ProtoPDFEngine{
|
||||
merge: func(_ context.Context, _ *zap.Logger, _ []string, _ string) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}(),
|
||||
expectOutputPathsCount: 1,
|
||||
},
|
||||
} {
|
||||
c := echo.New().NewContext(nil, nil)
|
||||
c.Set("context", tc.ctx.Context)
|
||||
|
||||
err := mergeRoute(tc.engine).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 api.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)
|
||||
if tc.expectErr && err == nil {
|
||||
t.Fatal("expected error from merge handler, but got none")
|
||||
}
|
||||
}
|
||||
|
||||
if tc.expectOutputPathsCount != len(tc.ctx.OutputPaths()) {
|
||||
t.Errorf("test %d: expected %d output paths but got %d", i, tc.expectOutputPathsCount, len(tc.ctx.OutputPaths()))
|
||||
}
|
||||
if !tc.expectErr && err != nil {
|
||||
t.Fatalf("expected no error from merge handler, but got: %v", err)
|
||||
}
|
||||
|
||||
var httpErr api.HTTPError
|
||||
isHTTPErr := errors.As(err, &httpErr)
|
||||
|
||||
if tc.expectHTTPErr && !isHTTPErr {
|
||||
t.Errorf("expected HTTP error from merge handler, but got: %v", err)
|
||||
}
|
||||
|
||||
if !tc.expectHTTPErr && isHTTPErr {
|
||||
t.Errorf("expected no HTTP error from merge handler, but got one: %v", httpErr)
|
||||
}
|
||||
|
||||
if err != nil && tc.expectHTTPErr && isHTTPErr {
|
||||
status, _ := httpErr.HTTPError()
|
||||
if status != tc.expectHTTPStatus {
|
||||
t.Errorf("expected %d HTTP status code from merge handler, but got %d", tc.expectHTTPStatus, status)
|
||||
}
|
||||
}
|
||||
|
||||
if tc.expectOutputPathsCount != len(tc.ctx.OutputPaths()) {
|
||||
t.Errorf("expected %d output paths from merge handler, but got %d", tc.expectOutputPathsCount, len(tc.ctx.OutputPaths()))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertHandler(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
tests := []struct {
|
||||
name string
|
||||
ctx *api.MockContext
|
||||
engine gotenberg.PDFEngine
|
||||
expectErr bool
|
||||
@@ -216,132 +211,109 @@ func TestConvertHandler(t *testing.T) {
|
||||
expectOutputPathsCount int
|
||||
}{
|
||||
{
|
||||
ctx: &api.MockContext{Context: &api.Context{}},
|
||||
expectErr: true,
|
||||
expectHTTPErr: true,
|
||||
expectHTTPStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "nominal behavior",
|
||||
ctx: func() *api.MockContext {
|
||||
ctx := &api.MockContext{Context: &api.Context{}}
|
||||
ctx.SetValues(map[string][]string{
|
||||
"pdfFormat": {
|
||||
"foo",
|
||||
},
|
||||
})
|
||||
|
||||
return ctx
|
||||
}(),
|
||||
expectErr: true,
|
||||
expectHTTPErr: true,
|
||||
expectHTTPStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
ctx: func() *api.MockContext {
|
||||
ctx := &api.MockContext{Context: &api.Context{}}
|
||||
|
||||
ctx.SetFiles(map[string]string{
|
||||
"foo.pdf": "/foo/foo.pdf",
|
||||
})
|
||||
ctx.SetValues(map[string][]string{
|
||||
"pdfFormat": {
|
||||
"foo",
|
||||
gotenberg.FormatPDFA1a,
|
||||
},
|
||||
})
|
||||
|
||||
return ctx
|
||||
}(),
|
||||
engine: func() gotenberg.PDFEngine {
|
||||
return &ProtoPDFEngine{
|
||||
convert: func(_ context.Context, _ *zap.Logger, _, _, _ string) error {
|
||||
return gotenberg.ErrPDFFormatNotAvailable
|
||||
},
|
||||
}
|
||||
}(),
|
||||
expectErr: true,
|
||||
expectHTTPErr: true,
|
||||
expectHTTPStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
ctx: func() *api.MockContext {
|
||||
ctx := &api.MockContext{Context: &api.Context{}}
|
||||
|
||||
ctx.SetFiles(map[string]string{
|
||||
"foo.pdf": "/foo/foo.pdf",
|
||||
})
|
||||
ctx.SetValues(map[string][]string{
|
||||
"pdfFormat": {
|
||||
"foo",
|
||||
},
|
||||
})
|
||||
|
||||
return ctx
|
||||
}(),
|
||||
engine: func() gotenberg.PDFEngine {
|
||||
return &ProtoPDFEngine{
|
||||
convert: func(_ context.Context, _ *zap.Logger, _, _, _ string) error {
|
||||
return errors.New("foo")
|
||||
},
|
||||
}
|
||||
}(),
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
ctx: func() *api.MockContext {
|
||||
ctx := &api.MockContext{Context: &api.Context{}}
|
||||
|
||||
ctx.SetCancelled(true)
|
||||
ctx.SetFiles(map[string]string{
|
||||
"foo.pdf": "/foo/foo.pdf",
|
||||
})
|
||||
ctx.SetValues(map[string][]string{
|
||||
"pdfFormat": {
|
||||
"foo",
|
||||
},
|
||||
})
|
||||
|
||||
return ctx
|
||||
}(),
|
||||
engine: func() gotenberg.PDFEngine {
|
||||
return &ProtoPDFEngine{
|
||||
convert: func(_ context.Context, _ *zap.Logger, _, _, _ string) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}(),
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
ctx: func() *api.MockContext {
|
||||
ctx := &api.MockContext{Context: &api.Context{}}
|
||||
|
||||
ctx.SetFiles(map[string]string{
|
||||
"foo.pdf": "/foo/foo.pdf",
|
||||
})
|
||||
ctx.SetValues(map[string][]string{
|
||||
"pdfFormat": {
|
||||
"foo",
|
||||
},
|
||||
})
|
||||
|
||||
return ctx
|
||||
}(),
|
||||
engine: func() gotenberg.PDFEngine {
|
||||
return &ProtoPDFEngine{
|
||||
convert: func(_ context.Context, _ *zap.Logger, _, _, _ string) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}(),
|
||||
engine: gotenberg.PDFEngineMock{
|
||||
ConvertMock: func(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
expectOutputPathsCount: 1,
|
||||
},
|
||||
{
|
||||
name: "nominal behavior, but with 3 PDFs",
|
||||
ctx: func() *api.MockContext {
|
||||
ctx := &api.MockContext{Context: &api.Context{}}
|
||||
ctx.SetFiles(map[string]string{
|
||||
"foo.pdf": "/foo/foo.pdf",
|
||||
"bar.pdf": "/bar/bar.pdf",
|
||||
"baz.pdf": "/baz/baz.pdf",
|
||||
})
|
||||
ctx.SetValues(map[string][]string{
|
||||
"pdfFormat": {
|
||||
gotenberg.FormatPDFA1a,
|
||||
},
|
||||
})
|
||||
|
||||
return ctx
|
||||
}(),
|
||||
engine: gotenberg.PDFEngineMock{
|
||||
ConvertMock: func(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
expectOutputPathsCount: 3,
|
||||
},
|
||||
{
|
||||
name: "invalid form data: no PDF",
|
||||
ctx: func() *api.MockContext {
|
||||
ctx := &api.MockContext{Context: &api.Context{}}
|
||||
ctx.SetValues(map[string][]string{
|
||||
"pdfFormat": {
|
||||
gotenberg.FormatPDFA1a,
|
||||
},
|
||||
})
|
||||
|
||||
return ctx
|
||||
}(),
|
||||
expectErr: true,
|
||||
expectHTTPErr: true,
|
||||
expectHTTPStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "invalid form data: no PDF format",
|
||||
ctx: func() *api.MockContext {
|
||||
ctx := &api.MockContext{Context: &api.Context{}}
|
||||
ctx.SetFiles(map[string]string{
|
||||
"foo.pdf": "/foo/foo.pdf",
|
||||
})
|
||||
|
||||
return ctx
|
||||
}(),
|
||||
expectErr: true,
|
||||
expectHTTPErr: true,
|
||||
expectHTTPStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "convert to PDF format fail",
|
||||
ctx: func() *api.MockContext {
|
||||
ctx := &api.MockContext{Context: &api.Context{}}
|
||||
ctx.SetFiles(map[string]string{
|
||||
"foo.pdf": "/foo/foo.pdf",
|
||||
})
|
||||
ctx.SetValues(map[string][]string{
|
||||
"pdfFormat": {
|
||||
gotenberg.FormatPDFA1a,
|
||||
},
|
||||
})
|
||||
|
||||
return ctx
|
||||
}(),
|
||||
engine: gotenberg.PDFEngineMock{
|
||||
ConvertMock: func(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error {
|
||||
return errors.New("foo")
|
||||
},
|
||||
},
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
name: "PDF format not available",
|
||||
ctx: func() *api.MockContext {
|
||||
ctx := &api.MockContext{Context: &api.Context{}}
|
||||
|
||||
ctx.SetFiles(map[string]string{
|
||||
"foo.pdf": "/foo/foo.pdf",
|
||||
"bar.pdf": "/foo/bar.pdf",
|
||||
})
|
||||
ctx.SetValues(map[string][]string{
|
||||
"pdfFormat": {
|
||||
@@ -351,49 +323,76 @@ func TestConvertHandler(t *testing.T) {
|
||||
|
||||
return ctx
|
||||
}(),
|
||||
engine: func() gotenberg.PDFEngine {
|
||||
return &ProtoPDFEngine{
|
||||
convert: func(_ context.Context, _ *zap.Logger, _, _, _ string) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}(),
|
||||
expectOutputPathsCount: 2,
|
||||
engine: gotenberg.PDFEngineMock{
|
||||
ConvertMock: func(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error {
|
||||
return gotenberg.ErrPDFFormatNotAvailable
|
||||
},
|
||||
},
|
||||
expectErr: true,
|
||||
expectHTTPErr: true,
|
||||
expectHTTPStatus: http.StatusBadRequest,
|
||||
},
|
||||
} {
|
||||
c := echo.New().NewContext(nil, nil)
|
||||
c.Set("context", tc.ctx.Context)
|
||||
{
|
||||
name: "cannot add output paths",
|
||||
ctx: func() *api.MockContext {
|
||||
ctx := &api.MockContext{Context: &api.Context{}}
|
||||
ctx.SetFiles(map[string]string{
|
||||
"foo.pdf": "/foo/foo.pdf",
|
||||
})
|
||||
ctx.SetValues(map[string][]string{
|
||||
"pdfFormat": {
|
||||
gotenberg.FormatPDFA1a,
|
||||
},
|
||||
})
|
||||
ctx.SetCancelled(true)
|
||||
|
||||
err := convertRoute(tc.engine).Handler(c)
|
||||
return ctx
|
||||
}(),
|
||||
engine: gotenberg.PDFEngineMock{
|
||||
ConvertMock: func(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
expectErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
if tc.expectErr && err == nil {
|
||||
t.Errorf("test %d: expected error but got: %v", i, err)
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
c := echo.New().NewContext(nil, nil)
|
||||
c.Set("context", tc.ctx.Context)
|
||||
|
||||
if !tc.expectErr && err != nil {
|
||||
t.Errorf("test %d: expected no error but got: %v", i, err)
|
||||
}
|
||||
err := convertRoute(tc.engine).Handler(c)
|
||||
|
||||
var httpErr api.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)
|
||||
if tc.expectErr && err == nil {
|
||||
t.Fatal("expected error from convert handler, but got none")
|
||||
}
|
||||
}
|
||||
|
||||
if tc.expectOutputPathsCount != len(tc.ctx.OutputPaths()) {
|
||||
t.Errorf("test %d: expected %d output paths but got %d", i, tc.expectOutputPathsCount, len(tc.ctx.OutputPaths()))
|
||||
}
|
||||
if !tc.expectErr && err != nil {
|
||||
t.Fatalf("expected no error from convert handler, but got: %v", err)
|
||||
}
|
||||
|
||||
var httpErr api.HTTPError
|
||||
isHTTPErr := errors.As(err, &httpErr)
|
||||
|
||||
if tc.expectHTTPErr && !isHTTPErr {
|
||||
t.Errorf("expected HTTP error from convert handler, but got: %v", err)
|
||||
}
|
||||
|
||||
if !tc.expectHTTPErr && isHTTPErr {
|
||||
t.Errorf("expected no HTTP error from convert handler, but got one: %v", httpErr)
|
||||
}
|
||||
|
||||
if err != nil && tc.expectHTTPErr && isHTTPErr {
|
||||
status, _ := httpErr.HTTPError()
|
||||
if status != tc.expectHTTPStatus {
|
||||
t.Errorf("expected %d HTTP status code from convert handler, but got %d", tc.expectHTTPStatus, status)
|
||||
}
|
||||
}
|
||||
|
||||
if tc.expectOutputPathsCount != len(tc.ctx.OutputPaths()) {
|
||||
t.Errorf("expected %d output paths from convert handler, but got %d", tc.expectOutputPathsCount, len(tc.ctx.OutputPaths()))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ func (engine PDFtk) Merge(ctx context.Context, logger *zap.Logger, inputPaths []
|
||||
activeInstancesCount += 1
|
||||
activeInstancesCountMu.Unlock()
|
||||
|
||||
err = cmd.Exec()
|
||||
_, err = cmd.Exec()
|
||||
|
||||
activeInstancesCountMu.Lock()
|
||||
activeInstancesCount -= 1
|
||||
|
||||
@@ -65,11 +65,11 @@ func TestPDFtk_Validate(t *testing.T) {
|
||||
func TestPDFtk_Metrics(t *testing.T) {
|
||||
metrics, err := new(PDFtk).Metrics()
|
||||
if err != nil {
|
||||
t.Errorf("expected no error but got: %v", err)
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if len(metrics) != 1 {
|
||||
t.Errorf("expected %d metrics, but got %d", 1, len(metrics))
|
||||
t.Fatalf("expected %d metrics, but got %d", 1, len(metrics))
|
||||
}
|
||||
|
||||
actual := metrics[0].Read()
|
||||
|
||||
@@ -150,7 +150,7 @@ func (mod Prometheus) Start() error {
|
||||
// StartupMessage returns a custom startup message.
|
||||
func (mod Prometheus) StartupMessage() string {
|
||||
if mod.disableCollect {
|
||||
return "application not started (collect disabled by user)"
|
||||
return "collect disabled"
|
||||
}
|
||||
|
||||
return "collecting metrics"
|
||||
|
||||
@@ -296,8 +296,8 @@ func TestPrometheus_StartupMessage(t *testing.T) {
|
||||
expectMessage string
|
||||
}{
|
||||
{
|
||||
expectMessage: "application not started (collect disabled by user)",
|
||||
disableCollect: true,
|
||||
expectMessage: "collect disabled",
|
||||
},
|
||||
{
|
||||
expectMessage: "collecting metrics",
|
||||
|
||||
3
pkg/modules/qpdf/doc.go
Normal file
3
pkg/modules/qpdf/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package qpdf provides a module which abstracts the CLI tool QPDF and
|
||||
// implements the gotenberg.PDFEngine interface.
|
||||
package qpdf
|
||||
117
pkg/modules/qpdf/qpdf.go
Normal file
117
pkg/modules/qpdf/qpdf.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package qpdf
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func init() {
|
||||
gotenberg.MustRegisterModule(QPDF{})
|
||||
}
|
||||
|
||||
// QPDF abstracts the CLI tool QPDF and implements the gotenberg.PDFEngine
|
||||
// interface.
|
||||
type QPDF struct {
|
||||
binPath string
|
||||
}
|
||||
|
||||
// Descriptor returns a QPDF's module descriptor.
|
||||
func (QPDF) Descriptor() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{
|
||||
ID: "qpdf",
|
||||
New: func() gotenberg.Module { return new(QPDF) },
|
||||
}
|
||||
}
|
||||
|
||||
// Provision sets the modules properties. It returns an error if the
|
||||
// environment variable QPDF_BIN_PATH is not set.
|
||||
func (engine *QPDF) Provision(_ *gotenberg.Context) error {
|
||||
binPath, ok := os.LookupEnv("QPDF_BIN_PATH")
|
||||
if !ok {
|
||||
return errors.New("QPDF_BIN_PATH environment variable is not set")
|
||||
}
|
||||
|
||||
engine.binPath = binPath
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate validates the module properties.
|
||||
func (engine QPDF) Validate() error {
|
||||
_, err := os.Stat(engine.binPath)
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("QPDF binary path does not exist: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Metrics returns the metrics.
|
||||
func (engine QPDF) Metrics() ([]gotenberg.Metric, error) {
|
||||
return []gotenberg.Metric{
|
||||
{
|
||||
Name: "qpdf_active_instances_count",
|
||||
Description: "Current number of active QPDF instances.",
|
||||
Read: func() float64 {
|
||||
activeInstancesCountMu.RLock()
|
||||
defer activeInstancesCountMu.RUnlock()
|
||||
|
||||
return activeInstancesCount
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Merge merges the given PDFs into a unique PDF.
|
||||
func (engine QPDF) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
||||
var args []string
|
||||
args = append(args, "--empty")
|
||||
args = append(args, "--pages")
|
||||
args = append(args, inputPaths...)
|
||||
args = append(args, "--", outputPath)
|
||||
|
||||
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create command: %w", err)
|
||||
}
|
||||
|
||||
activeInstancesCountMu.Lock()
|
||||
activeInstancesCount += 1
|
||||
activeInstancesCountMu.Unlock()
|
||||
|
||||
_, err = cmd.Exec()
|
||||
|
||||
activeInstancesCountMu.Lock()
|
||||
activeInstancesCount -= 1
|
||||
activeInstancesCountMu.Unlock()
|
||||
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("merge PDFs with QPDF: %w", err)
|
||||
}
|
||||
|
||||
// Convert is not available for this PDF engine.
|
||||
func (engine QPDF) Convert(_ context.Context, _ *zap.Logger, format, _, _ string) error {
|
||||
return fmt.Errorf("convert PDF to '%s' with QPDF: %w", format, gotenberg.ErrPDFEngineMethodNotAvailable)
|
||||
}
|
||||
|
||||
var (
|
||||
activeInstancesCount float64
|
||||
activeInstancesCountMu sync.RWMutex
|
||||
)
|
||||
|
||||
var (
|
||||
_ gotenberg.Module = (*QPDF)(nil)
|
||||
_ gotenberg.Provisioner = (*QPDF)(nil)
|
||||
_ gotenberg.Validator = (*QPDF)(nil)
|
||||
_ gotenberg.MetricsProvider = (*QPDF)(nil)
|
||||
_ gotenberg.PDFEngine = (*QPDF)(nil)
|
||||
)
|
||||
@@ -1,7 +1,8 @@
|
||||
package unoconv
|
||||
package qpdf
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"reflect"
|
||||
"testing"
|
||||
@@ -10,19 +11,19 @@ import (
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestUnoconv_Descriptor(t *testing.T) {
|
||||
descriptor := Unoconv{}.Descriptor()
|
||||
func TestQPDF_Descriptor(t *testing.T) {
|
||||
descriptor := QPDF{}.Descriptor()
|
||||
|
||||
actual := reflect.TypeOf(descriptor.New())
|
||||
expect := reflect.TypeOf(new(Unoconv))
|
||||
expect := reflect.TypeOf(new(QPDF))
|
||||
|
||||
if actual != expect {
|
||||
t.Errorf("expected '%'s' but got '%s'", expect, actual)
|
||||
t.Errorf("expected '%s' but got '%s'", expect, actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnoconv_Provision(t *testing.T) {
|
||||
mod := new(Unoconv)
|
||||
func TestQPDF_Provision(t *testing.T) {
|
||||
mod := new(QPDF)
|
||||
ctx := gotenberg.NewContext(gotenberg.ParsedFlags{}, nil)
|
||||
|
||||
err := mod.Provision(ctx)
|
||||
@@ -31,7 +32,7 @@ func TestUnoconv_Provision(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnoconv_Validate(t *testing.T) {
|
||||
func TestQPDF_Validate(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
binPath string
|
||||
expectErr bool
|
||||
@@ -44,10 +45,10 @@ func TestUnoconv_Validate(t *testing.T) {
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
binPath: os.Getenv("UNOCONV_BIN_PATH"),
|
||||
binPath: os.Getenv("QPDF_BIN_PATH"),
|
||||
},
|
||||
} {
|
||||
mod := new(Unoconv)
|
||||
mod := new(QPDF)
|
||||
mod.binPath = tc.binPath
|
||||
err := mod.Validate()
|
||||
|
||||
@@ -61,8 +62,8 @@ func TestUnoconv_Validate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestChromium_Metrics(t *testing.T) {
|
||||
metrics, err := new(Unoconv).Metrics()
|
||||
func TestQPDF_Metrics(t *testing.T) {
|
||||
metrics, err := new(QPDF).Metrics()
|
||||
if err != nil {
|
||||
t.Errorf("expected no error but got: %v", err)
|
||||
}
|
||||
@@ -73,59 +74,43 @@ func TestChromium_Metrics(t *testing.T) {
|
||||
|
||||
actual := metrics[0].Read()
|
||||
if actual != 0 {
|
||||
t.Errorf("expected %d unoconv instances, but got %f", 0, actual)
|
||||
t.Errorf("expected %d QPDF instances, but got %f", 0, actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnoconv_Unoconv(t *testing.T) {
|
||||
mod := new(Unoconv)
|
||||
|
||||
_, err := mod.Unoconv()
|
||||
if err != nil {
|
||||
t.Errorf("expected no error but got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnoconv_PDF(t *testing.T) {
|
||||
func TestQPDF_Merge(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
ctx context.Context
|
||||
inputPath string
|
||||
options Options
|
||||
expectErr bool
|
||||
ctx context.Context
|
||||
inputPaths []string
|
||||
expectErr bool
|
||||
}{
|
||||
{
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
ctx: context.Background(),
|
||||
inputPath: "/tests/test/testdata/libreoffice/sample1.docx",
|
||||
options: Options{
|
||||
Landscape: true,
|
||||
PageRanges: "1-2",
|
||||
PDFArchive: true,
|
||||
ctx: context.TODO(),
|
||||
inputPaths: []string{
|
||||
"/tests/test/testdata/pdfengines/sample1.pdf",
|
||||
},
|
||||
},
|
||||
{
|
||||
ctx: context.Background(),
|
||||
inputPath: "/tests/test/testdata/libreoffice/sample1.docx",
|
||||
options: Options{
|
||||
PageRanges: "foo",
|
||||
ctx: context.TODO(),
|
||||
inputPaths: []string{
|
||||
"/tests/test/testdata/pdfengines/sample1.pdf",
|
||||
"/tests/test/testdata/pdfengines/sample2.pdf",
|
||||
},
|
||||
},
|
||||
{
|
||||
ctx: nil,
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
ctx: func() context.Context {
|
||||
ctx, cancel := context.WithCancel(context.TODO())
|
||||
defer cancel()
|
||||
|
||||
return ctx
|
||||
}(),
|
||||
inputPath: "/tests/test/testdata/libreoffice/sample1.docx",
|
||||
ctx: context.TODO(),
|
||||
inputPaths: []string{
|
||||
"foo",
|
||||
},
|
||||
expectErr: true,
|
||||
},
|
||||
} {
|
||||
func() {
|
||||
mod := new(Unoconv)
|
||||
mod := new(QPDF)
|
||||
|
||||
err := mod.Provision(nil)
|
||||
if err != nil {
|
||||
@@ -144,7 +129,7 @@ func TestUnoconv_PDF(t *testing.T) {
|
||||
}
|
||||
}()
|
||||
|
||||
err = mod.PDF(tc.ctx, zap.NewNop(), tc.inputPath, outputDir+"/foo.pdf", tc.options)
|
||||
err = mod.Merge(tc.ctx, zap.NewNop(), tc.inputPaths, outputDir+"/foo.pdf")
|
||||
|
||||
if tc.expectErr && err == nil {
|
||||
t.Errorf("test %d: expected error but got: %v", i, err)
|
||||
@@ -157,14 +142,11 @@ func TestUnoconv_PDF(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnoconv_Extensions(t *testing.T) {
|
||||
mod := new(Unoconv)
|
||||
extensions := mod.Extensions()
|
||||
func TestQPDF_Convert(t *testing.T) {
|
||||
mod := new(QPDF)
|
||||
err := mod.Convert(context.TODO(), zap.NewNop(), "", "", "")
|
||||
|
||||
actual := len(extensions)
|
||||
expect := 76
|
||||
|
||||
if actual != expect {
|
||||
t.Errorf("expected %d extensions but got %d", expect, actual)
|
||||
if !errors.Is(err, gotenberg.ErrPDFEngineMethodNotAvailable) {
|
||||
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPDFEngineMethodNotAvailable, err)
|
||||
}
|
||||
}
|
||||
@@ -143,7 +143,7 @@ func webhookMiddleware(w Webhook) api.Middleware {
|
||||
|
||||
client: &retryablehttp.Client{
|
||||
HTTPClient: &http.Client{
|
||||
Timeout: c.Get("writeTimeout").(time.Duration),
|
||||
Timeout: w.clientTimeout,
|
||||
},
|
||||
RetryMax: w.maxRetry,
|
||||
RetryWaitMin: w.retryMinWait,
|
||||
|
||||
@@ -324,6 +324,7 @@ func TestWebhookMiddlewareAsynchronousProcess(t *testing.T) {
|
||||
maxRetry: 0,
|
||||
retryMinWait: 0,
|
||||
retryMaxWait: 0,
|
||||
clientTimeout: time.Duration(30) * time.Second,
|
||||
disable: false,
|
||||
}
|
||||
}
|
||||
@@ -397,7 +398,6 @@ func TestWebhookMiddlewareAsynchronousProcess(t *testing.T) {
|
||||
c.Set("traceHeader", "Gotenberg-Trace")
|
||||
c.Set("trace", "foo")
|
||||
c.Set("startTime", time.Now())
|
||||
c.Set("writeTimeout", time.Duration(10)*time.Second)
|
||||
|
||||
ctx := &api.MockContext{Context: &api.Context{}}
|
||||
ctx.SetLogger(zap.NewNop())
|
||||
|
||||
@@ -25,6 +25,7 @@ type Webhook struct {
|
||||
maxRetry int
|
||||
retryMinWait time.Duration
|
||||
retryMaxWait time.Duration
|
||||
clientTimeout time.Duration
|
||||
disable bool
|
||||
}
|
||||
|
||||
@@ -55,7 +56,7 @@ func (Webhook) Descriptor() gotenberg.ModuleDescriptor {
|
||||
err = multierr.Append(err, fs.MarkDeprecated("api-disable-webhook", "use webhook-disable instead"))
|
||||
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("create deprecated flags for webhook module: %v", err))
|
||||
panic(fmt.Errorf("create deprecated flags for the webhook module: %v", err))
|
||||
}
|
||||
|
||||
// New flags.
|
||||
@@ -66,6 +67,7 @@ func (Webhook) Descriptor() gotenberg.ModuleDescriptor {
|
||||
fs.Int("webhook-max-retry", 4, "Set the maximum number of retries for the webhook feature")
|
||||
fs.Duration("webhook-retry-min-wait", time.Duration(1)*time.Second, "Set the minimum duration to wait before trying to call the webhook again")
|
||||
fs.Duration("webhook-retry-max-wait", time.Duration(30)*time.Second, "Set the maximum duration to wait before trying to call the webhook again")
|
||||
fs.Duration("webhook-client-timeout", time.Duration(30)*time.Second, "Set the time limit for requests to the webhook")
|
||||
fs.Bool("webhook-disable", false, "Disable the webhook feature")
|
||||
|
||||
return fs
|
||||
@@ -84,6 +86,7 @@ func (w *Webhook) Provision(ctx *gotenberg.Context) error {
|
||||
w.maxRetry = flags.MustDeprecatedInt("api-webhook-max-retry", "webhook-max-retry")
|
||||
w.retryMinWait = flags.MustDeprecatedDuration("api-webhook-retry-min-wait", "webhook-retry-min-wait")
|
||||
w.retryMaxWait = flags.MustDeprecatedDuration("api-webhook-retry-min-wait", "webhook-retry-max-wait")
|
||||
w.clientTimeout = flags.MustDuration("webhook-client-timeout")
|
||||
w.disable = flags.MustDeprecatedBool("api-disable-webhook", "webhook-disable")
|
||||
|
||||
return nil
|
||||
|
||||
@@ -7,11 +7,12 @@ import (
|
||||
_ "github.com/gotenberg/gotenberg/v7/pkg/modules/gc"
|
||||
_ "github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice"
|
||||
_ "github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/pdfengine"
|
||||
_ "github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/unoconv"
|
||||
_ "github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/uno"
|
||||
_ "github.com/gotenberg/gotenberg/v7/pkg/modules/logging"
|
||||
_ "github.com/gotenberg/gotenberg/v7/pkg/modules/pdfcpu"
|
||||
_ "github.com/gotenberg/gotenberg/v7/pkg/modules/pdfengines"
|
||||
_ "github.com/gotenberg/gotenberg/v7/pkg/modules/pdftk"
|
||||
_ "github.com/gotenberg/gotenberg/v7/pkg/modules/prometheus"
|
||||
_ "github.com/gotenberg/gotenberg/v7/pkg/modules/qpdf"
|
||||
_ "github.com/gotenberg/gotenberg/v7/pkg/modules/webhook"
|
||||
)
|
||||
|
||||
@@ -6,8 +6,9 @@ GOLANG_VERSION="$1"
|
||||
GOTENBERG_VERSION="$2"
|
||||
GOTENBERG_USER_GID="$3"
|
||||
GOTENBERG_USER_UID="$4"
|
||||
PDFTK_VERSION="$5"
|
||||
DOCKER_REPOSITORY="$6"
|
||||
NOTO_COLOR_EMOJI_VERSION="$5"
|
||||
PDFTK_VERSION="$6"
|
||||
DOCKER_REPOSITORY="$7"
|
||||
|
||||
GOTENBERG_VERSION="${GOTENBERG_VERSION//v}"
|
||||
SEMVER=( ${GOTENBERG_VERSION//./ } )
|
||||
@@ -23,6 +24,7 @@ docker buildx build \
|
||||
--build-arg GOTENBERG_VERSION="$GOTENBERG_VERSION" \
|
||||
--build-arg GOTENBERG_USER_GID="$GOTENBERG_USER_GID" \
|
||||
--build-arg GOTENBERG_USER_UID="$GOTENBERG_USER_UID" \
|
||||
--build-arg NOTO_COLOR_EMOJI_VERSION="$NOTO_COLOR_EMOJI_VERSION" \
|
||||
--build-arg PDFTK_VERSION="$PDFTK_VERSION" \
|
||||
--platform linux/amd64 \
|
||||
--platform linux/arm64 \
|
||||
|
||||
@@ -42,4 +42,4 @@ RUN apt-get update -qq &&\
|
||||
# Pristine working directory.
|
||||
WORKDIR /tests
|
||||
|
||||
ENTRYPOINT [ "docker-entrypoint.sh" ]
|
||||
ENTRYPOINT [ "/usr/bin/tini", "--", "docker-entrypoint.sh" ]
|
||||
24
test/testdata/chromium/html/sample10/index.html
vendored
Normal file
24
test/testdata/chromium/html/sample10/index.html
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Gutenberg</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<p>Console API</p>
|
||||
|
||||
<script type="application/javascript">
|
||||
console.log("a simple message")
|
||||
console.debug("a debug message")
|
||||
console.warn("a warning message")
|
||||
console.error("an error message")
|
||||
</script>
|
||||
<script type="application/javascript">
|
||||
throw new Error("Exception 1")
|
||||
</script>
|
||||
<script type="application/javascript">
|
||||
throw new Error("Exception 2")
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
19
test/testdata/chromium/html/sample8/index.html
vendored
Normal file
19
test/testdata/chromium/html/sample8/index.html
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Gutenberg</title>
|
||||
<style>
|
||||
@media print {
|
||||
#screen { display: none }
|
||||
}
|
||||
@media screen {
|
||||
#print { display: none }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<p id="print">Print media type</p>
|
||||
<p id="screen">Screen media type</p>
|
||||
</body>
|
||||
</html>
|
||||
17
test/testdata/chromium/html/sample9/index.html
vendored
Normal file
17
test/testdata/chromium/html/sample9/index.html
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Gutenberg</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<p id="javascript">
|
||||
JavaScript disabled.
|
||||
</p>
|
||||
|
||||
<script type="application/javascript">
|
||||
document.getElementById('javascript').innerText = 'JavaScript not disabled.'
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user