From 052448c59ac65f712399e4c93a29c2e7eb088eb9 Mon Sep 17 00:00:00 2001 From: Julien Neuhart Date: Sat, 28 Oct 2023 17:55:53 +0200 Subject: [PATCH] chore(libreoffice): switch to supervisor (#708) --- Makefile | 14 +- build/Dockerfile | 2 +- go.mod | 6 +- go.sum | 12 +- pkg/modules/chromium/browser.go | 10 +- pkg/modules/chromium/browser_test.go | 4 +- pkg/modules/chromium/chromium.go | 4 +- pkg/modules/chromium/chromium_test.go | 2 +- pkg/modules/chromium/doc.go | 2 +- pkg/modules/chromium/mocks.go | 4 +- pkg/modules/chromium/routes_test.go | 15 - pkg/modules/libreoffice/api/api.go | 388 ++++++++ pkg/modules/libreoffice/api/api_test.go | 428 ++++++++ pkg/modules/libreoffice/api/doc.go | 3 + .../libreoffice/{uno => api}/freeport.go | 2 +- pkg/modules/libreoffice/api/libreoffice.go | 320 ++++++ .../libreoffice/api/libreoffice_test.go | 537 ++++++++++ pkg/modules/libreoffice/api/mocks.go | 49 + pkg/modules/libreoffice/api/mocks_test.go | 55 ++ pkg/modules/libreoffice/libreoffice.go | 24 +- pkg/modules/libreoffice/libreoffice_test.go | 249 +++-- pkg/modules/libreoffice/pdfengine/doc.go | 4 +- .../libreoffice/pdfengine/pdfengine.go | 52 +- .../libreoffice/pdfengine/pdfengine_test.go | 171 ++-- pkg/modules/libreoffice/routes.go | 88 +- pkg/modules/libreoffice/routes_test.go | 692 ++++++------- pkg/modules/libreoffice/uno/doc.go | 3 - pkg/modules/libreoffice/uno/listener.go | 398 -------- pkg/modules/libreoffice/uno/listener_test.go | 438 --------- pkg/modules/libreoffice/uno/mocks.go | 36 - pkg/modules/libreoffice/uno/mocks_test.go | 42 - pkg/modules/libreoffice/uno/uno.go | 485 --------- pkg/modules/libreoffice/uno/uno_test.go | 924 ------------------ pkg/modules/pdfengines/pdfengines.go | 4 +- pkg/modules/pdfengines/pdfengines_test.go | 4 +- pkg/standard/imports.go | 2 +- .../{sample1.docx => document.docx} | Bin test/testdata/libreoffice/document.txt | 1 + 38 files changed, 2381 insertions(+), 3093 deletions(-) create mode 100644 pkg/modules/libreoffice/api/api.go create mode 100644 pkg/modules/libreoffice/api/api_test.go create mode 100644 pkg/modules/libreoffice/api/doc.go rename pkg/modules/libreoffice/{uno => api}/freeport.go (98%) create mode 100644 pkg/modules/libreoffice/api/libreoffice.go create mode 100644 pkg/modules/libreoffice/api/libreoffice_test.go create mode 100644 pkg/modules/libreoffice/api/mocks.go create mode 100644 pkg/modules/libreoffice/api/mocks_test.go delete mode 100644 pkg/modules/libreoffice/uno/doc.go delete mode 100644 pkg/modules/libreoffice/uno/listener.go delete mode 100644 pkg/modules/libreoffice/uno/listener_test.go delete mode 100644 pkg/modules/libreoffice/uno/mocks.go delete mode 100644 pkg/modules/libreoffice/uno/mocks_test.go delete mode 100644 pkg/modules/libreoffice/uno/uno.go delete mode 100644 pkg/modules/libreoffice/uno/uno_test.go rename test/testdata/libreoffice/{sample1.docx => document.docx} (100%) create mode 100644 test/testdata/libreoffice/document.txt diff --git a/Makefile b/Makefile index 984d584c..bf2442dd 100644 --- a/Makefile +++ b/Makefile @@ -47,7 +47,10 @@ CHROMIUM_ALLOW_LIST= CHROMIUM_DENY_LIST="^file:///[^tmp].*" CHROMIUM_DISABLE_JAVASCRIPT=false CHROMIUM_DISABLE_ROUTES=false -LIBREOFFICE_DISABLES_ROUTES=false +LIBREOFFICE_RESTART_AFTER=10 +LIBREOFFICE_AUTO_START=false +LIBREOFFICE_START_TIMEOUT=10s +LIBREOFFICE_DISABLE_ROUTES=false LOG_LEVEL=info LOG_FORMAT=auto LOG_FIELDS_PREFIX= @@ -57,8 +60,6 @@ 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= @@ -96,7 +97,10 @@ run: ## Start a Gotenberg container --chromium-deny-list=$(CHROMIUM_DENY_LIST) \ --chromium-disable-javascript=$(CHROMIUM_DISABLE_JAVASCRIPT) \ --chromium-disable-routes=$(CHROMIUM_DISABLE_ROUTES) \ - --libreoffice-disable-routes=$(LIBREOFFICE_DISABLES_ROUTES) \ + --libreoffice-restart-after=$(LIBREOFFICE_RESTART_AFTER) \ + --libreoffice-auto-start=$(LIBREOFFICE_AUTO_START) \ + --libreoffice-start-timeout=$(LIBREOFFICE_START_TIMEOUT) \ + --libreoffice-disable-routes=$(LIBREOFFICE_DISABLE_ROUTES) \ --log-level=$(LOG_LEVEL) \ --log-format=$(LOG_FORMAT) \ --log-fields-prefix=$(LOG_FIELDS_PREFIX) \ @@ -106,8 +110,6 @@ 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) \ diff --git a/build/Dockerfile b/build/Dockerfile index d0d9d5af..eb17a55c 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -180,8 +180,8 @@ COPY --from=binary-stage /home/gotenberg /usr/bin/ # Environment variables required by modules or else. 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 UNOCONV_BIN_PATH /usr/bin/unoconv ENV PDFTK_BIN_PATH /usr/bin/pdftk ENV QPDF_BIN_PATH /usr/bin/qpdf diff --git a/go.mod b/go.mod index 9d2d26ef..55d42853 100644 --- a/go.mod +++ b/go.mod @@ -5,13 +5,13 @@ go 1.21 require ( github.com/alexliesenfeld/health v0.8.0 github.com/andybalholm/brotli v1.0.6 // indirect - github.com/chromedp/cdproto v0.0.0-20231019002500-864b42864d36 + github.com/chromedp/cdproto v0.0.0-20231025043423-5615e204d422 github.com/chromedp/chromedp v0.9.3 github.com/golang/snappy v0.0.4 // indirect - github.com/google/uuid v1.3.1 + github.com/google/uuid v1.4.0 github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-retryablehttp v0.7.4 - github.com/klauspost/compress v1.17.1 // indirect + github.com/klauspost/compress v1.17.2 // indirect github.com/klauspost/pgzip v1.2.6 // indirect github.com/labstack/echo/v4 v4.11.2 github.com/labstack/gommon v0.4.0 diff --git a/go.sum b/go.sum index 60bda6ca..6e663944 100644 --- a/go.sum +++ b/go.sum @@ -10,8 +10,8 @@ github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6r github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chromedp/cdproto v0.0.0-20231011050154-1d073bb38998/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs= -github.com/chromedp/cdproto v0.0.0-20231019002500-864b42864d36 h1:bZQXbfLJ/7qq7CKZ7F1wgrY91SeBbuTcQtv6xjeHpMQ= -github.com/chromedp/cdproto v0.0.0-20231019002500-864b42864d36/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs= +github.com/chromedp/cdproto v0.0.0-20231025043423-5615e204d422 h1:9d05eR3+VAcQqH5qiKbpMFa83XFK7jVDfAXIindFPbU= +github.com/chromedp/cdproto v0.0.0-20231025043423-5615e204d422/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs= github.com/chromedp/chromedp v0.9.3 h1:Wq58e0dZOdHsxaj9Owmfcf+ibtpYN1N0FWVbaxa/esg= github.com/chromedp/chromedp v0.9.3/go.mod h1:NipeUkUcuzIdFbBP8eNNvl9upcceOfWzoJn6cRe4ksA= github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic= @@ -35,8 +35,8 @@ github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEW github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4= -github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4= +github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY= github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= @@ -53,8 +53,8 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= github.com/klauspost/compress v1.11.4/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= -github.com/klauspost/compress v1.17.1 h1:NE3C767s2ak2bweCZo3+rdP4U/HoyVXLv/X9f2gPS5g= -github.com/klauspost/compress v1.17.1/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/klauspost/compress v1.17.2 h1:RlWWUY/Dr4fL8qk9YG7DTZ7PDgME2V4csBXA8L/ixi4= +github.com/klauspost/compress v1.17.2/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= github.com/klauspost/pgzip v1.2.5/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU= diff --git a/pkg/modules/chromium/browser.go b/pkg/modules/chromium/browser.go index 31926929..e5fdf95f 100644 --- a/pkg/modules/chromium/browser.go +++ b/pkg/modules/chromium/browser.go @@ -151,7 +151,8 @@ func (b *chromiumBrowser) Start(logger *zap.Logger) error { func (b *chromiumBrowser) Stop(logger *zap.Logger) error { if !b.isStarted.Load() { - return errors.New("browser is already stopped") + // No big deal? Like calling cancel twice. + return nil } // Always remove the user profile directory created by Chromium. @@ -195,8 +196,11 @@ func (b *chromiumBrowser) Healthy(logger *zap.Logger) bool { b.ctxMu.RLock() defer b.ctxMu.RUnlock() - taskCtx, cancel := chromedp.NewContext(b.ctx) - defer cancel() + timeoutCtx, timeoutCancel := context.WithTimeout(b.ctx, time.Duration(10)*time.Second) + defer timeoutCancel() + + taskCtx, taskCancel := chromedp.NewContext(timeoutCtx) + defer taskCancel() err := chromedp.Run(taskCtx, chromedp.Navigate("about:blank")) if err != nil { diff --git a/pkg/modules/chromium/browser_test.go b/pkg/modules/chromium/browser_test.go index 835d0921..39d295fd 100644 --- a/pkg/modules/chromium/browser_test.go +++ b/pkg/modules/chromium/browser_test.go @@ -135,7 +135,7 @@ func TestChromiumBrowser_Stop(t *testing.T) { b.isStarted.Store(false) return b }(), - expectError: true, + expectError: false, }, } { t.Run(tc.scenario, func(t *testing.T) { @@ -927,7 +927,7 @@ func TestChromiumBrowser_pdf(t *testing.T) { expectedError: ErrPageRangesSyntaxError, }, { - scenario: "default options", + scenario: "success (default options)", browser: newChromiumBrowser( browserArguments{ binPath: os.Getenv("CHROMIUM_BIN_PATH"), diff --git a/pkg/modules/chromium/chromium.go b/pkg/modules/chromium/chromium.go index f81e47ee..fd6f764a 100644 --- a/pkg/modules/chromium/chromium.go +++ b/pkg/modules/chromium/chromium.go @@ -223,10 +223,10 @@ func (mod *Chromium) Descriptor() gotenberg.ModuleDescriptor { var err error err = multierr.Append(err, fs.MarkDeprecated("chromium-user-agent", "use the extraHttpHeaders form field instead")) - err = multierr.Append(err, fs.MarkDeprecated("chromium-failed-starts-threshold", "use the chromium-restart-after instead")) + err = multierr.Append(err, fs.MarkDeprecated("chromium-failed-starts-threshold", "use the chromium-restart-after property instead")) if err != nil { - panic(fmt.Errorf("create deprecated flags for the chromium module: %v", err)) + panic(fmt.Errorf("create deprecated flags for the Chromium module: %v", err)) } fs.Int64("chromium-restart-after", 0, "Number of conversions after which Chromium will automatically restart. Set to 0 to disable this feature") diff --git a/pkg/modules/chromium/chromium_test.go b/pkg/modules/chromium/chromium_test.go index 6e2aeb63..7e0f9956 100644 --- a/pkg/modules/chromium/chromium_test.go +++ b/pkg/modules/chromium/chromium_test.go @@ -192,7 +192,7 @@ func TestChromium_Validate(t *testing.T) { expectError: true, }, { - scenario: "valid bin path", + scenario: "validate success", binPath: os.Getenv("CHROMIUM_BIN_PATH"), expectError: false, }, diff --git a/pkg/modules/chromium/doc.go b/pkg/modules/chromium/doc.go index 190dbd39..7b64233a 100644 --- a/pkg/modules/chromium/doc.go +++ b/pkg/modules/chromium/doc.go @@ -1,4 +1,4 @@ // Package chromium provides a module which adds routes for converting HTML -// documents to Pdf. Other modules may also retrieve the [Api] provided by this +// documents to PDF. Other modules may also retrieve the [Api] provided by this // module. package chromium diff --git a/pkg/modules/chromium/mocks.go b/pkg/modules/chromium/mocks.go index feb66f4b..1f1c15f7 100644 --- a/pkg/modules/chromium/mocks.go +++ b/pkg/modules/chromium/mocks.go @@ -13,8 +13,8 @@ type ApiMock struct { PdfMock func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error } -func (api *ApiMock) Pdf(ctx context.Context, logger *zap.Logger, URL, outputPath string, options Options) error { - return api.PdfMock(ctx, logger, URL, outputPath, options) +func (api *ApiMock) Pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error { + return api.PdfMock(ctx, logger, url, outputPath, options) } // browserMock is a mock for the [browser] interface. diff --git a/pkg/modules/chromium/routes_test.go b/pkg/modules/chromium/routes_test.go index 4e2b43d8..649485c6 100644 --- a/pkg/modules/chromium/routes_test.go +++ b/pkg/modules/chromium/routes_test.go @@ -37,7 +37,6 @@ func TestFormDataChromiumPdfOptions(t *testing.T) { "foo", }, }) - return ctx }(), expectedOptions: func() Options { @@ -45,7 +44,6 @@ func TestFormDataChromiumPdfOptions(t *testing.T) { options.ExtraHttpHeaders = map[string]string{ "User-Agent": "foo", } - return options }(), }, @@ -58,7 +56,6 @@ func TestFormDataChromiumPdfOptions(t *testing.T) { "foo", }, }) - return ctx }(), expectedOptions: DefaultOptions(), @@ -72,7 +69,6 @@ func TestFormDataChromiumPdfOptions(t *testing.T) { `{"foo":"bar"}`, }, }) - return ctx }(), expectedOptions: func() Options { @@ -80,7 +76,6 @@ func TestFormDataChromiumPdfOptions(t *testing.T) { options.ExtraHttpHeaders = map[string]string{ "foo": "bar", } - return options }(), }, @@ -93,7 +88,6 @@ func TestFormDataChromiumPdfOptions(t *testing.T) { "foo", }, }) - return ctx }(), expectedOptions: DefaultOptions(), @@ -107,13 +101,11 @@ func TestFormDataChromiumPdfOptions(t *testing.T) { "screen", }, }) - return ctx }(), expectedOptions: func() Options { options := DefaultOptions() options.EmulatedMediaType = "screen" - return options }(), }, @@ -156,7 +148,6 @@ func TestConvertUrlRoute(t *testing.T) { "", }, }) - return ctx }(), expectError: true, @@ -173,7 +164,6 @@ func TestConvertUrlRoute(t *testing.T) { "foo", }, }) - return ctx }(), api: &ApiMock{func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error { @@ -192,7 +182,6 @@ func TestConvertUrlRoute(t *testing.T) { "foo", }, }) - return ctx }(), api: &ApiMock{func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error { @@ -268,7 +257,6 @@ func TestConvertHtmlRoute(t *testing.T) { ctx.SetFiles(map[string]string{ "index.html": "/index.html", }) - return ctx }(), api: &ApiMock{func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error { @@ -285,7 +273,6 @@ func TestConvertHtmlRoute(t *testing.T) { ctx.SetFiles(map[string]string{ "index.html": "/index.html", }) - return ctx }(), api: &ApiMock{func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error { @@ -361,7 +348,6 @@ func TestConvertMarkdownRoute(t *testing.T) { ctx.SetFiles(map[string]string{ "index.html": "/index.html", }) - return ctx }(), expectError: true, @@ -707,7 +693,6 @@ func TestConvertUrl(t *testing.T) { ctx: func() *api.ContextMock { ctx := &api.ContextMock{Context: new(api.Context)} ctx.SetCancelled(true) - return ctx }(), api: &ApiMock{func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error { diff --git a/pkg/modules/libreoffice/api/api.go b/pkg/modules/libreoffice/api/api.go new file mode 100644 index 00000000..9eb64a91 --- /dev/null +++ b/pkg/modules/libreoffice/api/api.go @@ -0,0 +1,388 @@ +package api + +import ( + "context" + "errors" + "fmt" + "os" + "time" + + "github.com/alexliesenfeld/health" + flag "github.com/spf13/pflag" + "go.uber.org/multierr" + "go.uber.org/zap" + + "github.com/gotenberg/gotenberg/v7/pkg/gotenberg" + "github.com/gotenberg/gotenberg/v7/pkg/modules/api" +) + +func init() { + gotenberg.MustRegisterModule(new(Api)) +} + +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") +) + +// Api is a module which provides an [Uno] to interact with LibreOffice. +type Api struct { + autoStart bool + args libreOfficeArguments + + logger *zap.Logger + libreOffice libreOffice + supervisor gotenberg.ProcessSupervisor +} + +// Options gathers available options when converting a document to PDF. +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 +} + +// Uno is an abstraction on top of Api. +type Uno interface { + Pdf(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error + Extensions() []string +} + +// Provider is a module interface which exposes a method for creating a +// [Uno] for other modules. +// +// func (m *YourModule) Provision(ctx *gotenberg.Context) error { +// provider, _ := ctx.Module(new(libreofficeapi.Provider)) +// libreOffice, _ := provider.(api.Provider).LibreOffice() +// } +type Provider interface { + LibreOffice() (Uno, error) +} + +// Descriptor returns a [Api]'s module descriptor. +func (a *Api) Descriptor() gotenberg.ModuleDescriptor { + return gotenberg.ModuleDescriptor{ + ID: "libreoffice-api", + FlagSet: func() *flag.FlagSet { + fs := flag.NewFlagSet("api", flag.ExitOnError) + + // Deprecated flags. + fs.Duration("uno-listener-start-timeout", time.Duration(10)*time.Second, "Time limit for restarting the LibreOffice") + fs.Int("uno-listener-restart-threshold", 10, "Conversions limit after which the LibreOffice listener is restarted - 0 means no restart") + fs.Bool("unoconv-disable-listener", false, "Do not start a long-running listener - save resources in detriment of unitary performance") + + var err error + err = multierr.Append(err, fs.MarkDeprecated("uno-listener-start-timeout", "use the libreOffice-start-timeout property instead")) + err = multierr.Append(err, fs.MarkDeprecated("uno-listener-restart-threshold", "use the libreOffice-restart-after property instead")) + err = multierr.Append(err, fs.MarkDeprecated("unoconv-disable-listener", "use the libreOffice-auto-start property instead")) + + if err != nil { + panic(fmt.Errorf("create deprecated flags for the LibreOffice module: %v", err)) + } + + fs.Int64("libreoffice-restart-after", 10, "Number of conversions after which LibreOffice will automatically restart. Set to 0 to disable this feature") + fs.Bool("libreoffice-auto-start", false, "Automatically launch LibreOffice upon initialization if set to true; otherwise, LibreOffice will start at the time of the first conversion") + fs.Duration("libreoffice-start-timeout", time.Duration(10)*time.Second, "Maximum duration to wait for LibreOffice to start or restart") + + return fs + }(), + New: func() gotenberg.Module { return new(Api) }, + } +} + +// Provision sets the module properties. +func (a *Api) Provision(ctx *gotenberg.Context) error { + flags := ctx.ParsedFlags() + a.autoStart = flags.MustBool("libreoffice-auto-start") + + libreOfficeBinPath, ok := os.LookupEnv("LIBREOFFICE_BIN_PATH") + if !ok { + return errors.New("LIBREOFFICE_BIN_PATH environment variable is not set") + } + + unoBinPath, ok := os.LookupEnv("UNOCONV_BIN_PATH") + if !ok { + return errors.New("UNOCONV_BIN_PATH environment variable is not set") + } + + a.args = libreOfficeArguments{ + binPath: libreOfficeBinPath, + unoBinPath: unoBinPath, + startTimeout: flags.MustDeprecatedDuration("uno-listener-start-timeout", "libreoffice-start-timeout"), + } + + // Logger. + loggerProvider, err := ctx.Module(new(gotenberg.LoggerProvider)) + if err != nil { + return fmt.Errorf("get logger provider: %w", err) + } + logger, err := loggerProvider.(gotenberg.LoggerProvider).Logger(a) + if err != nil { + return fmt.Errorf("get logger: %w", err) + } + a.logger = logger.Named("libreoffice") + + // Process. + a.libreOffice = newLibreOfficeProcess(a.args) + a.supervisor = gotenberg.NewProcessSupervisor(a.logger, a.libreOffice, flags.MustDeprecatedInt64("uno-listener-restart-threshold", "libreoffice-restart-after")) + + return nil +} + +// Validate validates the module properties. +func (a *Api) Validate() error { + var err error + + _, statErr := os.Stat(a.args.binPath) + if os.IsNotExist(statErr) { + err = multierr.Append(err, fmt.Errorf("LibreOffice binary path does not exist: %w", statErr)) + } + + _, statErr = os.Stat(a.args.unoBinPath) + if os.IsNotExist(statErr) { + err = multierr.Append(err, fmt.Errorf("uno binary path does not exist: %w", statErr)) + } + + return err +} + +// Start does nothing if auto-start is not enabled. Otherwise, it starts a +// LibreOffice instance. +func (a *Api) Start() error { + if !a.autoStart { + return nil + } + + err := a.supervisor.Launch() + if err != nil { + return fmt.Errorf("launch supervisor: %w", err) + } + + return nil +} + +// StartupMessage returns a custom startup message. +func (a *Api) StartupMessage() string { + if !a.autoStart { + return "LibreOffice ready to start" + } + + return "LibreOffice automatically started" +} + +// Stop stops the current browser instance. +func (a *Api) Stop(ctx context.Context) error { + // Block until the context is done so that other module may gracefully stop + // before we do a shutdown. + a.logger.Debug("wait for the end of grace duration") + + <-ctx.Done() + + err := a.supervisor.Shutdown() + if err == nil { + return nil + } + + return fmt.Errorf("stop LibreOffice: %w", err) +} + +// Metrics returns the metrics. +func (a *Api) Metrics() ([]gotenberg.Metric, error) { + return []gotenberg.Metric{ + // TODO: remove deprecated. + { + Name: "unoconv_active_instances_count", + Description: "Current number of active unoconv instances - deprecated.", + Read: func() float64 { + return 1 + }, + }, + // TODO: remove deprecated. + { + Name: "libreoffice_listener_active_instances_count", + Description: "Current number of active LibreOffice listener instances - deprecated.", + Read: func() float64 { + return 1 + }, + }, + // TODO: remove deprecated. + { + Name: "unoconv_listener_active_instances_count", + Description: "Current number of active unoconv listener instances- deprecated.", + Read: func() float64 { + return 1 + }, + }, + // TODO: remove deprecated. + { + Name: "libreoffice_listener_queue_length", + Description: "Current number of processes in the LibreOffice listener queue - deprecated, prefer libreoffice_requests_queue_size.", + Read: func() float64 { + return float64(a.supervisor.ReqQueueSize()) + }, + }, + // TODO: remove deprecated. + { + Name: "unoconv_listener_queue_length", + Description: "Current number of processes in the queue - deprecated, prefer libreoffice_requests_queue_size.", + Read: func() float64 { + return float64(a.supervisor.ReqQueueSize()) + }, + }, + { + Name: "libreoffice_requests_queue_size", + Description: "Current number of LibreOffice conversion requests waiting to be treated.", + Read: func() float64 { + return float64(a.supervisor.ReqQueueSize()) + }, + }, + { + Name: "libreoffice_restarts_count", + Description: "Current number of LibreOffice restarts.", + Read: func() float64 { + return float64(a.supervisor.RestartsCount()) + }, + }, + }, nil +} + +// Checks adds a health check that verifies if LibreOffice is healthy. +func (a *Api) Checks() ([]health.CheckerOption, error) { + return []health.CheckerOption{ + health.WithCheck(health.Check{ + Name: "api", + Check: func(_ context.Context) error { + if a.supervisor.Healthy() { + return nil + } + + return errors.New("LibreOffice unhealthy") + }, + }), + }, nil +} + +// LibreOffice returns a [Uno] for interacting with LibreOffice. +func (a *Api) LibreOffice() (Uno, error) { + return a, nil +} + +// Pdf converts a document to PDF. +func (a *Api) Pdf(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error { + return a.supervisor.Run(ctx, logger, func() error { + return a.libreOffice.pdf(ctx, logger, inputPath, outputPath, options) + }) +} + +// Extensions returns the file extensions available for conversions. +// FIXME: don't care, take all on the route level? +func (a *Api) Extensions() []string { + return []string{ + ".bib", + ".doc", + ".xml", + ".docx", + ".fodt", + ".html", + ".ltx", + ".txt", + ".odt", + ".ott", + ".pdb", + ".pdf", + ".psw", + ".rtf", + ".sdw", + ".stw", + ".sxw", + ".uot", + ".vor", + ".wps", + ".epub", + ".png", + ".bmp", + ".emf", + ".eps", + ".fodg", + ".gif", + ".jpg", + ".jpeg", + ".met", + ".odd", + ".otg", + ".pbm", + ".pct", + ".pgm", + ".ppm", + ".ras", + ".std", + ".svg", + ".svm", + ".swf", + ".sxd", + ".sxw", + ".tif", + ".tiff", + ".xhtml", + ".xpm", + ".odp", + ".fodp", + ".potm", + ".pot", + ".pptx", + ".pps", + ".ppt", + ".pwp", + ".sda", + ".sdd", + ".sti", + ".sxi", + ".uop", + ".wmf", + ".csv", + ".dbf", + ".dif", + ".fods", + ".ods", + ".ots", + ".pxl", + ".sdc", + ".slk", + ".stc", + ".sxc", + ".uos", + ".xls", + ".xlt", + ".xlsx", + ".odg", + ".dotx", + ".xltx", + } +} + +// Interface guards. +var ( + _ gotenberg.Module = (*Api)(nil) + _ gotenberg.Provisioner = (*Api)(nil) + _ gotenberg.Validator = (*Api)(nil) + _ gotenberg.App = (*Api)(nil) + _ gotenberg.MetricsProvider = (*Api)(nil) + _ api.HealthChecker = (*Api)(nil) + _ Uno = (*Api)(nil) + _ Provider = (*Api)(nil) +) diff --git a/pkg/modules/libreoffice/api/api_test.go b/pkg/modules/libreoffice/api/api_test.go new file mode 100644 index 00000000..7e126aee --- /dev/null +++ b/pkg/modules/libreoffice/api/api_test.go @@ -0,0 +1,428 @@ +package api + +import ( + "context" + "errors" + "os" + "reflect" + "testing" + "time" + + "github.com/alexliesenfeld/health" + "go.uber.org/zap" + + "github.com/gotenberg/gotenberg/v7/pkg/gotenberg" +) + +func TestApi_Descriptor(t *testing.T) { + descriptor := new(Api).Descriptor() + + actual := reflect.TypeOf(descriptor.New()) + expect := reflect.TypeOf(new(Api)) + + if actual != expect { + t.Errorf("expected '%s' but got '%s'", expect, actual) + } +} + +func TestApi_Provision(t *testing.T) { + for _, tc := range []struct { + scenario string + ctx *gotenberg.Context + expectError bool + }{ + { + scenario: "no logger provider", + ctx: func() *gotenberg.Context { + return gotenberg.NewContext( + gotenberg.ParsedFlags{ + FlagSet: new(Api).Descriptor().FlagSet, + }, + []gotenberg.ModuleDescriptor{}, + ) + }(), + expectError: true, + }, + { + scenario: "no logger from logger provider", + ctx: func() *gotenberg.Context { + mod := &struct { + gotenberg.ModuleMock + gotenberg.LoggerProviderMock + }{} + mod.DescriptorMock = func() gotenberg.ModuleDescriptor { + return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }} + } + mod.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) { + return nil, errors.New("foo") + } + + return gotenberg.NewContext( + gotenberg.ParsedFlags{ + FlagSet: new(Api).Descriptor().FlagSet, + }, + []gotenberg.ModuleDescriptor{ + mod.Descriptor(), + }, + ) + }(), + expectError: true, + }, + { + scenario: "provision success", + ctx: func() *gotenberg.Context { + mod := &struct { + gotenberg.ModuleMock + gotenberg.LoggerProviderMock + }{} + mod.DescriptorMock = func() gotenberg.ModuleDescriptor { + return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }} + } + mod.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) { + return zap.NewNop(), nil + } + + return gotenberg.NewContext( + gotenberg.ParsedFlags{ + FlagSet: new(Api).Descriptor().FlagSet, + }, + []gotenberg.ModuleDescriptor{ + mod.Descriptor(), + }, + ) + }(), + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + a := new(Api) + err := a.Provision(tc.ctx) + + if !tc.expectError && err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + if tc.expectError && err == nil { + t.Fatal("expected error but got none") + } + }) + } +} + +func TestApi_Validate(t *testing.T) { + for _, tc := range []struct { + scenario string + binPath string + unoBinPath string + expectError bool + }{ + { + scenario: "empty LibreOffice bin path", + binPath: "", + unoBinPath: os.Getenv("UNOCONV_BIN_PATH"), + expectError: true, + }, + { + scenario: "LibreOffice bin path does not exist", + binPath: "/foo", + unoBinPath: os.Getenv("UNOCONV_BIN_PATH"), + expectError: true, + }, + { + scenario: "empty uno bin path", + binPath: os.Getenv("CHROMIUM_BIN_PATH"), + unoBinPath: "", + expectError: true, + }, + { + scenario: "uno bin path does not exist", + binPath: os.Getenv("CHROMIUM_BIN_PATH"), + unoBinPath: "/foo", + expectError: true, + }, + { + scenario: "validate success", + binPath: os.Getenv("CHROMIUM_BIN_PATH"), + unoBinPath: os.Getenv("UNOCONV_BIN_PATH"), + expectError: false, + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + a := new(Api) + a.args = libreOfficeArguments{ + binPath: tc.binPath, + unoBinPath: tc.unoBinPath, + } + err := a.Validate() + + if !tc.expectError && err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + if tc.expectError && err == nil { + t.Fatal("expected error but got none") + } + }) + } +} + +func TestApi_Start(t *testing.T) { + for _, tc := range []struct { + scenario string + autoStart bool + supervisor *gotenberg.ProcessSupervisorMock + expectError bool + }{ + { + scenario: "no auto-start", + autoStart: false, + expectError: false, + }, + { + scenario: "auto-start success", + autoStart: true, + supervisor: &gotenberg.ProcessSupervisorMock{LaunchMock: func() error { + return nil + }}, + expectError: false, + }, + { + scenario: "auto-start failed", + autoStart: true, + supervisor: &gotenberg.ProcessSupervisorMock{LaunchMock: func() error { + return errors.New("foo") + }}, + expectError: true, + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + a := new(Api) + a.autoStart = tc.autoStart + a.supervisor = tc.supervisor + + err := a.Start() + + if !tc.expectError && err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + if tc.expectError && err == nil { + t.Fatal("expected error but got none") + } + }) + } +} + +func TestApi_StartupMessage(t *testing.T) { + a := new(Api) + + a.autoStart = true + autoStartMsg := a.StartupMessage() + + a.autoStart = false + noAutoStartMsg := a.StartupMessage() + + if autoStartMsg == noAutoStartMsg { + t.Errorf("expected differrent startup messages based on auto start, but got '%s'", autoStartMsg) + } +} + +func TestApi_Stop(t *testing.T) { + for _, tc := range []struct { + scenario string + supervisor *gotenberg.ProcessSupervisorMock + expectError bool + }{ + { + scenario: "stop success", + supervisor: &gotenberg.ProcessSupervisorMock{ShutdownMock: func() error { + return nil + }}, + expectError: false, + }, + { + scenario: "stop failed", + supervisor: &gotenberg.ProcessSupervisorMock{ShutdownMock: func() error { + return errors.New("foo") + }}, + expectError: true, + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + a := new(Api) + a.logger = zap.NewNop() + a.supervisor = tc.supervisor + + ctx, cancel := context.WithTimeout(context.Background(), 0*time.Second) + cancel() + + err := a.Stop(ctx) + + if !tc.expectError && err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + if tc.expectError && err == nil { + t.Fatal("expected error but got none") + } + }) + } +} + +func TestApi_Metrics(t *testing.T) { + a := new(Api) + a.supervisor = &gotenberg.ProcessSupervisorMock{ + ReqQueueSizeMock: func() int64 { + return 10 + }, + RestartsCountMock: func() int64 { + return 0 + }, + } + + metrics, err := a.Metrics() + if err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + if len(metrics) != 7 { + t.Fatalf("expected %d metrics, but got %d", 7, len(metrics)) + } + + actual := metrics[0].Read() + if actual != float64(1) { + t.Errorf("expected %f for unoconv_active_instances_count, but got %f", float64(1), actual) + } + + actual = metrics[1].Read() + if actual != float64(1) { + t.Errorf("expected %f for libreoffice_listener_active_instances_count, but got %f", float64(1), actual) + } + + actual = metrics[2].Read() + if actual != float64(1) { + t.Errorf("expected %f for unoconv_listener_active_instances_count, but got %f", float64(1), actual) + } + + actual = metrics[3].Read() + if actual != float64(10) { + t.Errorf("expected %f for libreoffice_listener_queue_length, but got %f", float64(10), actual) + } + + actual = metrics[4].Read() + if actual != float64(10) { + t.Errorf("expected %f for unoconv_listener_queue_length, but got %f", float64(10), actual) + } + + actual = metrics[5].Read() + if actual != float64(10) { + t.Errorf("expected %f for libreoffice_requests_queue_size, but got %f", float64(10), actual) + } + + actual = metrics[6].Read() + if actual != float64(0) { + t.Errorf("expected %f for libreoffice_restarts_count, but got %f", float64(0), actual) + } +} + +func TestApi_Checks(t *testing.T) { + for _, tc := range []struct { + scenario string + supervisor gotenberg.ProcessSupervisor + expectAvailabilityStatus health.AvailabilityStatus + }{ + { + scenario: "healthy module", + supervisor: &gotenberg.ProcessSupervisorMock{HealthyMock: func() bool { + return true + }}, + expectAvailabilityStatus: health.StatusUp, + }, + { + scenario: "unhealthy module", + supervisor: &gotenberg.ProcessSupervisorMock{HealthyMock: func() bool { + return false + }}, + expectAvailabilityStatus: health.StatusDown, + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + a := new(Api) + a.supervisor = tc.supervisor + + checks, err := a.Checks() + if err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + checker := health.NewChecker(checks...) + result := checker.Check(context.Background()) + + if result.Status != tc.expectAvailabilityStatus { + t.Errorf("expected '%s' as availability status, but got '%s'", tc.expectAvailabilityStatus, result.Status) + } + }) + } +} + +func TestApi_LibreOffice(t *testing.T) { + a := new(Api) + + _, err := a.LibreOffice() + if err != nil { + t.Errorf("expected no error but got: %v", err) + } +} + +func TestApi_Pdf(t *testing.T) { + for _, tc := range []struct { + scenario string + supervisor gotenberg.ProcessSupervisor + libreOffice libreOffice + expectError bool + }{ + { + scenario: "PDF task success", + libreOffice: &libreOfficeMock{pdfMock: func(ctx context.Context, logger *zap.Logger, input, outputPath string, options Options) error { + return nil + }}, + expectError: false, + }, + { + scenario: "PDF task error", + libreOffice: &libreOfficeMock{pdfMock: func(ctx context.Context, logger *zap.Logger, input, outputPath string, options Options) error { + return errors.New("PDF task error") + }}, + expectError: true, + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + a := new(Api) + a.supervisor = &gotenberg.ProcessSupervisorMock{RunMock: func(ctx context.Context, logger *zap.Logger, task func() error) error { + return task() + }} + a.libreOffice = tc.libreOffice + + err := a.Pdf(context.Background(), zap.NewNop(), "", "", Options{}) + + if !tc.expectError && err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + if tc.expectError && err == nil { + t.Fatal("expected error but got none") + } + }) + } +} + +func TestApi_Extensions(t *testing.T) { + a := new(Api) + extensions := a.Extensions() + + actual := len(extensions) + expect := 79 + + if actual != expect { + t.Errorf("expected %d extensions, but got %d", expect, actual) + } +} diff --git a/pkg/modules/libreoffice/api/doc.go b/pkg/modules/libreoffice/api/doc.go new file mode 100644 index 00000000..a09b3f5d --- /dev/null +++ b/pkg/modules/libreoffice/api/doc.go @@ -0,0 +1,3 @@ +// Package api provides a module which manages a LibreOffice instance and +// interacts with it via the UNO (Universal Network Objects) API. +package api diff --git a/pkg/modules/libreoffice/uno/freeport.go b/pkg/modules/libreoffice/api/freeport.go similarity index 98% rename from pkg/modules/libreoffice/uno/freeport.go rename to pkg/modules/libreoffice/api/freeport.go index 1af4984c..0a3e71d8 100644 --- a/pkg/modules/libreoffice/uno/freeport.go +++ b/pkg/modules/libreoffice/api/freeport.go @@ -1,4 +1,4 @@ -package uno +package api import ( "fmt" diff --git a/pkg/modules/libreoffice/api/libreoffice.go b/pkg/modules/libreoffice/api/libreoffice.go new file mode 100644 index 00000000..72f13d99 --- /dev/null +++ b/pkg/modules/libreoffice/api/libreoffice.go @@ -0,0 +1,320 @@ +package api + +import ( + "context" + "errors" + "fmt" + "net" + "os" + "sync" + "sync/atomic" + "time" + + "go.uber.org/zap" + + "github.com/gotenberg/gotenberg/v7/pkg/gotenberg" +) + +type libreOffice interface { + gotenberg.Process + pdf(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error +} + +type libreOfficeArguments struct { + binPath string + unoBinPath string + startTimeout time.Duration +} + +type libreOfficeProcess struct { + socketPort int + userProfileDirPath string + cmd gotenberg.Cmd + cfgMu sync.RWMutex + isStarted atomic.Bool + + arguments libreOfficeArguments + fs *gotenberg.FileSystem +} + +func newLibreOfficeProcess(arguments libreOfficeArguments) libreOffice { + p := &libreOfficeProcess{ + arguments: arguments, + fs: gotenberg.NewFileSystem(), + } + p.isStarted.Store(false) + + return p +} + +func (p *libreOfficeProcess) Start(logger *zap.Logger) error { + if p.isStarted.Load() { + return errors.New("LibreOffice is already started") + } + + port, err := freePort(logger) + if err != nil { + return fmt.Errorf("get free port: %w", err) + } + + userProfileDirPath := p.fs.NewDirPath() + args := []string{ + "--headless", + "--invisible", + "--nocrashreport", + "--nodefault", + "--nologo", + "--nofirststartwizard", + "--norestore", + fmt.Sprintf("-env:UserInstallation=file://%s", userProfileDirPath), + fmt.Sprintf("--accept=socket,host=127.0.0.1,port=%d,tcpNoDelay=1;urp;StarOffice.ComponentContext", port), + } + + ctx, cancel := context.WithTimeout(context.Background(), p.arguments.startTimeout) + defer cancel() + + cmd, err := gotenberg.CommandContext(ctx, logger, p.arguments.binPath, args...) + if err != nil { + return fmt.Errorf("create LibreOffice command: %w", err) + } + + // For whatever reason, LibreOffice requires a first start before being + // able to run as a daemon. + exitCode, err := cmd.Exec() + if err != nil && exitCode != 81 { + return fmt.Errorf("execute LibreOffice: %w", err) + } + + logger.Debug("got exit code 81, e.g., LibreOffice first start") + + // Second start (daemon). + cmd = gotenberg.Command(logger, p.arguments.binPath, args...) + + err = cmd.Start() + if err != nil { + return fmt.Errorf("start LibreOffice: %w", err) + } + + waitChan := make(chan error, 1) + + go func() { + // By waiting the process, we avoid the creation of a zombie process + // and make sure we catch an early exit if any. + waitChan <- cmd.Wait() + }() + + connChan := make(chan error, 1) + + go func() { + // As the LibreOffice socket may take some time to be available, we + // have to ensure that it is indeed accepting connections. + for { + if ctx.Err() != nil { + connChan <- ctx.Err() + break + } + + conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), time.Duration(1)*time.Second) + if err != nil { + continue + } + + connChan <- nil + err = conn.Close() + if err != nil { + logger.Debug(fmt.Sprintf("close connection after health checking the LibreOffice: %v", err)) + } + + break + } + }() + + var success bool + + defer func() { + if success { + p.cfgMu.Lock() + defer p.cfgMu.Unlock() + + p.socketPort = port + p.userProfileDirPath = userProfileDirPath + p.cmd = cmd + p.isStarted.Store(true) + + return + } + + // Let's make sure the process is killed. + err = cmd.Kill() + if err != nil { + logger.Debug(fmt.Sprintf("kill LibreOffice process: %v", err)) + } + + // And the user profile directory is deleted. + err = os.RemoveAll(userProfileDirPath) + if err != nil { + logger.Error(fmt.Sprintf("remove LibreOffice's user profile directory: %v", err)) + } + + logger.Debug(fmt.Sprintf("'%s' LibreOffice's user profile directory removed", userProfileDirPath)) + }() + + logger.Debug("waiting for the LibreOffice socket to be available...") + + for { + select { + case err = <-connChan: + if err != nil { + return fmt.Errorf("LibreOffice socket not available: %w", err) + } + + logger.Debug("LibreOffice socket available") + success = true + + return nil + case err = <-waitChan: + return fmt.Errorf("LibreOffice process exited: %w", err) + } + } +} + +func (p *libreOfficeProcess) Stop(logger *zap.Logger) error { + if !p.isStarted.Load() { + // No big deal? Like calling cancel twice. + return nil + } + + // Always remove the user profile directory created by LibreOffice. + copyUserProfileDirPath := p.userProfileDirPath + defer func(userProfileDirPath string) { + go func() { + err := os.RemoveAll(userProfileDirPath) + if err != nil { + logger.Error(fmt.Sprintf("remove LibreOffice's user profile directory: %v", err)) + } + + logger.Debug(fmt.Sprintf("'%s' LibreOffice's user profile directory removed", userProfileDirPath)) + + // Also remove LibreOffice specific files in the temporary directory. + err = gotenberg.GarbageCollect(logger, os.TempDir(), []string{"OSL_PIPE", ".tmp"}) + if err != nil { + logger.Error(err.Error()) + } + }() + }(copyUserProfileDirPath) + + p.cfgMu.Lock() + defer p.cfgMu.Unlock() + + err := p.cmd.Kill() + if err != nil { + return fmt.Errorf("kill LibreOffice process: %w", err) + } + + p.socketPort = 0 + p.userProfileDirPath = "" + p.cmd = gotenberg.Cmd{} // FIXME: pointer. + p.isStarted.Store(false) + + return nil +} + +func (p *libreOfficeProcess) Healthy(logger *zap.Logger) bool { + // Good to know: the supervisor does not call this method if no first start + // or if the process is restarting. + + if !p.isStarted.Load() { + // Non-started browser but not restarting? + return false + } + + p.cfgMu.RLock() + defer p.cfgMu.RUnlock() + + conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", p.socketPort), time.Duration(10)*time.Second) + if err == nil { + err = conn.Close() + if err != nil { + logger.Debug(fmt.Sprintf("close connection after health checking LibreOffice: %v", err)) + } + + return true + } + + return false +} + +func (p *libreOfficeProcess) pdf(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error { + if !p.isStarted.Load() { + return errors.New("LibreOffice not started, cannot handle PDF conversion") + } + + args := []string{ + "--no-launch", + "--format", + "pdf", + } + + args = append(args, "--port", fmt.Sprintf("%d", p.socketPort)) + + checkedEntry := logger.Check(zap.DebugLevel, "check for debug level before setting high verbosity") + if checkedEntry != nil { + args = append(args, "-vvv") + } + + if options.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, p.arguments.unoBinPath, args...) + if err != nil { + return fmt.Errorf("create uno command: %w", err) + } + + logger.Debug(fmt.Sprintf("print to PDF with: %+v", options)) + + exitCode, err := cmd.Exec() + if err == nil { + return nil + } + + // LibreOffice's errors are not explicit. + // 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. 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("convert to PDF: %w", err) +} + +// Interface guards. +var ( + _ gotenberg.Process = (*libreOfficeProcess)(nil) + _ libreOffice = (*libreOfficeProcess)(nil) +) diff --git a/pkg/modules/libreoffice/api/libreoffice_test.go b/pkg/modules/libreoffice/api/libreoffice_test.go new file mode 100644 index 00000000..63ae7670 --- /dev/null +++ b/pkg/modules/libreoffice/api/libreoffice_test.go @@ -0,0 +1,537 @@ +package api + +import ( + "context" + "errors" + "fmt" + "os" + "testing" + "time" + + "github.com/google/uuid" + "go.uber.org/zap" + + "github.com/gotenberg/gotenberg/v7/pkg/gotenberg" +) + +func TestLibreOfficeProcess_Start(t *testing.T) { + for _, tc := range []struct { + scenario string + libreOffice libreOffice + expectError bool + cleanup bool + }{ + { + scenario: "successful start", + libreOffice: newLibreOfficeProcess( + libreOfficeArguments{ + binPath: os.Getenv("LIBREOFFICE_BIN_PATH"), + unoBinPath: os.Getenv("UNOCONV_BIN_PATH"), + startTimeout: 5 * time.Second, + }, + ), + expectError: false, + cleanup: true, + }, + { + scenario: "LibreOffice already started", + libreOffice: func() libreOffice { + p := new(libreOfficeProcess) + p.isStarted.Store(true) + return p + }(), + expectError: true, + cleanup: false, + }, + { + scenario: "non-exit code 81 on first start", + libreOffice: newLibreOfficeProcess( + libreOfficeArguments{ + binPath: "foo", + unoBinPath: os.Getenv("UNOCONV_BIN_PATH"), + startTimeout: 5 * time.Second, + }, + ), + expectError: true, + cleanup: false, + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + logger := zap.NewNop() + err := tc.libreOffice.Start(logger) + + if tc.cleanup { + defer func(p libreOffice, logger *zap.Logger) { + err = p.Stop(logger) + if err != nil { + t.Fatalf("expected no error while cleaning up, but got: %v", err) + } + }(tc.libreOffice, logger) + } + + if !tc.expectError && err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + if tc.expectError && err == nil { + t.Fatal("expected error but got none") + } + }) + } +} + +func TestLibreOfficeProcess_Stop(t *testing.T) { + for _, tc := range []struct { + scenario string + libreOffice libreOffice + setup func(libreOffice libreOffice, logger *zap.Logger) error + expectError bool + }{ + { + scenario: "successful stop", + libreOffice: newLibreOfficeProcess( + libreOfficeArguments{ + binPath: os.Getenv("LIBREOFFICE_BIN_PATH"), + unoBinPath: os.Getenv("UNOCONV_BIN_PATH"), + startTimeout: 5 * time.Second, + }, + ), + setup: func(p libreOffice, logger *zap.Logger) error { + return p.Start(logger) + }, + expectError: false, + }, + { + scenario: "LibreOffice already stopped", + libreOffice: func() libreOffice { + p := new(libreOfficeProcess) + p.isStarted.Store(false) + return p + }(), + expectError: false, + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + logger := zap.NewNop() + + if tc.setup != nil { + err := tc.setup(tc.libreOffice, logger) + if err != nil { + t.Fatalf("setup error: %v", err) + } + } + + err := tc.libreOffice.Stop(logger) + + if !tc.expectError && err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + if tc.expectError && err == nil { + t.Fatal("expected error but got none") + } + }) + } +} + +func TestLibreOfficeProcess_Healthy(t *testing.T) { + for _, tc := range []struct { + scenario string + libreOffice libreOffice + setup func(libreOffice libreOffice, logger *zap.Logger) error + expectHealthy bool + cleanup bool + }{ + { + scenario: "healthy LibreOffice", + libreOffice: newLibreOfficeProcess( + libreOfficeArguments{ + binPath: os.Getenv("LIBREOFFICE_BIN_PATH"), + unoBinPath: os.Getenv("UNOCONV_BIN_PATH"), + startTimeout: 5 * time.Second, + }, + ), + setup: func(p libreOffice, logger *zap.Logger) error { + return p.Start(logger) + }, + expectHealthy: true, + cleanup: true, + }, + { + scenario: "LibreOffice not started", + libreOffice: func() libreOffice { + p := new(libreOfficeProcess) + p.isStarted.Store(false) + return p + }(), + expectHealthy: false, + cleanup: false, + }, + { + scenario: "unhealthy LibreOffice", + libreOffice: func() libreOffice { + p := new(libreOfficeProcess) + p.isStarted.Store(true) + p.socketPort = 12345 + return p + }(), + expectHealthy: false, + cleanup: false, + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + logger := zap.NewNop() + + if tc.setup != nil { + err := tc.setup(tc.libreOffice, logger) + if err != nil { + t.Fatalf("setup error: %v", err) + } + } + + if tc.cleanup { + defer func(p libreOffice, logger *zap.Logger) { + err := p.Stop(logger) + if err != nil { + t.Fatalf("expected no error while cleaning up, but got: %v", err) + } + }(tc.libreOffice, logger) + } + + healthy := tc.libreOffice.Healthy(logger) + + if !tc.expectHealthy && healthy { + t.Fatal("expected unhealthy LibreOffice but got an healthy one") + } + + if tc.expectHealthy && !healthy { + t.Fatal("expected a healthy LibreOffice but got an unhealthy one") + } + }) + } +} + +func TestLibreOfficeProcess_pdf(t *testing.T) { + for _, tc := range []struct { + scenario string + libreOffice libreOffice + fs *gotenberg.FileSystem + options Options + cancelledCtx bool + start bool + expectError bool + expectedError error + }{ + { + scenario: "LibreOffice not started", + libreOffice: func() libreOffice { + p := new(libreOfficeProcess) + p.isStarted.Store(false) + return p + }(), + fs: gotenberg.NewFileSystem(), + cancelledCtx: false, + start: false, + expectError: true, + }, + { + scenario: "ErrInvalidPdfFormat", + libreOffice: func() libreOffice { + p := new(libreOfficeProcess) + p.socketPort = 12345 + p.isStarted.Store(true) + return p + }(), + fs: gotenberg.NewFileSystem(), + options: Options{PdfFormat: "foo"}, + cancelledCtx: false, + start: false, + expectError: true, + expectedError: ErrInvalidPdfFormat, + }, + { + scenario: "ErrMalformedPageRanges", + libreOffice: newLibreOfficeProcess( + libreOfficeArguments{ + binPath: os.Getenv("LIBREOFFICE_BIN_PATH"), + unoBinPath: os.Getenv("UNOCONV_BIN_PATH"), + startTimeout: 5 * time.Second, + }, + ), + options: Options{PageRanges: "foo"}, + fs: func() *gotenberg.FileSystem { + fs := gotenberg.NewFileSystem() + + err := os.MkdirAll(fs.WorkingDirPath(), 0o755) + if err != nil { + t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) + } + + err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("ErrMalformedPageRanges"), 0o755) + if err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + return fs + }(), + cancelledCtx: false, + start: true, + expectError: true, + expectedError: ErrMalformedPageRanges, + }, + { + scenario: "context done", + libreOffice: newLibreOfficeProcess( + libreOfficeArguments{ + binPath: os.Getenv("LIBREOFFICE_BIN_PATH"), + unoBinPath: os.Getenv("UNOCONV_BIN_PATH"), + startTimeout: 5 * time.Second, + }, + ), + fs: func() *gotenberg.FileSystem { + fs := gotenberg.NewFileSystem() + + err := os.MkdirAll(fs.WorkingDirPath(), 0o755) + if err != nil { + t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) + } + + err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Context done"), 0o755) + if err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + return fs + }(), + cancelledCtx: true, + start: true, + expectError: true, + }, + { + scenario: "success (default options)", + libreOffice: newLibreOfficeProcess( + libreOfficeArguments{ + binPath: os.Getenv("LIBREOFFICE_BIN_PATH"), + unoBinPath: os.Getenv("UNOCONV_BIN_PATH"), + startTimeout: 5 * time.Second, + }, + ), + fs: func() *gotenberg.FileSystem { + fs := gotenberg.NewFileSystem() + + err := os.MkdirAll(fs.WorkingDirPath(), 0o755) + if err != nil { + t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) + } + + err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Success"), 0o755) + if err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + return fs + }(), + cancelledCtx: false, + start: true, + expectError: false, + }, + { + scenario: "success (landscape)", + libreOffice: newLibreOfficeProcess( + libreOfficeArguments{ + binPath: os.Getenv("LIBREOFFICE_BIN_PATH"), + unoBinPath: os.Getenv("UNOCONV_BIN_PATH"), + startTimeout: 5 * time.Second, + }, + ), + fs: func() *gotenberg.FileSystem { + fs := gotenberg.NewFileSystem() + + err := os.MkdirAll(fs.WorkingDirPath(), 0o755) + if err != nil { + t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) + } + + err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Landscape"), 0o755) + if err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + return fs + }(), + options: Options{Landscape: true}, + cancelledCtx: false, + start: true, + expectError: false, + }, + { + scenario: "success (page ranges)", + libreOffice: newLibreOfficeProcess( + libreOfficeArguments{ + binPath: os.Getenv("LIBREOFFICE_BIN_PATH"), + unoBinPath: os.Getenv("UNOCONV_BIN_PATH"), + startTimeout: 5 * time.Second, + }, + ), + fs: func() *gotenberg.FileSystem { + fs := gotenberg.NewFileSystem() + + err := os.MkdirAll(fs.WorkingDirPath(), 0o755) + if err != nil { + t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) + } + + err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Landscape"), 0o755) + if err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + return fs + }(), + options: Options{PageRanges: "1-1"}, + cancelledCtx: false, + start: true, + expectError: false, + }, + { + scenario: "success (PDF/A-1a)", + libreOffice: newLibreOfficeProcess( + libreOfficeArguments{ + binPath: os.Getenv("LIBREOFFICE_BIN_PATH"), + unoBinPath: os.Getenv("UNOCONV_BIN_PATH"), + startTimeout: 5 * time.Second, + }, + ), + fs: func() *gotenberg.FileSystem { + fs := gotenberg.NewFileSystem() + + err := os.MkdirAll(fs.WorkingDirPath(), 0o755) + if err != nil { + t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) + } + + err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Landscape"), 0o755) + if err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + return fs + }(), + options: Options{PdfFormat: gotenberg.FormatPDFA1a}, + cancelledCtx: false, + start: true, + expectError: false, + }, + { + scenario: "success (PDF/A-2b)", + libreOffice: newLibreOfficeProcess( + libreOfficeArguments{ + binPath: os.Getenv("LIBREOFFICE_BIN_PATH"), + unoBinPath: os.Getenv("UNOCONV_BIN_PATH"), + startTimeout: 5 * time.Second, + }, + ), + fs: func() *gotenberg.FileSystem { + fs := gotenberg.NewFileSystem() + + err := os.MkdirAll(fs.WorkingDirPath(), 0o755) + if err != nil { + t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) + } + + err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Landscape"), 0o755) + if err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + return fs + }(), + options: Options{PdfFormat: gotenberg.FormatPDFA2b}, + cancelledCtx: false, + start: true, + expectError: false, + }, + { + scenario: "success (PDF/A-3b)", + libreOffice: newLibreOfficeProcess( + libreOfficeArguments{ + binPath: os.Getenv("LIBREOFFICE_BIN_PATH"), + unoBinPath: os.Getenv("UNOCONV_BIN_PATH"), + startTimeout: 5 * time.Second, + }, + ), + fs: func() *gotenberg.FileSystem { + fs := gotenberg.NewFileSystem() + + err := os.MkdirAll(fs.WorkingDirPath(), 0o755) + if err != nil { + t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) + } + + err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Landscape"), 0o755) + if err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + return fs + }(), + options: Options{PdfFormat: gotenberg.FormatPDFA3b}, + cancelledCtx: false, + start: true, + expectError: false, + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + // Force the debug level. + logger := zap.NewExample() + + defer func() { + err := os.RemoveAll(tc.fs.WorkingDirPath()) + if err != nil { + t.Fatalf("expected no error while cleaning up, but got: %v", err) + } + }() + + if tc.start { + err := tc.libreOffice.Start(logger) + if err != nil { + t.Fatalf("setup error: %v", err) + } + + defer func(p libreOffice, logger *zap.Logger) { + err = p.Stop(logger) + if err != nil { + t.Fatalf("expected no error while cleaning up, but got: %v", err) + } + }(tc.libreOffice, logger) + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(5)*time.Second) + defer cancel() + + if tc.cancelledCtx { + cancel() + } + + err := tc.libreOffice.pdf( + ctx, + logger, + fmt.Sprintf("file://%s/document.txt", tc.fs.WorkingDirPath()), + fmt.Sprintf("%s/%s.pdf", tc.fs.WorkingDirPath(), uuid.NewString()), + tc.options, + ) + + if !tc.expectError && err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + if tc.expectError && err == nil { + t.Fatal("expected error but got none") + } + + if tc.expectedError != nil && !errors.Is(err, tc.expectedError) { + t.Fatalf("expected error %v but got: %v", tc.expectedError, err) + } + }) + } +} diff --git a/pkg/modules/libreoffice/api/mocks.go b/pkg/modules/libreoffice/api/mocks.go new file mode 100644 index 00000000..068ac6c9 --- /dev/null +++ b/pkg/modules/libreoffice/api/mocks.go @@ -0,0 +1,49 @@ +package api + +import ( + "context" + + "go.uber.org/zap" + + "github.com/gotenberg/gotenberg/v7/pkg/gotenberg" +) + +// ApiMock is a mock for the [Uno] interface. +type ApiMock struct { + PdfMock func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error + ExtensionsMock func() []string +} + +func (api *ApiMock) Pdf(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error { + return api.PdfMock(ctx, logger, inputPath, outputPath, options) +} + +func (api *ApiMock) Extensions() []string { + return api.ExtensionsMock() +} + +// ProviderMock is a mock for the [Provider] interface. +type ProviderMock struct { + LibreOfficeMock func() (Uno, error) +} + +func (provider *ProviderMock) LibreOffice() (Uno, error) { + return provider.LibreOfficeMock() +} + +// libreOfficeMock is a mock for the [libreOffice] interface. +type libreOfficeMock struct { + gotenberg.ProcessMock + pdfMock func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error +} + +func (b *libreOfficeMock) pdf(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error { + return b.pdfMock(ctx, logger, inputPath, outputPath, options) +} + +// Interface guards. +var ( + _ Uno = (*ApiMock)(nil) + _ Provider = (*ProviderMock)(nil) + _ libreOffice = (*libreOfficeMock)(nil) +) diff --git a/pkg/modules/libreoffice/api/mocks_test.go b/pkg/modules/libreoffice/api/mocks_test.go new file mode 100644 index 00000000..2c6f2e4a --- /dev/null +++ b/pkg/modules/libreoffice/api/mocks_test.go @@ -0,0 +1,55 @@ +package api + +import ( + "context" + "testing" + + "go.uber.org/zap" +) + +func TestApiMock(t *testing.T) { + mock := &ApiMock{ + PdfMock: func(ctx context.Context, logger *zap.Logger, input, outputPath string, options Options) error { + return nil + }, + ExtensionsMock: func() []string { + return nil + }, + } + + err := mock.Pdf(context.Background(), zap.NewNop(), "", "", Options{}) + if err != nil { + t.Errorf("expected no error from ApiMock.Pdf, but got: %v", err) + } + + ext := mock.Extensions() + if ext != nil { + t.Errorf("expected nil result from ApiMock.Extensions, but got: %v", ext) + } +} + +func TestProviderMock(t *testing.T) { + mock := &ProviderMock{ + LibreOfficeMock: func() (Uno, error) { + return nil, nil + }, + } + + _, err := mock.LibreOffice() + if err != nil { + t.Errorf("expected no error from ProviderMock.LibreOffice, but got: %v", err) + } +} + +func TestLibreOfficeMock(t *testing.T) { + mock := &libreOfficeMock{ + pdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error { + return nil + }, + } + + err := mock.pdf(context.Background(), zap.NewNop(), "", "", Options{}) + if err != nil { + t.Errorf("expected no error from libreOfficeMock.pdf, but got: %v", err) + } +} diff --git a/pkg/modules/libreoffice/libreoffice.go b/pkg/modules/libreoffice/libreoffice.go index 711987e5..c064e6f2 100644 --- a/pkg/modules/libreoffice/libreoffice.go +++ b/pkg/modules/libreoffice/libreoffice.go @@ -7,23 +7,23 @@ import ( "github.com/gotenberg/gotenberg/v7/pkg/gotenberg" "github.com/gotenberg/gotenberg/v7/pkg/modules/api" - "github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/uno" + libeofficeapi "github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/api" ) func init() { - gotenberg.MustRegisterModule(LibreOffice{}) + gotenberg.MustRegisterModule(new(LibreOffice)) } // LibreOffice is a module which provides a route for converting documents to // PDF with LibreOffice. type LibreOffice struct { - unoAPI uno.API + api libeofficeapi.Uno engine gotenberg.PDFEngine disableRoutes bool } -// Descriptor returns a LibreOffice's module descriptor. -func (LibreOffice) Descriptor() gotenberg.ModuleDescriptor { +// Descriptor returns a [LibreOffice]'s module descriptor. +func (mod *LibreOffice) Descriptor() gotenberg.ModuleDescriptor { return gotenberg.ModuleDescriptor{ ID: "libreoffice", FlagSet: func() *flag.FlagSet { @@ -41,17 +41,17 @@ func (mod *LibreOffice) Provision(ctx *gotenberg.Context) error { flags := ctx.ParsedFlags() mod.disableRoutes = flags.MustBool("libreoffice-disable-routes") - provider, err := ctx.Module(new(uno.Provider)) + provider, err := ctx.Module(new(libeofficeapi.Provider)) if err != nil { - return fmt.Errorf("get unoAPI provider: %w", err) + return fmt.Errorf("get LibreOffice Uno provider: %w", err) } - unoAPI, err := provider.(uno.Provider).UNO() + libreOfficeApi, err := provider.(libeofficeapi.Provider).LibreOffice() if err != nil { - return fmt.Errorf("get unoAPI API: %w", err) + return fmt.Errorf("get LibreOffice Uno: %w", err) } - mod.unoAPI = unoAPI + mod.api = libreOfficeApi provider, err = ctx.Module(new(gotenberg.PDFEngineProvider)) if err != nil { @@ -69,13 +69,13 @@ func (mod *LibreOffice) Provision(ctx *gotenberg.Context) error { } // Routes returns the HTTP routes. -func (mod LibreOffice) Routes() ([]api.Route, error) { +func (mod *LibreOffice) Routes() ([]api.Route, error) { if mod.disableRoutes { return nil, nil } return []api.Route{ - convertRoute(mod.unoAPI, mod.engine), + convertRoute(mod.api, mod.engine), }, nil } diff --git a/pkg/modules/libreoffice/libreoffice_test.go b/pkg/modules/libreoffice/libreoffice_test.go index 6e7d6ae5..39c6f9d3 100644 --- a/pkg/modules/libreoffice/libreoffice_test.go +++ b/pkg/modules/libreoffice/libreoffice_test.go @@ -6,11 +6,11 @@ import ( "testing" "github.com/gotenberg/gotenberg/v7/pkg/gotenberg" - "github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/uno" + libreofficeapi "github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/api" ) func TestLibreOffice_Descriptor(t *testing.T) { - descriptor := LibreOffice{}.Descriptor() + descriptor := new(LibreOffice).Descriptor() actual := reflect.TypeOf(descriptor.New()) expect := reflect.TypeOf(new(LibreOffice)) @@ -21,38 +21,89 @@ func TestLibreOffice_Descriptor(t *testing.T) { } func TestLibreOffice_Provision(t *testing.T) { - tests := []struct { - name string - ctx *gotenberg.Context - expectProvisionErr bool + for _, tc := range []struct { + scenario string + ctx *gotenberg.Context + expectError bool }{ { - name: "nominal behavior", + scenario: "no LibreOffice API provider", ctx: func() *gotenberg.Context { - provider1 := &struct { + return gotenberg.NewContext( + gotenberg.ParsedFlags{ + FlagSet: new(LibreOffice).Descriptor().FlagSet, + }, + []gotenberg.ModuleDescriptor{}, + ) + }(), + expectError: true, + }, + { + scenario: "no LibreOffice API from LibreOffice API provider", + ctx: func() *gotenberg.Context { + mod := &struct { gotenberg.ModuleMock - uno.ProviderMock + libreofficeapi.ProviderMock }{} - provider1.DescriptorMock = func() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { - return provider1 - }} + mod.DescriptorMock = func() gotenberg.ModuleDescriptor { + return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }} } - provider1.UNOMock = func() (uno.API, error) { - return uno.APIMock{}, nil + mod.LibreOfficeMock = func() (libreofficeapi.Uno, error) { + return nil, errors.New("foo") } - provider2 := &struct { + return gotenberg.NewContext( + gotenberg.ParsedFlags{ + FlagSet: new(LibreOffice).Descriptor().FlagSet, + }, + []gotenberg.ModuleDescriptor{ + mod.Descriptor(), + }, + ) + }(), + expectError: true, + }, + { + scenario: "no PDF engine provider", + ctx: func() *gotenberg.Context { + mod := &struct { gotenberg.ModuleMock + libreofficeapi.ProviderMock + }{} + mod.DescriptorMock = func() gotenberg.ModuleDescriptor { + return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }} + } + mod.LibreOfficeMock = func() (libreofficeapi.Uno, error) { + return new(libreofficeapi.ApiMock), nil + } + + return gotenberg.NewContext( + gotenberg.ParsedFlags{ + FlagSet: new(LibreOffice).Descriptor().FlagSet, + }, + []gotenberg.ModuleDescriptor{ + mod.Descriptor(), + }, + ) + }(), + expectError: true, + }, + { + scenario: "no PDF engine from PDF engine provider", + ctx: func() *gotenberg.Context { + mod := &struct { + gotenberg.ModuleMock + libreofficeapi.ProviderMock gotenberg.PDFEngineProviderMock }{} - provider2.DescriptorMock = func() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { - return provider2 - }} + mod.DescriptorMock = func() gotenberg.ModuleDescriptor { + return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }} } - provider2.PDFEngineMock = func() (gotenberg.PDFEngine, error) { - return &gotenberg.PDFEngineMock{}, nil + mod.LibreOfficeMock = func() (libreofficeapi.Uno, error) { + return new(libreofficeapi.ApiMock), nil + } + mod.PDFEngineMock = func() (gotenberg.PDFEngine, error) { + return nil, errors.New("foo") } return gotenberg.NewContext( @@ -60,103 +111,28 @@ func TestLibreOffice_Provision(t *testing.T) { FlagSet: new(LibreOffice).Descriptor().FlagSet, }, []gotenberg.ModuleDescriptor{ - provider1.Descriptor(), - provider2.Descriptor(), + mod.Descriptor(), }, ) }(), + expectError: true, }, { - name: "no UNO API provider", - ctx: gotenberg.NewContext( - gotenberg.ParsedFlags{ - FlagSet: new(LibreOffice).Descriptor().FlagSet, - }, - []gotenberg.ModuleDescriptor{}, - ), - expectProvisionErr: true, - }, - { - name: "no API from UNO API provider", + scenario: "provision success", ctx: func() *gotenberg.Context { - provider := &struct { - gotenberg.ModuleMock - uno.ProviderMock - }{} - provider.DescriptorMock = func() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { - return provider - }} - } - provider.UNOMock = func() (uno.API, error) { - return uno.APIMock{}, errors.New("foo") - } - - return gotenberg.NewContext( - gotenberg.ParsedFlags{ - FlagSet: new(LibreOffice).Descriptor().FlagSet, - }, - []gotenberg.ModuleDescriptor{ - provider.Descriptor(), - }, - ) - }(), - expectProvisionErr: true, - }, - { - name: "no PDF engine provider", - ctx: func() *gotenberg.Context { - provider := &struct { - gotenberg.ModuleMock - uno.ProviderMock - }{} - provider.DescriptorMock = func() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { - return provider - }} - } - provider.UNOMock = func() (uno.API, error) { - return uno.APIMock{}, nil - } - - return gotenberg.NewContext( - gotenberg.ParsedFlags{ - FlagSet: new(LibreOffice).Descriptor().FlagSet, - }, - []gotenberg.ModuleDescriptor{ - provider.Descriptor(), - }, - ) - }(), - expectProvisionErr: true, - }, - { - name: "no PDF engine from PDF engine provider", - ctx: func() *gotenberg.Context { - 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 { + mod := &struct { gotenberg.ModuleMock + libreofficeapi.ProviderMock gotenberg.PDFEngineProviderMock }{} - provider2.DescriptorMock = func() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { - return provider2 - }} + mod.DescriptorMock = func() gotenberg.ModuleDescriptor { + return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }} } - provider2.PDFEngineMock = func() (gotenberg.PDFEngine, error) { - return &gotenberg.PDFEngineMock{}, errors.New("foo") + mod.LibreOfficeMock = func() (libreofficeapi.Uno, error) { + return new(libreofficeapi.ApiMock), nil + } + mod.PDFEngineMock = func() (gotenberg.PDFEngine, error) { + return new(gotenberg.PDFEngineMock), nil } return gotenberg.NewContext( @@ -164,59 +140,56 @@ func TestLibreOffice_Provision(t *testing.T) { FlagSet: new(LibreOffice).Descriptor().FlagSet, }, []gotenberg.ModuleDescriptor{ - provider1.Descriptor(), - provider2.Descriptor(), + mod.Descriptor(), }, ) }(), - expectProvisionErr: true, + expectError: false, }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { + } { + t.Run(tc.scenario, func(t *testing.T) { mod := new(LibreOffice) err := mod.Provision(tc.ctx) - if tc.expectProvisionErr && err == nil { - t.Error("expected mod.Provision() error, but got none") + if !tc.expectError && err != nil { + t.Fatalf("expected no error but got: %v", err) } - if !tc.expectProvisionErr && err != nil { - t.Errorf("expected no error from mod.Provision(), but got: %v", err) + if tc.expectError && err == nil { + t.Fatal("expected error but got none") } }) } } func TestLibreOffice_Routes(t *testing.T) { - tests := []struct { - name string - mod LibreOffice - expectRoutesCount int + for _, tc := range []struct { + scenario string + expectRoutes int + disableRoutes bool }{ { - name: "route not disabled", - mod: LibreOffice{}, - expectRoutesCount: 1, + scenario: "routes not disabled", + expectRoutes: 1, + disableRoutes: false, }, { - name: "route disabled", - mod: LibreOffice{ - disableRoutes: true, - }, + scenario: "routes disabled", + expectRoutes: 0, + disableRoutes: true, }, - } + } { + t.Run(tc.scenario, func(t *testing.T) { + mod := new(LibreOffice) + mod.disableRoutes = tc.disableRoutes - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - routes, err := tc.mod.Routes() + routes, err := mod.Routes() if err != nil { - t.Fatalf("expected no error from mod.Routes(), but got: %v", err) + t.Fatalf("expected no error but got: %v", err) } - if tc.expectRoutesCount != len(routes) { - t.Errorf("expected %d routes from mod.Routes(), but got %d", tc.expectRoutesCount, len(routes)) + if tc.expectRoutes != len(routes) { + t.Errorf("expected %d routes but got %d", tc.expectRoutes, len(routes)) } }) } diff --git a/pkg/modules/libreoffice/pdfengine/doc.go b/pkg/modules/libreoffice/pdfengine/doc.go index b8861337..9c5d114f 100644 --- a/pkg/modules/libreoffice/pdfengine/doc.go +++ b/pkg/modules/libreoffice/pdfengine/doc.go @@ -1,4 +1,4 @@ -// Package pdfengine provides a module which interacts with the UNO -// (Universal Network Objects) API and implements the gotenberg.PDFEngine +// Package pdfengine provides a module which interacts with LibreOffice via the +// UNO (Universal Network Objects) API and implements the [gotenberg.PDFEngine] // interface. package pdfengine diff --git a/pkg/modules/libreoffice/pdfengine/pdfengine.go b/pkg/modules/libreoffice/pdfengine/pdfengine.go index 8eed1f01..d26bc66c 100644 --- a/pkg/modules/libreoffice/pdfengine/pdfengine.go +++ b/pkg/modules/libreoffice/pdfengine/pdfengine.go @@ -8,37 +8,37 @@ import ( "go.uber.org/zap" "github.com/gotenberg/gotenberg/v7/pkg/gotenberg" - "github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/uno" + "github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/api" ) func init() { - gotenberg.MustRegisterModule(UNO{}) + gotenberg.MustRegisterModule(new(LibreOfficePdfEngine)) } -// UNO interacts with the UNO (Universal Network Objects) API and implements -// the gotenberg.PDFEngine interface. -type UNO struct { - unoAPI uno.API +// LibreOfficePdfEngine interacts with the LibreOffice (Universal Network Objects) API +// and implements the [gotenberg.PDFEngine] interface. +type LibreOfficePdfEngine struct { + unoAPI api.Uno } -// Descriptor returns a UNO's module descriptor. -func (UNO) Descriptor() gotenberg.ModuleDescriptor { +// Descriptor returns a [LibreOfficePdfEngine]'s module descriptor. +func (engine *LibreOfficePdfEngine) Descriptor() gotenberg.ModuleDescriptor { return gotenberg.ModuleDescriptor{ - ID: "uno-pdfengine", - New: func() gotenberg.Module { return new(UNO) }, + ID: "libreoffice-pdfengine", + New: func() gotenberg.Module { return new(LibreOfficePdfEngine) }, } } // Provision sets the module properties. -func (engine *UNO) Provision(ctx *gotenberg.Context) error { - provider, err := ctx.Module(new(uno.Provider)) +func (engine *LibreOfficePdfEngine) Provision(ctx *gotenberg.Context) error { + provider, err := ctx.Module(new(api.Provider)) if err != nil { - return fmt.Errorf("get unoconv provider: %w", err) + return fmt.Errorf("get LibreOffice Uno provider: %w", err) } - unoAPI, err := provider.(uno.Provider).UNO() + unoAPI, err := provider.(api.Provider).LibreOffice() if err != nil { - return fmt.Errorf("get unoconv API: %w", err) + return fmt.Errorf("get LibreOffice Uno: %w", err) } engine.unoAPI = unoAPI @@ -47,24 +47,24 @@ func (engine *UNO) Provision(ctx *gotenberg.Context) error { } // Merge is not available for this PDF engine. -func (engine UNO) Merge(_ context.Context, _ *zap.Logger, _ []string, _ string) error { - return fmt.Errorf("merge PDFs with unoconv: %w", gotenberg.ErrPDFEngineMethodNotAvailable) +func (engine *LibreOfficePdfEngine) Merge(_ context.Context, _ *zap.Logger, _ []string, _ string) error { + return fmt.Errorf("merge PDFs with LibreOffice: %w", gotenberg.ErrPDFEngineMethodNotAvailable) } // Convert converts the given PDF to a specific PDF format. Currently, only the // PDF/A-1a, PDF/A-2b and PDF/A-3b formats are available. If another PDF format -// is requested, it returns a gotenberg.ErrPDFFormatNotAvailable error. -func (engine UNO) Convert(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error { - err := engine.unoAPI.PDF(ctx, logger, inputPath, outputPath, uno.Options{ - PDFformat: format, +// is requested, it returns a [gotenberg.ErrPDFFormatNotAvailable] error. +func (engine *LibreOfficePdfEngine) Convert(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error { + err := engine.unoAPI.Pdf(ctx, logger, inputPath, outputPath, api.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) + if errors.Is(err, api.ErrInvalidPdfFormat) { + return fmt.Errorf("convert PDF to '%s' with LibreOffice: %w", format, gotenberg.ErrPDFFormatNotAvailable) } return fmt.Errorf("convert PDF to '%s' with unoconv: %w", format, err) @@ -72,7 +72,7 @@ func (engine UNO) Convert(ctx context.Context, logger *zap.Logger, format, input // Interface guards. var ( - _ gotenberg.Module = (*UNO)(nil) - _ gotenberg.Provisioner = (*UNO)(nil) - _ gotenberg.PDFEngine = (*UNO)(nil) + _ gotenberg.Module = (*LibreOfficePdfEngine)(nil) + _ gotenberg.Provisioner = (*LibreOfficePdfEngine)(nil) + _ gotenberg.PDFEngine = (*LibreOfficePdfEngine)(nil) ) diff --git a/pkg/modules/libreoffice/pdfengine/pdfengine_test.go b/pkg/modules/libreoffice/pdfengine/pdfengine_test.go index 2e9d948f..7394b5da 100644 --- a/pkg/modules/libreoffice/pdfengine/pdfengine_test.go +++ b/pkg/modules/libreoffice/pdfengine/pdfengine_test.go @@ -9,166 +9,159 @@ import ( "go.uber.org/zap" "github.com/gotenberg/gotenberg/v7/pkg/gotenberg" - "github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/uno" + "github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/api" ) -func TestUNO_Descriptor(t *testing.T) { - descriptor := UNO{}.Descriptor() +func TestLibreOfficePdfEngine_Descriptor(t *testing.T) { + descriptor := new(LibreOfficePdfEngine).Descriptor() actual := reflect.TypeOf(descriptor.New()) - expect := reflect.TypeOf(new(UNO)) + expect := reflect.TypeOf(new(LibreOfficePdfEngine)) if actual != expect { t.Errorf("expected '%s' but got '%s'", expect, actual) } } -func TestUNO_Provider(t *testing.T) { - tests := []struct { - name string - ctx *gotenberg.Context - expectProvisionErr bool +func TestLibreOfficePdfEngine_Provider(t *testing.T) { + for _, tc := range []struct { + scenario string + ctx *gotenberg.Context + expectError bool }{ { - name: "nominal behavior", - ctx: func() *gotenberg.Context { - provider := &struct { - gotenberg.ModuleMock - uno.ProviderMock - }{} - provider.DescriptorMock = func() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { - return provider - }} - } - provider.UNOMock = func() (uno.API, error) { - return uno.APIMock{}, nil - } - - return gotenberg.NewContext( - gotenberg.ParsedFlags{ - FlagSet: new(UNO).Descriptor().FlagSet, - }, - []gotenberg.ModuleDescriptor{ - provider.Descriptor(), - }, - ) - }(), - }, - { - name: "no UNO API provider", + scenario: "no LibreOffice API provider", ctx: gotenberg.NewContext( gotenberg.ParsedFlags{ - FlagSet: new(UNO).Descriptor().FlagSet, + FlagSet: new(LibreOfficePdfEngine).Descriptor().FlagSet, }, []gotenberg.ModuleDescriptor{}, ), - expectProvisionErr: true, + expectError: true, }, { - name: "no API from UNO API provider", + scenario: "no API from LibreOffice API provider", ctx: func() *gotenberg.Context { provider := &struct { gotenberg.ModuleMock - uno.ProviderMock + api.ProviderMock }{} provider.DescriptorMock = func() gotenberg.ModuleDescriptor { return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return provider }} } - provider.UNOMock = func() (uno.API, error) { - return uno.APIMock{}, errors.New("foo") + provider.LibreOfficeMock = func() (api.Uno, error) { + return nil, errors.New("foo") } return gotenberg.NewContext( gotenberg.ParsedFlags{ - FlagSet: new(UNO).Descriptor().FlagSet, + FlagSet: new(LibreOfficePdfEngine).Descriptor().FlagSet, }, []gotenberg.ModuleDescriptor{ provider.Descriptor(), }, ) }(), - expectProvisionErr: true, + expectError: true, }, - } + { + scenario: "provision success", + ctx: func() *gotenberg.Context { + provider := &struct { + gotenberg.ModuleMock + api.ProviderMock + }{} + provider.DescriptorMock = func() gotenberg.ModuleDescriptor { + return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { + return provider + }} + } + provider.LibreOfficeMock = func() (api.Uno, error) { + return new(api.ApiMock), nil + } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - mod := new(UNO) + return gotenberg.NewContext( + gotenberg.ParsedFlags{ + FlagSet: new(LibreOfficePdfEngine).Descriptor().FlagSet, + }, + []gotenberg.ModuleDescriptor{ + provider.Descriptor(), + }, + ) + }(), + expectError: false, + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + mod := new(LibreOfficePdfEngine) err := mod.Provision(tc.ctx) - if tc.expectProvisionErr && err == nil { - t.Error("expected mod.Provision() error, but got none") + if !tc.expectError && err != nil { + t.Fatalf("expected no error but got: %v", err) } - if !tc.expectProvisionErr && err != nil { - t.Errorf("expected no error from mod.Provision(), but got: %v", err) + if tc.expectError && err == nil { + t.Fatal("expected error but got none") } }) } } -func TestUNO_Merge(t *testing.T) { - mod := new(UNO) +func TestLibreOfficePdfEngine_Merge(t *testing.T) { + mod := new(LibreOfficePdfEngine) err := mod.Merge(context.Background(), zap.NewNop(), nil, "") if !errors.Is(err, gotenberg.ErrPDFEngineMethodNotAvailable) { - t.Errorf("expected error %v from mod.Merge(), but got: %v", gotenberg.ErrPDFEngineMethodNotAvailable, err) + t.Errorf("expected error %v, but got: %v", gotenberg.ErrPDFEngineMethodNotAvailable, err) } } -func TestUNO_Convert(t *testing.T) { - tests := []struct { - name string - mod UNO - expectConvertErr bool +func TestLibreOfficePdfEngine_Convert(t *testing.T) { + for _, tc := range []struct { + scenario string + api api.Uno + expectError bool }{ { - name: "nominal behavior", - mod: UNO{ - unoAPI: uno.APIMock{ - PDFMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options uno.Options) error { - return nil - }, + scenario: "convert success", + api: &api.ApiMock{ + PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options api.Options) error { + return nil }, }, + expectError: false, }, { - name: "invalid PDF format", - mod: UNO{ - unoAPI: uno.APIMock{ - PDFMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options uno.Options) error { - return uno.ErrInvalidPDFformat - }, + scenario: "invalid PDF format", + api: &api.ApiMock{ + PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options api.Options) error { + return api.ErrInvalidPdfFormat }, }, - expectConvertErr: true, + expectError: true, }, { - name: "convert fail", - mod: UNO{ - unoAPI: uno.APIMock{ - PDFMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options uno.Options) error { - return errors.New("foo") - }, + scenario: "convert fail", + api: &api.ApiMock{ + PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options api.Options) error { + return errors.New("foo") }, }, - expectConvertErr: true, + expectError: true, }, - } + } { + t.Run(tc.scenario, func(t *testing.T) { + engine := &LibreOfficePdfEngine{unoAPI: tc.api} + err := engine.Convert(context.Background(), zap.NewNop(), "", "", "") - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - err := tc.mod.Convert(context.Background(), zap.NewNop(), "", "", "") - - if tc.expectConvertErr && err == nil { - t.Errorf("expected mod.Convert() error, but got none") + if !tc.expectError && err != nil { + t.Fatalf("expected no error but got: %v", err) } - if !tc.expectConvertErr && err != nil { - t.Fatalf("expected no error from mod.Convert(), but got: %v", err) + if tc.expectError && err == nil { + t.Fatal("expected error but got none") } }) } diff --git a/pkg/modules/libreoffice/routes.go b/pkg/modules/libreoffice/routes.go index 471c0c40..fb4718dd 100644 --- a/pkg/modules/libreoffice/routes.go +++ b/pkg/modules/libreoffice/routes.go @@ -9,12 +9,12 @@ import ( "github.com/gotenberg/gotenberg/v7/pkg/gotenberg" "github.com/gotenberg/gotenberg/v7/pkg/modules/api" - "github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/uno" + libreofficeapi "github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/api" ) -// convertRoute returns an api.Route which can convert LibreOffice documents +// convertRoute returns an [api.Route] which can convert LibreOffice documents // to PDF. -func convertRoute(unoAPI uno.API, engine gotenberg.PDFEngine) api.Route { +func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PDFEngine) api.Route { return api.Route{ Method: http.MethodPost, Path: "/forms/libreoffice/convert", @@ -27,52 +27,46 @@ func convertRoute(unoAPI uno.API, engine gotenberg.PDFEngine) api.Route { inputPaths []string landscape bool nativePageRanges string - nativePDFA1aFormat bool - nativePDFformat string - PDFformat string + nativePdfA1aFormat bool + nativePdfFormat string + pdfFormat string merge bool ) err := ctx.FormData(). - MandatoryPaths(unoAPI.Extensions(), &inputPaths). + MandatoryPaths(libreOffice.Extensions(), &inputPaths). Bool("landscape", &landscape, false). String("nativePageRanges", &nativePageRanges, ""). - Bool("nativePdfA1aFormat", &nativePDFA1aFormat, false). - String("nativePdfFormat", &nativePDFformat, ""). - String("pdfFormat", &PDFformat, ""). + Bool("nativePdfA1aFormat", &nativePdfA1aFormat, false). + String("nativePdfFormat", &nativePdfFormat, ""). + String("pdfFormat", &pdfFormat, ""). Bool("merge", &merge, false). Validate() if err != nil { return fmt.Errorf("validate form data: %w", err) } - if nativePDFA1aFormat { + // nativePdfFormat > pdfFormat > nativePdfA1aFormat. + var ( + actualPdfFormat string + nativeFormat bool + ) + + // FIXME: deprecated. + if nativePdfA1aFormat { ctx.Log().Warn("'nativePdfA1aFormat' is deprecated; prefer 'nativePdfFormat' or 'pdfFormat' form fields instead") + actualPdfFormat = gotenberg.FormatPDFA1a + nativeFormat = true } - 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 pdfFormat != "" { + actualPdfFormat = pdfFormat + nativeFormat = false } - if nativePDFA1aFormat && PDFformat != "" { - return api.WrapError( - errors.New("got both 'pdfFormat' and 'nativePdfA1aFormat' form fields"), - api.NewSentinelHTTPError(http.StatusBadRequest, "Both 'pdfFormat' and 'nativePdfA1aFormat' form fields are provided"), - ) - } - - if nativePDFformat != "" && PDFformat != "" { - return api.WrapError( - errors.New("got both 'pdfFormat' and 'nativePdfFormat' form fields"), - api.NewSentinelHTTPError(http.StatusBadRequest, "Both 'pdfFormat' and 'nativePdfFormat' form fields are provided"), - ) - } - - if nativePDFA1aFormat { - nativePDFformat = gotenberg.FormatPDFA1a + if nativePdfFormat != "" { + actualPdfFormat = nativePdfFormat + nativeFormat = true } // Alright, let's convert each document to PDF. @@ -82,16 +76,16 @@ func convertRoute(unoAPI uno.API, engine gotenberg.PDFEngine) api.Route { for i, inputPath := range inputPaths { outputPaths[i] = ctx.GeneratePath(".pdf") - options := uno.Options{ + options := libreofficeapi.Options{ Landscape: landscape, PageRanges: nativePageRanges, - PDFformat: nativePDFformat, + PdfFormat: nativePdfFormat, } - err = unoAPI.PDF(ctx, ctx.Log(), inputPath, outputPaths[i], options) + err = libreOffice.Pdf(ctx, ctx.Log(), inputPath, outputPaths[i], options) if err != nil { - if errors.Is(err, uno.ErrMalformedPageRanges) { + if errors.Is(err, libreofficeapi.ErrMalformedPageRanges) { return api.WrapError( fmt.Errorf("convert to PDF: %w", err), api.NewSentinelHTTPError(http.StatusBadRequest, fmt.Sprintf("Malformed page ranges '%s' (nativePageRanges)", options.PageRanges)), @@ -116,14 +110,11 @@ func convertRoute(unoAPI uno.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/nativePdfFormat have not been - // specified if PDFformat is not empty. - - if PDFformat != "" { + if !nativeFormat && actualPdfFormat != "" { convertInputPath := outputPath convertOutputPath := ctx.GeneratePath(".pdf") - err = engine.Convert(ctx, ctx.Log(), PDFformat, convertInputPath, convertOutputPath) + err = engine.Convert(ctx, ctx.Log(), actualPdfFormat, convertInputPath, convertOutputPath) if err != nil { if errors.Is(err, gotenberg.ErrPDFFormatNotAvailable) { @@ -131,7 +122,7 @@ func convertRoute(unoAPI uno.API, engine gotenberg.PDFEngine) api.Route { fmt.Errorf("convert PDF: %w", err), api.NewSentinelHTTPError( http.StatusBadRequest, - fmt.Sprintf("At least one PDF engine does not handle the PDF format '%s' (pdfFormat), while other have failed to convert for other reasons", PDFformat), + fmt.Sprintf("At least one PDF engine does not handle the PDF format '%s' (pdfFormat), while other have failed to convert for other reasons", actualPdfFormat), ), ) } @@ -144,7 +135,7 @@ func convertRoute(unoAPI uno.API, engine gotenberg.PDFEngine) api.Route { } // Last but not least, add the output path to the context so that - // the API is able to send it as a response to the client. + // the Uno is able to send it as a response to the client. err = ctx.AddOutputPaths(outputPath) if err != nil { @@ -157,17 +148,14 @@ func convertRoute(unoAPI uno.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/nativePdfFormat have not been - // specified if PDFformat is not empty. - - if PDFformat != "" { + if !nativeFormat && actualPdfFormat != "" { convertOutputPaths := make([]string, len(outputPaths)) for i, outputPath := range outputPaths { convertInputPath := outputPath convertOutputPaths[i] = ctx.GeneratePath(".pdf") - err = engine.Convert(ctx, ctx.Log(), PDFformat, convertInputPath, convertOutputPaths[i]) + err = engine.Convert(ctx, ctx.Log(), actualPdfFormat, convertInputPath, convertOutputPaths[i]) if err != nil { if errors.Is(err, gotenberg.ErrPDFFormatNotAvailable) { @@ -175,7 +163,7 @@ func convertRoute(unoAPI uno.API, engine gotenberg.PDFEngine) api.Route { fmt.Errorf("convert PDF: %w", err), api.NewSentinelHTTPError( http.StatusBadRequest, - fmt.Sprintf("At least one PDF engine does not handle the PDF format '%s' (pdfFormat), while other have failed to convert for other reasons", PDFformat), + fmt.Sprintf("At least one PDF engine does not handle the PDF format '%s' (pdfFormat), while other have failed to convert for other reasons", actualPdfFormat), ), ) } @@ -190,7 +178,7 @@ func convertRoute(unoAPI uno.API, engine gotenberg.PDFEngine) api.Route { } // Last but not least, add the output paths to the context so that - // the API is able to send them as a response to the client. + // the Uno is able to send them as a response to the client. err = ctx.AddOutputPaths(outputPaths...) if err != nil { diff --git a/pkg/modules/libreoffice/routes_test.go b/pkg/modules/libreoffice/routes_test.go index 40841b3a..9db8584f 100644 --- a/pkg/modules/libreoffice/routes_test.go +++ b/pkg/modules/libreoffice/routes_test.go @@ -11,139 +11,240 @@ import ( "github.com/gotenberg/gotenberg/v7/pkg/gotenberg" "github.com/gotenberg/gotenberg/v7/pkg/modules/api" - "github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/uno" + libreofficeapi "github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/api" ) -func TestConvertHandler(t *testing.T) { - tests := []struct { - name string +func TestConvertRoute(t *testing.T) { + for _, tc := range []struct { + scenario string ctx *api.ContextMock - unoAPI uno.API + libreOffice libreofficeapi.Uno engine gotenberg.PDFEngine - expectErr bool - expectHTTPErr bool - expectHTTPStatus int + expectOptions libreofficeapi.Options + expectError bool + expectHttpError bool + expectHttpStatus int expectOutputPathsCount int }{ { - name: "nominal behavior", + scenario: "missing at least one mandatory file", + ctx: &api.ContextMock{Context: new(api.Context)}, + libreOffice: &libreofficeapi.ApiMock{ExtensionsMock: func() []string { + return []string{".docx"} + }}, + expectError: true, + expectHttpError: true, + expectHttpStatus: http.StatusBadRequest, + expectOutputPathsCount: 0, + }, + { + scenario: "ErrMalformedPageRanges", ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: &api.Context{}} + ctx := &api.ContextMock{Context: new(api.Context)} ctx.SetFiles(map[string]string{ - "foo.docx": "/foo/foo.docx", + "document.docx": "/document.docx", }) - return ctx }(), - unoAPI: uno.APIMock{ - PDFMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options uno.Options) error { + libreOffice: &libreofficeapi.ApiMock{ + PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error { + return libreofficeapi.ErrMalformedPageRanges + }, + ExtensionsMock: func() []string { + return []string{".docx"} + }, + }, + expectError: true, + expectHttpError: true, + expectHttpStatus: http.StatusBadRequest, + expectOutputPathsCount: 0, + }, + { + scenario: "error from LibreOffice", + ctx: func() *api.ContextMock { + ctx := &api.ContextMock{Context: new(api.Context)} + ctx.SetFiles(map[string]string{ + "document.docx": "/document.docx", + }) + return ctx + }(), + libreOffice: &libreofficeapi.ApiMock{ + PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error { + return errors.New("foo") + }, + ExtensionsMock: func() []string { + return []string{".docx"} + }, + }, + expectError: true, + expectHttpError: false, + expectOutputPathsCount: 0, + }, + { + scenario: "ErrPDFFormatNotAvailable (single file)", + ctx: func() *api.ContextMock { + ctx := &api.ContextMock{Context: new(api.Context)} + ctx.SetFiles(map[string]string{ + "document.docx": "/document.docx", + }) + ctx.SetValues(map[string][]string{ + "pdfFormat": { + "foo", + }, + }) + return ctx + }(), + libreOffice: &libreofficeapi.ApiMock{ + PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error { return nil }, ExtensionsMock: func() []string { - return []string{ - ".docx", - } + return []string{".docx"} }, }, - expectOutputPathsCount: 1, + engine: &gotenberg.PDFEngineMock{ + ConvertMock: func(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error { + return gotenberg.ErrPDFFormatNotAvailable + }, + }, + expectError: true, + expectHttpError: true, + expectHttpStatus: http.StatusBadRequest, + expectOutputPathsCount: 0, }, { - name: "nominal behavior, but with 3 documents", + scenario: "PDF engine convert error (single file)", ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: &api.Context{}} + ctx := &api.ContextMock{Context: new(api.Context)} ctx.SetFiles(map[string]string{ - "foo.docx": "/foo/foo.docx", - "bar.docx": "/bar/bar.docx", - "baz.docx": "/baz/baz.docx", + "document.docx": "/document.docx", + }) + ctx.SetValues(map[string][]string{ + "pdfFormat": { + gotenberg.FormatPDFA1a, + }, }) - return ctx }(), - unoAPI: uno.APIMock{ - PDFMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options uno.Options) error { + libreOffice: &libreofficeapi.ApiMock{ + PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error { return nil }, ExtensionsMock: func() []string { - return []string{ - ".docx", - } + return []string{".docx"} }, }, - expectOutputPathsCount: 3, + engine: &gotenberg.PDFEngineMock{ + ConvertMock: func(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error { + return errors.New("foo") + }, + }, + expectError: true, + expectHttpError: false, + expectOutputPathsCount: 0, }, { - name: "cannot add output paths", + scenario: "cannot add output paths (single file)", ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: &api.Context{}} + ctx := &api.ContextMock{Context: new(api.Context)} ctx.SetFiles(map[string]string{ - "foo.docx": "/foo/foo.docx", + "document.docx": "/document.docx", }) ctx.SetCancelled(true) - return ctx }(), - unoAPI: uno.APIMock{ - PDFMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options uno.Options) error { + libreOffice: &libreofficeapi.ApiMock{ + PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error { return nil }, ExtensionsMock: func() []string { - return []string{ - ".docx", - } + return []string{".docx"} }, }, - expectErr: true, + expectError: true, + expectHttpError: false, + expectOutputPathsCount: 0, }, { - name: "invalid form data: no documents", - ctx: &api.ContextMock{Context: &api.Context{}}, - unoAPI: uno.APIMock{ - ExtensionsMock: func() []string { - return []string{ - ".docx", - } - }, - }, - expectErr: true, - expectHTTPErr: true, - expectHTTPStatus: http.StatusBadRequest, - }, - { - name: "invalid form data: both nativePdfA1aFormat and nativePdfFormat are set", + scenario: "success (single file)", ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: &api.Context{}} + ctx := &api.ContextMock{Context: new(api.Context)} ctx.SetFiles(map[string]string{ - "foo.docx": "/foo/foo.docx", + "document.docx": "/document.docx", + }) + return ctx + }(), + libreOffice: &libreofficeapi.ApiMock{ + PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error { + return nil + }, + ExtensionsMock: func() []string { + return []string{".docx"} + }, + }, + expectError: false, + expectHttpError: false, + expectOutputPathsCount: 1, + }, + { + scenario: "success (many files)", + ctx: func() *api.ContextMock { + ctx := &api.ContextMock{Context: new(api.Context)} + ctx.SetFiles(map[string]string{ + "document.docx": "/document.docx", + "document2.docx": "/document2.docx", + }) + return ctx + }(), + libreOffice: &libreofficeapi.ApiMock{ + PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error { + return nil + }, + ExtensionsMock: func() []string { + return []string{".docx"} + }, + }, + expectError: false, + expectHttpError: false, + expectOutputPathsCount: 2, + }, + { + scenario: "success with PDF format (single file)", + ctx: func() *api.ContextMock { + ctx := &api.ContextMock{Context: new(api.Context)} + ctx.SetFiles(map[string]string{ + "document.docx": "/document.docx", }) ctx.SetValues(map[string][]string{ - "nativePdfA1aFormat": { - "true", - }, - "nativePdfFormat": { + "pdfFormat": { gotenberg.FormatPDFA1a, }, }) - ctx.SetLogger(zap.NewNop()) - return ctx }(), - unoAPI: uno.APIMock{ + libreOffice: &libreofficeapi.ApiMock{ + PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error { + return nil + }, ExtensionsMock: func() []string { - return []string{ - ".docx", - } + return []string{".docx"} }, }, - expectErr: true, - expectHTTPErr: true, - expectHTTPStatus: http.StatusBadRequest, + engine: &gotenberg.PDFEngineMock{ + ConvertMock: func(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error { + return nil + }, + }, + expectError: false, + expectHttpError: false, + expectOutputPathsCount: 1, }, { - name: "invalid form data: both nativePdfA1aFormat and pdfFormat are set", + scenario: "success with every PDF formats form field (single file)", ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: &api.Context{}} + ctx := &api.ContextMock{Context: new(api.Context)} ctx.SetFiles(map[string]string{ - "foo.docx": "/foo/foo.docx", + "document.docx": "/document.docx", }) ctx.SetValues(map[string][]string{ "nativePdfA1aFormat": { @@ -152,253 +253,68 @@ func TestConvertHandler(t *testing.T) { "pdfFormat": { gotenberg.FormatPDFA1a, }, - }) - ctx.SetLogger(zap.NewNop()) - - return ctx - }(), - unoAPI: uno.APIMock{ - ExtensionsMock: func() []string { - return []string{ - ".docx", - } - }, - }, - expectErr: true, - expectHTTPErr: true, - expectHTTPStatus: http.StatusBadRequest, - }, - { - name: "invalid form data: both nativePdfFormat and pdfFormat are set", - ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: &api.Context{}} - ctx.SetFiles(map[string]string{ - "foo.docx": "/foo/foo.docx", - }) - ctx.SetValues(map[string][]string{ "nativePdfFormat": { gotenberg.FormatPDFA1a, }, - "pdfFormat": { - gotenberg.FormatPDFA1a, - }, }) - return ctx }(), - unoAPI: uno.APIMock{ - ExtensionsMock: func() []string { - return []string{ - ".docx", - } - }, - }, - expectErr: true, - expectHTTPErr: true, - expectHTTPStatus: http.StatusBadRequest, - }, - { - name: "convert to PDF fail", - ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: &api.Context{}} - ctx.SetFiles(map[string]string{ - "foo.docx": "/foo/foo.docx", - }) - - return ctx - }(), - unoAPI: uno.APIMock{ - PDFMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options uno.Options) error { - return errors.New("foo") - }, - ExtensionsMock: func() []string { - return []string{ - ".docx", - } - }, - }, - expectErr: true, - }, - { - name: "invalid page ranges", - ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: &api.Context{}} - ctx.SetFiles(map[string]string{ - "foo.docx": "/foo/foo.docx", - }) - - return ctx - }(), - unoAPI: uno.APIMock{ - PDFMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options uno.Options) error { - return uno.ErrMalformedPageRanges - }, - ExtensionsMock: func() []string { - return []string{ - ".docx", - } - }, - }, - expectErr: true, - expectHTTPErr: true, - expectHTTPStatus: http.StatusBadRequest, - }, - { - name: "convert 3 documents and merge them", - ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: &api.Context{}} - ctx.SetFiles(map[string]string{ - "foo.docx": "/foo/foo.docx", - "bar.docx": "/bar/bar.docx", - "baz.docx": "/baz/baz.docx", - }) - ctx.SetValues(map[string][]string{ - "merge": { - "true", - }, - }) - - return ctx - }(), - unoAPI: uno.APIMock{ - PDFMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options uno.Options) error { + libreOffice: &libreofficeapi.ApiMock{ + PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error { return nil }, ExtensionsMock: func() []string { - return []string{ - ".docx", - } + return []string{".docx"} }, }, engine: &gotenberg.PDFEngineMock{ - MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { - return nil - }, - }, - expectOutputPathsCount: 1, - }, - { - name: "merge fail", - ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: &api.Context{}} - ctx.SetFiles(map[string]string{ - "foo.docx": "/foo/foo.docx", - "bar.docx": "/bar/bar.docx", - "baz.docx": "/baz/baz.docx", - }) - ctx.SetValues(map[string][]string{ - "merge": { - "true", - }, - }) - - return ctx - }(), - unoAPI: uno.APIMock{ - PDFMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options uno.Options) error { - return nil - }, - ExtensionsMock: func() []string { - return []string{ - ".docx", - } - }, - }, - engine: &gotenberg.PDFEngineMock{ - MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { - return errors.New("foo") - }, - }, - expectErr: true, - }, - { - name: "convert 3 documents, merge them, and convert them to a PDF format", - ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: &api.Context{}} - ctx.SetFiles(map[string]string{ - "foo.docx": "/foo/foo.docx", - "bar.docx": "/bar/bar.docx", - "baz.docx": "/baz/baz.docx", - }) - ctx.SetValues(map[string][]string{ - "merge": { - "true", - }, - "pdfFormat": { - gotenberg.FormatPDFA1a, - }, - }) - - return ctx - }(), - unoAPI: uno.APIMock{ - PDFMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options uno.Options) error { - return nil - }, - ExtensionsMock: func() []string { - return []string{ - ".docx", - } - }, - }, - 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 }, }, + expectError: false, + expectHttpError: false, expectOutputPathsCount: 1, }, { - name: "convert 3 documents, merge them, but convert them to PDF format fail", + scenario: "merge error", ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: &api.Context{}} + ctx := &api.ContextMock{Context: new(api.Context)} ctx.SetFiles(map[string]string{ - "foo.docx": "/foo/foo.docx", - "bar.docx": "/bar/bar.docx", - "baz.docx": "/baz/baz.docx", + "document.docx": "/document.docx", + "document2.docx": "/document2.docx", }) ctx.SetValues(map[string][]string{ "merge": { "true", }, - "pdfFormat": { - gotenberg.FormatPDFA1a, - }, }) - return ctx }(), - unoAPI: uno.APIMock{ - PDFMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options uno.Options) error { + libreOffice: &libreofficeapi.ApiMock{ + PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error { return nil }, ExtensionsMock: func() []string { - return []string{ - ".docx", - } + return []string{".docx"} }, }, 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, + expectError: true, + expectHttpError: false, + expectOutputPathsCount: 0, }, { - name: "convert 3 documents, merge them, but PDF format not available", + scenario: "ErrPDFFormatNotAvailable (merge)", ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: &api.Context{}} + ctx := &api.ContextMock{Context: new(api.Context)} ctx.SetFiles(map[string]string{ - "foo.docx": "/foo/foo.docx", - "bar.docx": "/bar/bar.docx", - "baz.docx": "/baz/baz.docx", + "document.docx": "/document.docx", + "document2.docx": "/document2.docx", }) ctx.SetValues(map[string][]string{ "merge": { @@ -408,17 +324,14 @@ func TestConvertHandler(t *testing.T) { "foo", }, }) - return ctx }(), - unoAPI: uno.APIMock{ - PDFMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options uno.Options) error { + libreOffice: &libreofficeapi.ApiMock{ + PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error { return nil }, ExtensionsMock: func() []string { - return []string{ - ".docx", - } + return []string{".docx"} }, }, engine: &gotenberg.PDFEngineMock{ @@ -429,18 +342,56 @@ func TestConvertHandler(t *testing.T) { return gotenberg.ErrPDFFormatNotAvailable }, }, - expectErr: true, - expectHTTPErr: true, - expectHTTPStatus: http.StatusBadRequest, + expectError: true, + expectHttpError: true, + expectHttpStatus: http.StatusBadRequest, + expectOutputPathsCount: 0, }, { - name: "convert 3 documents and merge them, but cannot add output paths", + scenario: "PDF engine convert error (merge)", ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: &api.Context{}} + ctx := &api.ContextMock{Context: new(api.Context)} ctx.SetFiles(map[string]string{ - "foo.docx": "/foo/foo.docx", - "bar.docx": "/bar/bar.docx", - "baz.docx": "/baz/baz.docx", + "document.docx": "/document.docx", + "document2.docx": "/document2.docx", + }) + ctx.SetValues(map[string][]string{ + "merge": { + "true", + }, + "pdfFormat": { + gotenberg.FormatPDFA1a, + }, + }) + return ctx + }(), + libreOffice: &libreofficeapi.ApiMock{ + PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error { + return nil + }, + ExtensionsMock: func() []string { + return []string{".docx"} + }, + }, + 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") + }, + }, + expectError: true, + expectHttpError: false, + expectOutputPathsCount: 0, + }, + { + scenario: "cannot add output paths (merge)", + ctx: func() *api.ContextMock { + ctx := &api.ContextMock{Context: new(api.Context)} + ctx.SetFiles(map[string]string{ + "document.docx": "/document.docx", + "document2.docx": "/document2.docx", }) ctx.SetValues(map[string][]string{ "merge": { @@ -448,17 +399,14 @@ func TestConvertHandler(t *testing.T) { }, }) ctx.SetCancelled(true) - return ctx }(), - unoAPI: uno.APIMock{ - PDFMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options uno.Options) error { + libreOffice: &libreofficeapi.ApiMock{ + PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error { return nil }, ExtensionsMock: func() []string { - return []string{ - ".docx", - } + return []string{".docx"} }, }, engine: &gotenberg.PDFEngineMock{ @@ -466,176 +414,116 @@ func TestConvertHandler(t *testing.T) { return nil }, }, - expectErr: true, + expectError: true, + expectHttpError: false, + expectOutputPathsCount: 0, }, { - name: "convert to PDF format", + scenario: "success (merge)", ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: &api.Context{}} + ctx := &api.ContextMock{Context: new(api.Context)} ctx.SetFiles(map[string]string{ - "foo.docx": "/foo/foo.docx", + "document.docx": "/document.docx", + "document2.docx": "/document2.docx", }) ctx.SetValues(map[string][]string{ - "pdfFormat": { - gotenberg.FormatPDFA1a, - }, - }) - - return ctx - }(), - unoAPI: uno.APIMock{ - PDFMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options uno.Options) error { - return nil - }, - ExtensionsMock: func() []string { - return []string{ - ".docx", - } - }, - }, - engine: &gotenberg.PDFEngineMock{ - ConvertMock: func(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error { - return nil - }, - }, - expectOutputPathsCount: 1, - }, - { - name: "convert to PDF format using nativePdfA1aFormat", - ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: &api.Context{}} - ctx.SetFiles(map[string]string{ - "foo.docx": "/foo/foo.docx", - }) - ctx.SetValues(map[string][]string{ - "nativePdfA1aFormat": { + "merge": { "true", }, }) - ctx.SetLogger(zap.NewNop()) - return ctx }(), - unoAPI: uno.APIMock{ - PDFMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options uno.Options) error { + libreOffice: &libreofficeapi.ApiMock{ + PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error { return nil }, ExtensionsMock: func() []string { - return []string{ - ".docx", - } + return []string{".docx"} }, }, engine: &gotenberg.PDFEngineMock{ - ConvertMock: func(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error { + MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { return nil }, }, + expectError: false, + expectHttpError: false, expectOutputPathsCount: 1, }, { - name: "convert to PDF format fail", + scenario: "success with PDF format (merge)", ctx: func() *api.ContextMock { - ctx := &api.ContextMock{Context: &api.Context{}} + ctx := &api.ContextMock{Context: new(api.Context)} ctx.SetFiles(map[string]string{ - "foo.docx": "/foo/foo.docx", + "document.docx": "/document.docx", + "document2.docx": "/document2.docx", }) ctx.SetValues(map[string][]string{ + "merge": { + "true", + }, "pdfFormat": { gotenberg.FormatPDFA1a, }, }) - return ctx }(), - unoAPI: uno.APIMock{ - PDFMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options uno.Options) error { + libreOffice: &libreofficeapi.ApiMock{ + PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error { return nil }, ExtensionsMock: func() []string { - return []string{ - ".docx", - } + return []string{".docx"} }, }, 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.ContextMock { - ctx := &api.ContextMock{Context: &api.Context{}} - ctx.SetFiles(map[string]string{ - "foo.docx": "/foo/foo.docx", - }) - ctx.SetValues(map[string][]string{ - "pdfFormat": { - "foo", - }, - }) - - return ctx - }(), - unoAPI: uno.APIMock{ - PDFMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options uno.Options) error { + MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { return nil }, - ExtensionsMock: func() []string { - return []string{ - ".docx", - } - }, - }, - engine: &gotenberg.PDFEngineMock{ ConvertMock: func(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error { - return gotenberg.ErrPDFFormatNotAvailable + return nil }, }, - expectErr: true, - expectHTTPErr: true, - expectHTTPStatus: http.StatusBadRequest, + expectError: false, + expectHttpError: false, + expectOutputPathsCount: 1, }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { + } { + t.Run(tc.scenario, func(t *testing.T) { + tc.ctx.SetLogger(zap.NewNop()) c := echo.New().NewContext(nil, nil) c.Set("context", tc.ctx.Context) - err := convertRoute(tc.unoAPI, tc.engine).Handler(c) + err := convertRoute(tc.libreOffice, tc.engine).Handler(c) - if tc.expectErr && err == nil { - t.Fatal("expected error from convert handler, but got none") + if tc.expectError && err == nil { + t.Fatal("expected error but got none", err) } - if !tc.expectErr && err != nil { - t.Fatalf("expected no error from convert handler, but got: %v", err) + if !tc.expectError && err != nil { + t.Fatalf("expected no error 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.expectHttpError && !isHTTPErr { + t.Errorf("expected an HTTP error but got: %v", err) } - if !tc.expectHTTPErr && isHTTPErr { - t.Errorf("expected no HTTP error from convert handler, but got one: %v", httpErr) + if !tc.expectHttpError && isHTTPErr { + t.Errorf("expected no HTTP error but got one: %v", httpErr) } - if err != nil && tc.expectHTTPErr && isHTTPErr { + if err != nil && tc.expectHttpError && 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 status != tc.expectHttpStatus { + t.Errorf("expected %d as HTTP status code 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())) + t.Errorf("expected %d output paths but got %d", tc.expectOutputPathsCount, len(tc.ctx.OutputPaths())) } }) } diff --git a/pkg/modules/libreoffice/uno/doc.go b/pkg/modules/libreoffice/uno/doc.go deleted file mode 100644 index 7e38fd41..00000000 --- a/pkg/modules/libreoffice/uno/doc.go +++ /dev/null @@ -1,3 +0,0 @@ -// Package uno provides a module which interacts with the UNO -// (Universal Network Objects) API. -package uno diff --git a/pkg/modules/libreoffice/uno/listener.go b/pkg/modules/libreoffice/uno/listener.go deleted file mode 100644 index d659ad50..00000000 --- a/pkg/modules/libreoffice/uno/listener.go +++ /dev/null @@ -1,398 +0,0 @@ -package uno - -import ( - "context" - "fmt" - "net" - "os" - "sync" - "time" - - "go.uber.org/zap" - - "github.com/gotenberg/gotenberg/v7/pkg/gotenberg" -) - -type listener interface { - start(logger *zap.Logger) error - stop(logger *zap.Logger) error - restart(logger *zap.Logger) error - lock(ctx context.Context, logger *zap.Logger) error - unlock(logger *zap.Logger) error - port() int - queue() int - healthy() bool -} - -// TODO: this implementation, even if it's working, is way too complex. -type libreOfficeListener struct { - binPath string - startTimeout time.Duration - threshold int - - socketPort int - userProfileDirPath string - cmd gotenberg.Cmd - cfgMu sync.RWMutex - - usage int - hadFirstStart bool - hadFirstStartMu sync.RWMutex - restarting bool - restartingMu sync.RWMutex - queueLength int - queueLengthMu sync.RWMutex - lockChan chan struct{} - - fs *gotenberg.FileSystem - logger *zap.Logger -} - -func newLibreOfficeListener(logger *zap.Logger, fs *gotenberg.FileSystem, binPath string, startTimeout time.Duration, threshold int) listener { - return &libreOfficeListener{ - binPath: binPath, - startTimeout: startTimeout, - threshold: threshold, - lockChan: make(chan struct{}, 1), - fs: fs, - logger: logger.Named("listener"), - } -} - -func (listener *libreOfficeListener) start(logger *zap.Logger) error { - listener.hadFirstStartMu.Lock() - listener.hadFirstStart = true - listener.hadFirstStartMu.Unlock() - - port, err := freePort(logger) - if err != nil { - return fmt.Errorf("get free port: %w", err) - } - - userProfileDirPath := listener.fs.NewDirPath() - args := []string{ - "--headless", - "--invisible", - "--nocrashreport", - "--nodefault", - "--nologo", - "--nofirststartwizard", - "--norestore", - fmt.Sprintf("-env:UserInstallation=file://%s", userProfileDirPath), - fmt.Sprintf("--accept=socket,host=127.0.0.1,port=%d,tcpNoDelay=1;urp;StarOffice.ComponentContext", port), - } - - ctx, cancel := context.WithTimeout(context.Background(), listener.startTimeout) - defer cancel() - - cmd, err := gotenberg.CommandContext(ctx, logger, listener.binPath, args...) - if err != nil { - return fmt.Errorf("create LibreOffice listener command: %w", err) - } - - // For whatever reason, LibreOffice requires a first start before being - // able to run as a daemon. - exitCode, err := cmd.Exec() - if err != nil && exitCode != 81 { - return fmt.Errorf("execute LibreOffice listener: %w", err) - } - - logger.Debug("got exit code 81, e.g., LibreOffice listener first start") - - // Second start (daemon). - cmd = gotenberg.Command(logger, listener.binPath, args...) - - err = cmd.Start() - if err != nil { - return fmt.Errorf("start LibreOffice listener: %w", err) - } - - waitChan := make(chan error, 1) - - go func() { - // By waiting the process, we avoid the creation of a zombie process - // and make sure we catch an early exit if any. - waitChan <- cmd.Wait() - }() - - connChan := make(chan error, 1) - - go func() { - // As the LibreOffice socket may take some time to be available, we - // have to ensure that it is indeed accepting connections. - for { - if ctx.Err() != nil { - connChan <- ctx.Err() - break - } - - conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), time.Duration(1)*time.Second) - if err != nil { - continue - } - - connChan <- nil - err = conn.Close() - if err != nil { - logger.Debug(fmt.Sprintf("close connection after health checking the LibreOffice listener: %v", err)) - } - - break - } - }() - - var success bool - - defer func() { - if success { - listener.cfgMu.Lock() - listener.socketPort = port - listener.userProfileDirPath = userProfileDirPath - listener.cmd = cmd - listener.cfgMu.Unlock() - - return - } - - // Let's make sure the process is killed. - err = cmd.Kill() - if err != nil { - logger.Debug(fmt.Sprintf("kill LibreOffice listener process: %v", err)) - } - - // And the user profile directory is deleted. - err = os.RemoveAll(userProfileDirPath) - if err != nil { - logger.Debug(fmt.Sprintf("remove user profile directory: %v", err)) - } - }() - - logger.Debug("waiting for the LibreOffice listener socket to be available...") - - for { - select { - case err = <-connChan: - if err != nil { - return fmt.Errorf("LibreOffice listener socket not available: %w", err) - } - - logger.Debug("LibreOffice listener socket available") - success = true - - return nil - case err = <-waitChan: - return fmt.Errorf("LibreOffice listener process exited: %w", err) - } - } -} - -func (listener *libreOfficeListener) stop(logger *zap.Logger) error { - listener.cfgMu.RLock() - - defer func() { - defer listener.cfgMu.RUnlock() - - if listener.userProfileDirPath == "" { - return - } - - err := os.RemoveAll(listener.userProfileDirPath) - if err != nil { - logger.Error(fmt.Sprintf("remove LibreOffice listener's user profile directory: %v", err)) - } - - logger.Debug(fmt.Sprintf("'%s' LibreOffice listener's user profile directory removed", listener.userProfileDirPath)) - - // Also remove listener specific files in the temporary directory. - err = gotenberg.GarbageCollect(logger, os.TempDir(), []string{"OSL_PIPE", ".tmp"}) - if err != nil { - logger.Error(err.Error()) - } - }() - - err := listener.cmd.Kill() - if err != nil { - return fmt.Errorf("kill LibreOffice listener process: %w", err) - } - - return nil -} - -func (listener *libreOfficeListener) restart(logger *zap.Logger) error { - listener.restartingMu.Lock() - listener.restarting = true - listener.restartingMu.Unlock() - - defer func() { - listener.restartingMu.Lock() - listener.restarting = false - listener.restartingMu.Unlock() - }() - - err := listener.stop(logger) - if err != nil { - return fmt.Errorf("stop LibreOffice listener: %w", err) - } - - err = listener.start(logger) - if err != nil { - return fmt.Errorf("start LibreOffice listener: %w", err) - } - - listener.usage = 0 - - return nil -} - -func (listener *libreOfficeListener) lock(ctx context.Context, logger *zap.Logger) error { - listener.queueLengthMu.Lock() - listener.queueLength += 1 - listener.queueLengthMu.Unlock() - - defer func() { - listener.queueLengthMu.Lock() - listener.queueLength -= 1 - listener.queueLengthMu.Unlock() - }() - - doWithContext := func(ctx context.Context, do func() error) error { - doChan := make(chan error, 1) - - go func() { - doChan <- do() - }() - - for { - select { - case err := <-doChan: - return err - case <-ctx.Done(): - return ctx.Err() - } - } - } - - select { - case listener.lockChan <- struct{}{}: - logger.Debug("LibreOffice listener lock acquired") - - listener.hadFirstStartMu.RLock() - - if !listener.hadFirstStart { - listener.hadFirstStartMu.RUnlock() - - logger.Debug("starting LibreOffice listener...") - - err := doWithContext(ctx, func() error { - return listener.start(logger) - }) - - if err == nil { - return nil - } - - return fmt.Errorf("start long-running LibreOffice listener: %w", err) - } - - listener.hadFirstStartMu.RUnlock() - - if !listener.healthy() { - logger.Debug("LibreOffice listener is unhealthy, restarting it...") - - err := doWithContext(ctx, func() error { - return listener.restart(logger) - }) - - if err == nil { - return nil - } - - return fmt.Errorf("restart long-running LibreOffice listener: %w", err) - } - - return nil - case <-ctx.Done(): - logger.Debug("failed to acquire LibreOffice listener lock before deadline") - - return fmt.Errorf("acquire LibreOffice listener lock: %w", ctx.Err()) - } -} - -func (listener *libreOfficeListener) unlock(logger *zap.Logger) error { - defer func() { - <-listener.lockChan - logger.Debug("LibreOffice listener lock released") - }() - - if !listener.healthy() { - logger.Debug("LibreOffice listener is unhealthy, restarting it...") - - err := listener.restart(logger) - if err == nil { - return nil - } - - return fmt.Errorf("restart LibreOffice listener: %w", err) - } - - listener.usage += 1 - if listener.threshold > 0 && listener.usage < listener.threshold { - return nil - } - - logger.Debug("LibreOffice listener threshold reached, restarting it...") - - err := listener.restart(logger) - if err == nil { - return nil - } - - return fmt.Errorf("restart LibreOffice listener: %w", err) -} - -func (listener *libreOfficeListener) port() int { - listener.cfgMu.RLock() - defer listener.cfgMu.RUnlock() - - return listener.socketPort -} - -func (listener *libreOfficeListener) queue() int { - listener.queueLengthMu.RLock() - defer listener.queueLengthMu.RUnlock() - - return listener.queueLength -} - -func (listener *libreOfficeListener) healthy() bool { - listener.hadFirstStartMu.RLock() - defer listener.hadFirstStartMu.RUnlock() - - if !listener.hadFirstStart { - return true - } - - listener.restartingMu.RLock() - defer listener.restartingMu.RUnlock() - - if listener.restarting { - return true - } - - conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", listener.port()), time.Duration(1)*time.Second) - if err == nil { - err := conn.Close() - if err != nil { - listener.logger.Debug(fmt.Sprintf("close connection after health checking the LibreOffice listener: %v", err)) - } - - return true - } - - return false -} - -// Interface guards. -var ( - _ listener = (*libreOfficeListener)(nil) -) diff --git a/pkg/modules/libreoffice/uno/listener_test.go b/pkg/modules/libreoffice/uno/listener_test.go deleted file mode 100644 index 435f2e28..00000000 --- a/pkg/modules/libreoffice/uno/listener_test.go +++ /dev/null @@ -1,438 +0,0 @@ -package uno - -import ( - "context" - "os" - "testing" - "time" - - "go.uber.org/zap" - - "github.com/gotenberg/gotenberg/v7/pkg/gotenberg" -) - -func TestListener_start(t *testing.T) { - tests := []struct { - name string - listener listener - expectStartErr bool - }{ - { - name: "nominal behavior", - listener: newLibreOfficeListener(zap.NewNop(), gotenberg.NewFileSystem(), os.Getenv("LIBREOFFICE_BIN_PATH"), time.Duration(10)*time.Second, 10), - }, - { - name: "non-exit code 81 on first start", - listener: newLibreOfficeListener(zap.NewNop(), gotenberg.NewFileSystem(), "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(), - gotenberg.NewFileSystem(), - 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(), - gotenberg.NewFileSystem(), - 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(), gotenberg.NewFileSystem(), os.Getenv("LIBREOFFICE_BIN_PATH"), time.Duration(10)*time.Second, 10) - - err := listener.start(zap.NewNop()) - if err != nil { - t.Fatalf("expected no error from listener.start(), but got: %v", err) - } - - return listener - }(), - ctx: context.Background(), - teardown: func(listener listener) error { - return listener.stop(zap.NewNop()) - }, - }, - { - name: "first start", - listener: newLibreOfficeListener(zap.NewNop(), gotenberg.NewFileSystem(), os.Getenv("LIBREOFFICE_BIN_PATH"), time.Duration(10)*time.Second, 10), - ctx: context.Background(), - teardown: func(listener listener) error { - return listener.stop(zap.NewNop()) - }, - }, - { - name: "unhealthy listener", - listener: func() listener { - listener := newLibreOfficeListener(zap.NewNop(), gotenberg.NewFileSystem(), 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(), gotenberg.NewFileSystem(), 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(), gotenberg.NewFileSystem(), 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(), gotenberg.NewFileSystem(), 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(), gotenberg.NewFileSystem(), 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(), - gotenberg.NewFileSystem(), - 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(), - gotenberg.NewFileSystem(), - 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), - fs: gotenberg.NewFileSystem(), - logger: zap.NewNop(), - } - - // i.e., first start. - if !listener.healthy() { - t.Error("expected an healthy LibreOffice listener") - } - - err := listener.start(zap.NewNop()) - if err != nil { - t.Fatalf("expected no error from listener.start(), but got: %v", err) - } - - if !listener.healthy() { - t.Error("expected an healthy LibreOffice listener") - } - - err = listener.stop(zap.NewNop()) - if err != nil { - t.Fatalf("expected no error from listener.stop(), but got: %v", err) - } - - time.Sleep(time.Duration(1) * time.Second) - - if listener.healthy() { - t.Errorf("expected a non-healthy LibreOffice listener") - } - - listener.restarting = true - - if !listener.healthy() { - t.Error("expected an healthy LibreOffice listener") - } -} diff --git a/pkg/modules/libreoffice/uno/mocks.go b/pkg/modules/libreoffice/uno/mocks.go deleted file mode 100644 index 252b3bfa..00000000 --- a/pkg/modules/libreoffice/uno/mocks.go +++ /dev/null @@ -1,36 +0,0 @@ -package uno - -import ( - "context" - - "go.uber.org/zap" -) - -// APIMock is a mock for the API interface. -type APIMock struct { - PDFMock func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error - ExtensionsMock func() []string -} - -func (api APIMock) PDF(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error { - return api.PDFMock(ctx, logger, inputPath, outputPath, options) -} - -func (api APIMock) Extensions() []string { - return api.ExtensionsMock() -} - -// ProviderMock is a mock for the Provider interface. -type ProviderMock struct { - UNOMock func() (API, error) -} - -func (provider ProviderMock) UNO() (API, error) { - return provider.UNOMock() -} - -// Interface guards. -var ( - _ API = (*APIMock)(nil) - _ Provider = (*ProviderMock)(nil) -) diff --git a/pkg/modules/libreoffice/uno/mocks_test.go b/pkg/modules/libreoffice/uno/mocks_test.go deleted file mode 100644 index 7020e931..00000000 --- a/pkg/modules/libreoffice/uno/mocks_test.go +++ /dev/null @@ -1,42 +0,0 @@ -package uno - -import ( - "context" - "testing" - - "go.uber.org/zap" -) - -func TestAPIMock(t *testing.T) { - mock := APIMock{ - PDFMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error { - return nil - }, - ExtensionsMock: func() []string { - return nil - }, - } - - err := mock.PDF(context.Background(), zap.NewNop(), "", "", Options{}) - if err != nil { - t.Errorf("expected no error from mock.PDF(), but got: %v", err) - } - - ext := mock.Extensions() - if ext != nil { - t.Errorf("expected no extensions from mock.Extensions(), but got: %+v", ext) - } -} - -func TestProviderMock(t *testing.T) { - mock := ProviderMock{ - UNOMock: func() (API, error) { - return APIMock{}, nil - }, - } - - _, err := mock.UNO() - if err != nil { - t.Errorf("expected no error from mock.UNO(), but got: %v", err) - } -} diff --git a/pkg/modules/libreoffice/uno/uno.go b/pkg/modules/libreoffice/uno/uno.go deleted file mode 100644 index 9b32c8e7..00000000 --- a/pkg/modules/libreoffice/uno/uno.go +++ /dev/null @@ -1,485 +0,0 @@ -package uno - -import ( - "context" - "errors" - "fmt" - "os" - "sync" - "time" - - "github.com/alexliesenfeld/health" - flag "github.com/spf13/pflag" - "go.uber.org/multierr" - "go.uber.org/zap" - - "github.com/gotenberg/gotenberg/v7/pkg/gotenberg" - "github.com/gotenberg/gotenberg/v7/pkg/modules/api" -) - -func init() { - gotenberg.MustRegisterModule(UNO{}) -} - -var ( - // ErrInvalidPDFformat happens if the PDF format option cannot be handled - // by LibreOffice. - ErrInvalidPDFformat = errors.New("invalid PDF format") - - // ErrMalformedPageRanges happens if the page ranges option cannot be - // interpreted by LibreOffice. - ErrMalformedPageRanges = errors.New("page ranges are malformed") -) - -// UNO is a module which provides an API to interact with LibreOffice. -type UNO struct { - unoconvBinPath string - libreOfficeBinPath string - libreOfficeStartTimeout time.Duration - libreOfficeRestartThreshold int - - listener listener - logger *zap.Logger -} - -// Options gathers available options when converting a document to PDF. -type Options struct { - // Landscape allows to change the orientation of the resulting PDF. - // Optional. - Landscape bool - - // PageRanges allows to select the pages to convert. - // TODO: should prefer a method form PDFEngine. - // Optional. - PageRanges string - - // PDFformat allows to convert the resulting PDF to PDF/A-1a, PDF/A-2b, or - // PDF/A-3b. - // Optional. - PDFformat string -} - -// API is an abstraction on top of uno. -type API interface { - PDF(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error - Extensions() []string -} - -// Provider is a module interface which exposes a method for creating an API -// for other modules. -// -// func (m *YourModule) Provision(ctx *gotenberg.Context) error { -// provider, _ := ctx.Module(new(uno.Provider)) -// unoAPI, _ := provider.(uno.Provider).UNO() -// } -type Provider interface { - UNO() (API, error) -} - -// Descriptor returns a UNO's module descriptor. -func (UNO) Descriptor() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ - ID: "uno", - FlagSet: func() *flag.FlagSet { - fs := flag.NewFlagSet("uno", flag.ExitOnError) - fs.Duration("uno-listener-start-timeout", time.Duration(10)*time.Second, "Time limit for restarting the LibreOffice listener") - fs.Int("uno-listener-restart-threshold", 10, "Conversions limit after which the LibreOffice listener is restarted - 0 means no restart") - 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", "listener cannot be disabled") - 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 - - // Listener. - mod.listener = newLibreOfficeListener( - mod.logger, - gotenberg.NewFileSystem(), - mod.libreOfficeBinPath, - mod.libreOfficeStartTimeout, - mod.libreOfficeRestartThreshold, - ) - - return nil -} - -// Validate validates the module properties. -func (mod UNO) Validate() error { - var err error - - _, statErr := os.Stat(mod.unoconvBinPath) - if os.IsNotExist(statErr) { - err = multierr.Append(err, fmt.Errorf("unoconv binary path does not exist: %w", statErr)) - } - - _, statErr = os.Stat(mod.libreOfficeBinPath) - if os.IsNotExist(statErr) { - err = multierr.Append(err, fmt.Errorf("LibreOffice binary path does not exist: %w", statErr)) - } - - return err -} - -// Start does nothing: it is here to validate the contract from the -// gotenberg.App interface. The long-running LibreOffice Listener will be -// started on the first call to PDF. -func (mod UNO) Start() error { - return nil -} - -// StartupMessage returns a custom startup message. -func (mod UNO) StartupMessage() string { - return "long-running LibreOffice listener ready to start" -} - -// Stop stops the long-running LibreOffice Listener. -func (mod UNO) Stop(ctx context.Context) error { - // Block until the context is done so that other module may gracefully stop - // before we do a shutdown cleanup. - mod.logger.Debug("wait for the end of grace duration") - - <-ctx.Done() - - err := mod.listener.stop(mod.logger) - if err == nil { - return nil - } - - return fmt.Errorf("stop long-running LibreOffice listener: %w", err) -} - -// 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. Be cautious when making multiple concurrent -// calls, as it might lead to reaching the context's deadline. -func (mod UNO) PDF(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error { - args := []string{ - "--no-launch", - "--format", - "pdf", - } - - err := mod.listener.lock(ctx, logger) - if err != nil { - return fmt.Errorf("lock long-running LibreOffice listener: %w", err) - } - - defer func() { - go func() { - err := mod.listener.unlock(logger) - if err != nil { - mod.logger.Error(fmt.Sprintf("unlock long-running LibreOffice listener: %v", err)) - - return - } - }() - }() - - // If the LibreOffice listener is restarting while acquiring the lock, - // the port will change. It's therefore important to add the port args - // after we acquire the lock. - args = append(args, "--port", fmt.Sprintf("%d", mod.listener.port())) - - checkedEntry := logger.Check(zap.DebugLevel, "check for debug level before setting high verbosity") - if checkedEntry != nil { - args = append(args, "-vvv") - } - - if options.Landscape { - args = append(args, "--printer", "PaperOrientation=landscape") - } - - if options.PageRanges != "" { - args = append(args, "--export", fmt.Sprintf("PageRange=%s", options.PageRanges)) - } - - switch options.PDFformat { - case "": - case gotenberg.FormatPDFA1a: - args = append(args, "--export", "SelectPdfVersion=1") - case gotenberg.FormatPDFA2b: - args = append(args, "--export", "SelectPdfVersion=2") - case gotenberg.FormatPDFA3b: - args = append(args, "--export", "SelectPdfVersion=3") - default: - return ErrInvalidPDFformat - } - - args = append(args, "--output", outputPath, inputPath) - - cmd, err := gotenberg.CommandContext(ctx, logger, mod.unoconvBinPath, args...) - if err != nil { - return fmt.Errorf("create unoconv command: %w", err) - } - - logger.Debug(fmt.Sprintf("print to PDF with: %+v", options)) - - activeInstancesCountMu.Lock() - activeInstancesCount += 1 - activeInstancesCountMu.Unlock() - - exitCode, err := cmd.Exec() - - activeInstancesCountMu.Lock() - activeInstancesCount -= 1 - activeInstancesCountMu.Unlock() - - if err == nil { - return nil - } - - // Unoconv/LibreOffice errors are not explicit. - // That's why we have to make an educated guess according to the exit code - // and given inputs. - - if exitCode == 5 && options.PageRanges != "" { - return ErrMalformedPageRanges - } - - // Possible errors: - // 1. Unoconv/LibreOffice failed for some reason. - // 2. Context done. - // - // On the second scenario, LibreOffice might not have time to remove some - // of its temporary files, as it has been killed without warning. The - // garbage collector will delete them for us (if the module is loaded). - return fmt.Errorf("unoconv PDF: %w", err) -} - -// Extensions returns the file extensions available for conversions. -func (mod UNO) Extensions() []string { - return []string{ - ".bib", - ".doc", - ".xml", - ".docx", - ".fodt", - ".html", - ".ltx", - ".txt", - ".odt", - ".ott", - ".pdb", - ".pdf", - ".psw", - ".rtf", - ".sdw", - ".stw", - ".sxw", - ".uot", - ".vor", - ".wps", - ".epub", - ".png", - ".bmp", - ".emf", - ".eps", - ".fodg", - ".gif", - ".jpg", - ".jpeg", - ".met", - ".odd", - ".otg", - ".pbm", - ".pct", - ".pgm", - ".ppm", - ".ras", - ".std", - ".svg", - ".svm", - ".swf", - ".sxd", - ".sxw", - ".tif", - ".tiff", - ".xhtml", - ".xpm", - ".odp", - ".fodp", - ".potm", - ".pot", - ".pptx", - ".pps", - ".ppt", - ".pwp", - ".sda", - ".sdd", - ".sti", - ".sxi", - ".uop", - ".wmf", - ".csv", - ".dbf", - ".dif", - ".fods", - ".ods", - ".ots", - ".pxl", - ".sdc", - ".slk", - ".stc", - ".sxc", - ".uos", - ".xls", - ".xlt", - ".xlsx", - ".odg", - ".dotx", - ".xltx", - } -} - -// UNO returns an API for interacting with LibreOffice. -func (mod UNO) UNO() (API, error) { - return mod, nil -} - -var ( - listenerActiveInstancesCount float64 - listenerActiveInstancesCountMu sync.RWMutex - activeInstancesCount float64 - activeInstancesCountMu sync.RWMutex -) - -// Interface guards. -var ( - _ gotenberg.Module = (*UNO)(nil) - _ gotenberg.Provisioner = (*UNO)(nil) - _ gotenberg.Validator = (*UNO)(nil) - _ gotenberg.App = (*UNO)(nil) - _ gotenberg.MetricsProvider = (*UNO)(nil) - _ api.HealthChecker = (*UNO)(nil) - _ API = (*UNO)(nil) - _ Provider = (*UNO)(nil) -) diff --git a/pkg/modules/libreoffice/uno/uno_test.go b/pkg/modules/libreoffice/uno/uno_test.go deleted file mode 100644 index 7bd2be05..00000000 --- a/pkg/modules/libreoffice/uno/uno_test.go +++ /dev/null @@ -1,924 +0,0 @@ -package uno - -import ( - "context" - "errors" - "os" - "reflect" - "testing" - "time" - - "github.com/alexliesenfeld/health" - flag "github.com/spf13/pflag" - "go.uber.org/zap" - - "github.com/gotenberg/gotenberg/v7/pkg/gotenberg" -) - -func TestUNO_Descriptor(t *testing.T) { - descriptor := UNO{}.Descriptor() - - actual := reflect.TypeOf(descriptor.New()) - expect := reflect.TypeOf(new(UNO)) - - if actual != expect { - t.Errorf("expected '%s' but got '%s'", expect, actual) - } -} - -func TestUNO_Provision(t *testing.T) { - tests := []struct { - name string - ctx *gotenberg.Context - expectProvisionErr bool - }{ - { - name: "nominal behavior", - ctx: func() *gotenberg.Context { - provider := &struct { - gotenberg.ModuleMock - gotenberg.LoggerProviderMock - }{} - provider.DescriptorMock = func() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { - return provider - }} - } - provider.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) { - return zap.NewNop(), nil - } - - return gotenberg.NewContext( - gotenberg.ParsedFlags{ - FlagSet: new(UNO).Descriptor().FlagSet, - }, - []gotenberg.ModuleDescriptor{ - provider.Descriptor(), - }, - ) - }(), - }, - { - name: "threshold from deprecated flag --unoconv-disable-listener", - ctx: func() *gotenberg.Context { - provider := &struct { - gotenberg.ModuleMock - gotenberg.LoggerProviderMock - }{} - provider.DescriptorMock = func() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { - return provider - }} - } - provider.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) { - return zap.NewNop(), nil - } - - return gotenberg.NewContext( - gotenberg.ParsedFlags{ - FlagSet: func() *flag.FlagSet { - fs := new(UNO).Descriptor().FlagSet - err := fs.Parse([]string{"--unoconv-disable-listener=true"}) - if err != nil { - t.Fatalf("expected no error from fs.Parse(), but got: %v", err) - } - - return fs - }(), - }, - []gotenberg.ModuleDescriptor{ - provider.Descriptor(), - }, - ) - }(), - }, - { - name: "no logger provider", - ctx: func() *gotenberg.Context { - return gotenberg.NewContext( - gotenberg.ParsedFlags{ - FlagSet: new(UNO).Descriptor().FlagSet, - }, - []gotenberg.ModuleDescriptor{}, - ) - }(), - expectProvisionErr: true, - }, - { - name: "no logger from logger provider", - ctx: func() *gotenberg.Context { - provider := &struct { - gotenberg.ModuleMock - gotenberg.LoggerProviderMock - }{} - provider.DescriptorMock = func() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { - return provider - }} - } - provider.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) { - return nil, errors.New("foo") - } - - return gotenberg.NewContext( - gotenberg.ParsedFlags{ - FlagSet: new(UNO).Descriptor().FlagSet, - }, - []gotenberg.ModuleDescriptor{ - provider.Descriptor(), - }, - ) - }(), - expectProvisionErr: true, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - mod := new(UNO) - err := mod.Provision(tc.ctx) - - if tc.expectProvisionErr && err == nil { - t.Errorf("expected mod.Provision() error, but got none") - } - - if !tc.expectProvisionErr && err != nil { - t.Errorf("expected no error from mod.Provision(), but got: %v", err) - } - }) - } -} - -func TestUNO_Validate(t *testing.T) { - tests := []struct { - name string - unoconvBinPath string - libreOfficeBinPath string - expectValidateErr bool - }{ - { - name: "nominal behavior", - unoconvBinPath: os.Getenv("UNOCONV_BIN_PATH"), - libreOfficeBinPath: os.Getenv("LIBREOFFICE_BIN_PATH"), - }, - { - name: "unoconv bin path does not exist", - unoconvBinPath: "/foo", - libreOfficeBinPath: os.Getenv("LIBREOFFICE_BIN_PATH"), - expectValidateErr: true, - }, - { - name: "LibreOffice bin path does not exist", - unoconvBinPath: os.Getenv("UNOCONV_BIN_PATH"), - libreOfficeBinPath: "/foo", - expectValidateErr: true, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - mod := UNO{ - unoconvBinPath: tc.unoconvBinPath, - libreOfficeBinPath: tc.libreOfficeBinPath, - } - - err := mod.Validate() - - if tc.expectValidateErr && err == nil { - t.Errorf("expected mod.Validate() error, but got none") - } - - if !tc.expectValidateErr && err != nil { - t.Errorf("expected no error from mod.Validate(), but got: %v", err) - } - }) - } -} - -func TestUNO_Start(t *testing.T) { - mod := new(UNO) - - err := mod.Start() - if err != nil { - t.Errorf("expected no error but got: %v", err) - } -} - -func TestUNO_StartupMessage(t *testing.T) { - actual := new(UNO).StartupMessage() - expect := "long-running LibreOffice listener ready to start" - - if actual != expect { - t.Errorf("expected '%s' but got '%s'", expect, 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: "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 { - scenario string - mod UNO - ctx context.Context - logger *zap.Logger - inputPath string - options Options - expectPDFErr bool - teardown func(mod UNO) error - }{ - { - scenario: "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, - gotenberg.NewFileSystem(), - mod.libreOfficeBinPath, - mod.libreOfficeStartTimeout, - mod.libreOfficeRestartThreshold, - ) - - return mod - }(), - ctx: context.Background(), - logger: zap.NewNop(), - inputPath: "/tests/test/testdata/libreoffice/sample1.docx", - teardown: func(mod UNO) error { - ctx, cancel := context.WithCancel(context.Background()) - cancel() - - return mod.Stop(ctx) - }, - }, - //{ - // scenario: "convert with a debug logger", - // 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, - // gotenberg.NewFileSystem(), - // mod.libreOfficeBinPath, - // mod.libreOfficeStartTimeout, - // mod.libreOfficeRestartThreshold, - // ) - // - // return mod - // }(), - // ctx: context.Background(), - // logger: zap.NewExample(), - // inputPath: "/tests/test/testdata/libreoffice/sample1.docx", - // teardown: func(mod UNO) error { - // ctx, cancel := context.WithCancel(context.Background()) - // cancel() - // - // return mod.Stop(ctx) - // }, - //}, - { - scenario: "convert with landscape", - 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, - gotenberg.NewFileSystem(), - mod.libreOfficeBinPath, - mod.libreOfficeStartTimeout, - mod.libreOfficeRestartThreshold, - ) - - return mod - }(), - ctx: context.Background(), - logger: zap.NewNop(), - inputPath: "/tests/test/testdata/libreoffice/sample1.docx", - options: Options{ - Landscape: true, - }, - teardown: func(mod UNO) error { - ctx, cancel := context.WithCancel(context.Background()) - cancel() - - return mod.Stop(ctx) - }, - }, - { - scenario: "convert with page ranges", - 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, - gotenberg.NewFileSystem(), - mod.libreOfficeBinPath, - mod.libreOfficeStartTimeout, - mod.libreOfficeRestartThreshold, - ) - - return mod - }(), - ctx: context.Background(), - logger: zap.NewNop(), - inputPath: "/tests/test/testdata/libreoffice/sample1.docx", - options: Options{ - PageRanges: "1-2", - }, - teardown: func(mod UNO) error { - ctx, cancel := context.WithCancel(context.Background()) - cancel() - - return mod.Stop(ctx) - }, - }, - { - scenario: "convert with invalid page ranges", - 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, - gotenberg.NewFileSystem(), - mod.libreOfficeBinPath, - mod.libreOfficeStartTimeout, - mod.libreOfficeRestartThreshold, - ) - - return mod - }(), - ctx: context.Background(), - logger: zap.NewNop(), - inputPath: "/tests/test/testdata/libreoffice/sample1.docx", - options: Options{ - PageRanges: "foo", - }, - expectPDFErr: true, - teardown: func(mod UNO) error { - ctx, cancel := context.WithCancel(context.Background()) - cancel() - - return mod.Stop(ctx) - }, - }, - { - scenario: "convert to PDF/A-1a", - 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, - gotenberg.NewFileSystem(), - mod.libreOfficeBinPath, - mod.libreOfficeStartTimeout, - mod.libreOfficeRestartThreshold, - ) - - return mod - }(), - ctx: context.Background(), - logger: zap.NewNop(), - inputPath: "/tests/test/testdata/libreoffice/sample1.docx", - options: Options{ - PDFformat: gotenberg.FormatPDFA1a, - }, - teardown: func(mod UNO) error { - ctx, cancel := context.WithCancel(context.Background()) - cancel() - - return mod.Stop(ctx) - }, - }, - { - scenario: "convert to PDF/A-2b", - 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, - gotenberg.NewFileSystem(), - mod.libreOfficeBinPath, - mod.libreOfficeStartTimeout, - mod.libreOfficeRestartThreshold, - ) - - return mod - }(), - ctx: context.Background(), - logger: zap.NewNop(), - inputPath: "/tests/test/testdata/libreoffice/sample1.docx", - options: Options{ - PDFformat: gotenberg.FormatPDFA2b, - }, - teardown: func(mod UNO) error { - ctx, cancel := context.WithCancel(context.Background()) - cancel() - - return mod.Stop(ctx) - }, - }, - { - scenario: "convert to PDF/A-3b", - 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, - gotenberg.NewFileSystem(), - mod.libreOfficeBinPath, - mod.libreOfficeStartTimeout, - mod.libreOfficeRestartThreshold, - ) - - return mod - }(), - ctx: context.Background(), - logger: zap.NewNop(), - inputPath: "/tests/test/testdata/libreoffice/sample1.docx", - options: Options{ - PDFformat: gotenberg.FormatPDFA3b, - }, - teardown: func(mod UNO) error { - ctx, cancel := context.WithCancel(context.Background()) - cancel() - - return mod.Stop(ctx) - }, - }, - { - scenario: "convert to invalid PDF format", - 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, - gotenberg.NewFileSystem(), - mod.libreOfficeBinPath, - mod.libreOfficeStartTimeout, - mod.libreOfficeRestartThreshold, - ) - - return mod - }(), - ctx: context.Background(), - logger: zap.NewNop(), - inputPath: "/tests/test/testdata/libreoffice/sample1.docx", - options: Options{ - PDFformat: "foo", - }, - expectPDFErr: true, - teardown: func(mod UNO) error { - ctx, cancel := context.WithCancel(context.Background()) - cancel() - - return mod.Stop(ctx) - }, - }, - { - scenario: "expired context", - 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, - gotenberg.NewFileSystem(), - mod.libreOfficeBinPath, - mod.libreOfficeStartTimeout, - mod.libreOfficeRestartThreshold, - ) - - return mod - }(), - 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, - }, - { - scenario: "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, - }, - { - scenario: "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.scenario, 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) - } - }() - - fs := gotenberg.NewFileSystem() - outputDir, err := fs.MkdirAll() - if err != nil { - t.Fatalf("test %s: expected error but got: %v", tc.scenario, err) - } - - defer func() { - err := os.RemoveAll(fs.WorkingDirPath()) - if err != nil { - t.Fatalf("test %s: expected no error while cleaning up but got: %v", tc.scenario, err) - } - }() - - err = tc.mod.PDF(tc.ctx, tc.logger, tc.inputPath, outputDir+"/foo.pdf", tc.options) - - if tc.expectPDFErr && err == nil { - t.Fatalf("expected mod.PDF() error, but got none") - } - - if !tc.expectPDFErr && err != nil { - t.Fatalf("expected no error from mod.PDF(), but got: %v", err) - } - }) - } -} - -func TestUNO_Extensions(t *testing.T) { - mod := new(UNO) - extensions := mod.Extensions() - - actual := len(extensions) - expect := 79 - - if actual != expect { - t.Errorf("expected %d extensions, but got %d", expect, actual) - } -} - -func TestUNO_UNO(t *testing.T) { - mod := new(UNO) - - _, err := mod.UNO() - if err != nil { - t.Errorf("expected no error from mod.UNO(), but got: %v", err) - } -} - -type listenerMock struct { - startMock func(logger *zap.Logger) error - stopMock func(logger *zap.Logger) error - restartMock func(logger *zap.Logger) error - lockMock func(ctx context.Context, logger *zap.Logger) error - unlockMock func(logger *zap.Logger) error - portMock func() int - queueMock func() int - healthyMock func() bool -} - -func (listener listenerMock) start(logger *zap.Logger) error { - return listener.startMock(logger) -} - -func (listener listenerMock) stop(logger *zap.Logger) error { - return listener.stopMock(logger) -} - -func (listener listenerMock) restart(logger *zap.Logger) error { - return listener.restartMock(logger) -} - -func (listener listenerMock) lock(ctx context.Context, logger *zap.Logger) error { - return listener.lockMock(ctx, logger) -} - -func (listener listenerMock) unlock(logger *zap.Logger) error { - return listener.unlockMock(logger) -} - -func (listener listenerMock) port() int { - return listener.portMock() -} - -func (listener listenerMock) queue() int { - return listener.queueMock() -} - -func (listener listenerMock) healthy() bool { - return listener.healthyMock() -} - -// Interface guards. -var ( - _ listener = (*listenerMock)(nil) -) diff --git a/pkg/modules/pdfengines/pdfengines.go b/pkg/modules/pdfengines/pdfengines.go index 4c3780ac..41a0caf4 100644 --- a/pkg/modules/pdfengines/pdfengines.go +++ b/pkg/modules/pdfengines/pdfengines.go @@ -82,9 +82,9 @@ func (mod *PDFEngines) Provision(ctx *gotenberg.Context) error { mod.names = names for i, name := range names { - logger.Warn("unoconv-pdfengine is deprecated; prefer uno-pdfengine instead") + logger.Warn("unoconv-pdfengine is deprecated; prefer api-pdfengine instead") if name == "unoconv-pdfengine" { - mod.names[i] = "uno-pdfengine" + mod.names[i] = "api-pdfengine" } } diff --git a/pkg/modules/pdfengines/pdfengines_test.go b/pkg/modules/pdfengines/pdfengines_test.go index 26b830f2..96b0278b 100644 --- a/pkg/modules/pdfengines/pdfengines_test.go +++ b/pkg/modules/pdfengines/pdfengines_test.go @@ -150,7 +150,7 @@ func TestPDFEngines_Provision(t *testing.T) { gotenberg.PDFEngineMock }{} engine.DescriptorMock = func() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ID: "uno-pdfengine", New: func() gotenberg.Module { return engine }} + return gotenberg.ModuleDescriptor{ID: "api-pdfengine", New: func() gotenberg.Module { return engine }} } engine.ValidateMock = func() error { return nil @@ -172,7 +172,7 @@ func TestPDFEngines_Provision(t *testing.T) { }, ) }(), - expectPDFEngineNames: []string{"uno-pdfengine"}, + expectPDFEngineNames: []string{"api-pdfengine"}, }, { name: "no logger provider", diff --git a/pkg/standard/imports.go b/pkg/standard/imports.go index db5701ea..818a3789 100644 --- a/pkg/standard/imports.go +++ b/pkg/standard/imports.go @@ -5,8 +5,8 @@ import ( _ "github.com/gotenberg/gotenberg/v7/pkg/modules/api" _ "github.com/gotenberg/gotenberg/v7/pkg/modules/chromium" _ "github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice" + _ "github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/api" _ "github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/pdfengine" - _ "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" diff --git a/test/testdata/libreoffice/sample1.docx b/test/testdata/libreoffice/document.docx similarity index 100% rename from test/testdata/libreoffice/sample1.docx rename to test/testdata/libreoffice/document.docx diff --git a/test/testdata/libreoffice/document.txt b/test/testdata/libreoffice/document.txt new file mode 100644 index 00000000..5023b0c7 --- /dev/null +++ b/test/testdata/libreoffice/document.txt @@ -0,0 +1 @@ +This is a text from a text file. \ No newline at end of file