From 24817d57079c34fe4e6b80bd03289bf66ed34d83 Mon Sep 17 00:00:00 2001 From: Julien Neuhart Date: Mon, 7 Feb 2022 18:28:58 +0100 Subject: [PATCH] feat: add more PDF formats, drastically improve LibreOffice long-running instance management --- Makefile | 6 +- build/Dockerfile | 1 + pkg/gotenberg/cmd.go | 33 +- pkg/gotenberg/cmd_test.go | 318 ++++--- pkg/gotenberg/mocks.go | 66 ++ pkg/gotenberg/mocks_test.go | 82 ++ pkg/modules/api/context.go | 2 + pkg/modules/libreoffice/doc.go | 2 +- pkg/modules/libreoffice/libreoffice.go | 16 +- pkg/modules/libreoffice/libreoffice_test.go | 277 +++--- pkg/modules/libreoffice/pdfengine/doc.go | 5 +- .../libreoffice/pdfengine/pdfengine.go | 55 +- .../libreoffice/pdfengine/pdfengine_test.go | 231 +++-- pkg/modules/libreoffice/routes.go | 53 +- pkg/modules/libreoffice/routes_test.go | 842 +++++++++-------- pkg/modules/libreoffice/uno/doc.go | 3 + .../libreoffice/{unoconv => uno}/freeport.go | 10 +- pkg/modules/libreoffice/uno/listener.go | 248 +++++ pkg/modules/libreoffice/uno/listener_test.go | 288 ++++++ pkg/modules/libreoffice/uno/mocks.go | 36 + pkg/modules/libreoffice/uno/mocks_test.go | 42 + pkg/modules/libreoffice/uno/uno.go | 522 +++++++++++ pkg/modules/libreoffice/uno/uno_test.go | 865 ++++++++++++++++++ pkg/modules/libreoffice/unoconv/doc.go | 2 - pkg/modules/libreoffice/unoconv/unoconv.go | 482 ---------- .../libreoffice/unoconv/unoconv_test.go | 453 --------- pkg/modules/pdfengines/multi_test.go | 279 +++--- pkg/modules/pdfengines/pdfengines.go | 19 + pkg/modules/pdfengines/pdfengines_test.go | 469 ++++++---- pkg/modules/pdfengines/routes_test.go | 545 ++++++----- pkg/modules/pdftk/pdftk.go | 2 +- pkg/modules/qpdf/qpdf.go | 2 +- pkg/standard/imports.go | 2 +- test/Dockerfile | 2 +- 34 files changed, 3883 insertions(+), 2377 deletions(-) create mode 100644 pkg/gotenberg/mocks.go create mode 100644 pkg/gotenberg/mocks_test.go create mode 100644 pkg/modules/libreoffice/uno/doc.go rename pkg/modules/libreoffice/{unoconv => uno}/freeport.go (66%) create mode 100644 pkg/modules/libreoffice/uno/listener.go create mode 100644 pkg/modules/libreoffice/uno/listener_test.go create mode 100644 pkg/modules/libreoffice/uno/mocks.go create mode 100644 pkg/modules/libreoffice/uno/mocks_test.go create mode 100644 pkg/modules/libreoffice/uno/uno.go create mode 100644 pkg/modules/libreoffice/uno/uno_test.go delete mode 100644 pkg/modules/libreoffice/unoconv/doc.go delete mode 100644 pkg/modules/libreoffice/unoconv/unoconv.go delete mode 100644 pkg/modules/libreoffice/unoconv/unoconv_test.go diff --git a/Makefile b/Makefile index 6097ae19..9fec9250 100644 --- a/Makefile +++ b/Makefile @@ -51,7 +51,8 @@ PROMETHEUS_NAMESPACE=gotenberg PROMETHEUS_COLLECT_INTERVAL=1s PROMETHEUS_DISABLE_ROUTE_LOGGING=false PROMETHEUS_DISABLE_COLLECT=false -UNOCONV_DISABLE_LISTENER=false +UNO_LISTENER_START_TIMEOUT=10s +UNO_LISTENER_RESTART_THRESHOLD=10 WEBHOOK_ALLOW_LIST= WEBHOOK_DENY_LIST= WEBHOOK_ERROR_ALLOW_LIST= @@ -93,7 +94,8 @@ run: ## Start a Gotenberg container --prometheus-collect-interval=$(PROMETHEUS_COLLECT_INTERVAL) \ --prometheus-disable-route-logging=$(PROMETHEUS_DISABLE_ROUTE_LOGGING) \ --prometheus-disable-collect=$(PROMETHEUS_DISABLE_COLLECT) \ - --unoconv-disable-listener=$(UNOCONV_DISABLE_LISTENER) \ + --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 3ffe4ea3..9db5e835 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -157,6 +157,7 @@ COPY --from=builder /home/gotenberg /usr/bin/ ENV GC_EXCLUDE_SUBSTR "hsperfdata_root,hsperfdata_gotenberg" ENV CHROMIUM_BIN_PATH /usr/bin/chromium ENV UNOCONV_BIN_PATH /usr/bin/unoconv +ENV LIBREOFFICE_BIN_PATH /usr/lib/libreoffice/program/soffice.bin ENV PDFTK_BIN_PATH /usr/bin/pdftk ENV QPDF_BIN_PATH /usr/bin/qpdf diff --git a/pkg/gotenberg/cmd.go b/pkg/gotenberg/cmd.go index fa9d1a25..2e3e18d2 100644 --- a/pkg/gotenberg/cmd.go +++ b/pkg/gotenberg/cmd.go @@ -73,22 +73,37 @@ func (cmd Cmd) Start() error { return nil } +// Wait waits for the command to complete. It should be called when using the +// Start method, so that the command does not leak zombies. +func (cmd Cmd) Wait() error { + err := cmd.process.Wait() + if err != nil { + return fmt.Errorf("wait for unix process: %w", err) + } + + return nil +} + // Exec executes the command and wait for its completion or until the context // is done. In any case, it kills the unix process and all its children. -func (cmd Cmd) Exec() error { +func (cmd Cmd) Exec() (int, error) { if cmd.ctx == nil { - return errors.New("nil context") + return 10, errors.New("nil context") } err := cmd.Start() if err != nil { - return fmt.Errorf("start command: %w", err) + if cmd.process.ProcessState == nil { + return 131, fmt.Errorf("start command: %w", err) + } + + return cmd.process.ProcessState.ExitCode(), fmt.Errorf("start command: %w", err) } errChan := make(chan error, 1) go func() { - errChan <- cmd.process.Wait() + errChan <- cmd.Wait() }() select { @@ -99,17 +114,21 @@ func (cmd Cmd) Exec() error { } if err == nil { - return nil + return 0, nil } - return fmt.Errorf("unix process error: %w", err) + if cmd.process.ProcessState == nil { + return 131, fmt.Errorf("unix process error: %w", err) + } + + return cmd.process.ProcessState.ExitCode(), fmt.Errorf("unix process error: %w", err) case <-cmd.ctx.Done(): errProc := cmd.Kill() if errProc != nil { cmd.logger.Error(errProc.Error()) } - return fmt.Errorf("context done: %w", cmd.ctx.Err()) + return 62, fmt.Errorf("context done: %w", cmd.ctx.Err()) } } diff --git a/pkg/gotenberg/cmd_test.go b/pkg/gotenberg/cmd_test.go index c0c7062f..69b54d93 100644 --- a/pkg/gotenberg/cmd_test.go +++ b/pkg/gotenberg/cmd_test.go @@ -12,209 +12,313 @@ func TestCommand(t *testing.T) { cmd := Command(zap.NewNop(), "foo") if !cmd.process.SysProcAttr.Setpgid { - t.Error("expected Setpgid to be true") + t.Error("expected cmd.process.SysProcAttr.Setpgid to be true") } } func TestCommandContext(t *testing.T) { - for i, tc := range []struct { - ctx context.Context - expectErr bool + tests := []struct { + name string + ctx context.Context + expectCommandContextErr bool }{ { - ctx: nil, - expectErr: true, + name: "nominal behavior", + ctx: context.Background(), }, { - ctx: context.TODO(), + name: "nil context", + expectCommandContextErr: true, }, - } { - cmd, err := CommandContext(tc.ctx, zap.NewNop(), "foo") + } - if err == nil && !cmd.process.SysProcAttr.Setpgid { - t.Fatalf("test %d: expected Setpgid to be true", i) - } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cmd, err := CommandContext(tc.ctx, zap.NewNop(), "foo") - if tc.expectErr && err == nil { - t.Errorf("test %d: expected error but got: %v", i, err) - } + if err == nil && !cmd.process.SysProcAttr.Setpgid { + t.Fatal("expected cmd.process.SysProcAttr.Setpgid to be true") + } - if !tc.expectErr && err != nil { - t.Errorf("test %d: expected no error but got: %v", i, err) - } + if tc.expectCommandContextErr && err == nil { + t.Error("expected error from CommandContext(), but got none") + } + + if !tc.expectCommandContextErr && err != nil { + t.Errorf("expected no error from CommandContext(), but got: %v", err) + } + }) } } func TestCmd_Start(t *testing.T) { - for i, tc := range []struct { - cmd Cmd - expectErr bool + tests := []struct { + name string + cmd Cmd + expectStartErr bool }{ { - cmd: Command(zap.NewNop(), "foo"), - expectErr: true, + name: "nominal behavior", + cmd: Command(zap.NewNop(), "echo", "Hello", "World"), }, { - cmd: Command(zap.NewNop(), "echo", "Hello", "World"), + name: "start error", + cmd: Command(zap.NewNop(), "foo"), + expectStartErr: true, }, - } { - err := tc.cmd.Start() + } - if tc.expectErr && err == nil { - t.Errorf("test %d: expected error but got: %v", i, err) - } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := tc.cmd.Start() - if !tc.expectErr && err != nil { - t.Errorf("test %d: expected no error but got: %v", i, err) - } + if tc.expectStartErr && err == nil { + t.Error("expected error from cmd.Start(), but got none") + } + + if !tc.expectStartErr && err != nil { + t.Errorf("expected no error from cmd.Start(), but got: %v", err) + } + }) + } +} + +func TestCmd_Wait(t *testing.T) { + tests := []struct { + name string + cmd Cmd + expectWaitErr bool + }{ + { + name: "nominal behavior", + cmd: func() Cmd { + cmd := Command(zap.NewNop(), "echo", "Hello", "World") + + err := cmd.Start() + if err != nil { + t.Fatalf("expected no error from cmd.Start(), but got: %v", err) + } + + return cmd + }(), + }, + { + name: "wait error", + cmd: func() Cmd { + cmd := Command(zap.NewNop(), "echo", "Hello", "World") + + err := cmd.Start() + if err != nil { + t.Fatalf("expected no error from cmd.Start(), but got: %v", err) + } + + err = cmd.Kill() + if err != nil { + t.Fatalf("expected no error from cmd.Kill(), but got: %v", err) + } + + return cmd + }(), + expectWaitErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := tc.cmd.Wait() + + if tc.expectWaitErr && err == nil { + t.Error("expected error from cmd.Wait(), but got none") + } + + if !tc.expectWaitErr && err != nil { + t.Errorf("expected no error from cmd.Wait(), but got: %v", err) + } + }) } } func TestCmd_Exec(t *testing.T) { - for i, tc := range []struct { - cmd Cmd - timeout time.Duration - expectErr bool + tests := []struct { + name string + cmd Cmd + timeout time.Duration + expectExecErr bool }{ { - cmd: Command(zap.NewNop(), "foo"), - expectErr: true, + name: "nominal behavior", + cmd: func() Cmd { + cmd, err := CommandContext(context.Background(), zap.NewNop(), "echo", "Hello", "World") + if err != nil { + t.Fatalf("expected no error from CommandContext(), but got: %v", err) + } + + return cmd + }(), }, { - cmd: Command(zap.NewNop(), "foo"), - timeout: time.Duration(5) * time.Second, - expectErr: true, + name: "nil context", + cmd: Command(zap.NewNop(), "echo", "Hello", "World"), + expectExecErr: true, }, { - cmd: Command(zap.NewNop(), "echo", "Hello", "World"), - timeout: time.Duration(5) * time.Second, + name: "start error", + cmd: func() Cmd { + cmd, err := CommandContext(context.Background(), zap.NewNop(), "foo") + if err != nil { + t.Fatalf("expected no error from CommandContext(), but got: %v", err) + } + + return cmd + }(), + expectExecErr: true, }, { - cmd: Command(zap.NewNop(), "sleep", "3"), - timeout: time.Duration(2) * time.Second, - expectErr: true, + name: "context done", + cmd: Command(zap.NewNop(), "sleep", "2"), + timeout: time.Duration(1) * time.Second, + expectExecErr: true, }, - } { - if tc.timeout > 0 { - ctx, cancel := context.WithTimeout(context.TODO(), tc.timeout) - defer cancel() + } - tc.cmd.ctx = ctx - } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if tc.timeout > 0 { + ctx, cancel := context.WithTimeout(context.TODO(), tc.timeout) + defer cancel() - err := tc.cmd.Exec() + tc.cmd.ctx = ctx + } - if tc.expectErr && err == nil { - t.Errorf("test %d: expected error but got: %v", i, err) - } + _, err := tc.cmd.Exec() - if !tc.expectErr && err != nil { - t.Errorf("test %d: expected no error but got: %v", i, err) - } + if tc.expectExecErr && err == nil { + t.Error("expected error from cmd.Exec(), but got none") + } + + if !tc.expectExecErr && err != nil { + t.Errorf("expected no error from cmd.Exec(), but got: %v", err) + } + }) } } func TestCmd_pipeOutput(t *testing.T) { - for i, tc := range []struct { - cmd Cmd - run bool - expectErr bool + tests := []struct { + name string + cmd Cmd + run bool + expectPipeOutputErr bool }{ { - cmd: Command(zap.NewNop(), "echo", "Hello", "World"), + name: "nominal behavior", + cmd: Command(zap.NewExample(), "echo", "Hello", "World"), + run: true, }, { + name: "no debug, no pipe", + cmd: Command(zap.NewNop(), "echo", "Hello", "World"), + }, + { + name: "stdout already piped", cmd: func() Cmd { cmd := Command(zap.NewExample(), "echo", "Hello", "World") + _, err := cmd.process.StdoutPipe() - if err != nil { - t.Fatalf("expected no error but got: %v", err) + t.Fatalf("expected no error from cmd.process.StdoutPipe(), but got: %v", err) } return cmd }(), - expectErr: true, + expectPipeOutputErr: true, }, { + name: "stderr already piped", cmd: func() Cmd { cmd := Command(zap.NewExample(), "echo", "Hello", "World") - _, err := cmd.process.StderrPipe() + _, err := cmd.process.StderrPipe() if err != nil { - t.Fatalf("expected no error but got: %v", err) + t.Fatalf("expected no error from cmd.process.StderrPipe(), but got: %v", err) } return cmd }(), - expectErr: true, + expectPipeOutputErr: true, }, - { - cmd: Command(zap.NewExample(), "echo", "Hello", "World"), - run: true, - }, - } { - err := tc.cmd.pipeOutput() + } - if tc.run { - errStart := tc.cmd.process.Start() + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := tc.cmd.pipeOutput() - if errStart != nil { - t.Fatalf("test %d: expected no error but got: %v", i, err) + if tc.run { + errStart := tc.cmd.process.Start() + if errStart != nil { + t.Fatalf("expected no error from tc.cmd.process.Start(), but got: %v", errStart) + } } - } - if tc.expectErr && err == nil { - t.Errorf("test %d: expected error but got: %v", i, err) - } + if tc.expectPipeOutputErr && err == nil { + t.Error("expected error from cmd.pipeOutput(), but got none") + } - if !tc.expectErr && err != nil { - t.Errorf("test %d: expected no error but got: %v", i, err) - } + if !tc.expectPipeOutputErr && err != nil { + t.Errorf("expected no error from cmd.pipeOutput(), but got: %v", err) + } + }) } } func TestCmd_Kill(t *testing.T) { - for i, tc := range []struct { - cmd Cmd - expectErr bool + tests := []struct { + name string + cmd Cmd }{ { - cmd: Cmd{logger: zap.NewNop()}, - }, - { + name: "nominal behavior", cmd: func() Cmd { cmd := Command(zap.NewNop(), "sleep", "60") - err := cmd.process.Start() + err := cmd.process.Start() if err != nil { - t.Fatalf("expected no error but got: %v", err) + t.Fatalf("expected no error from cmd.process.Start(), but got: %v", err) } return cmd }(), }, { + name: "no process", + cmd: Cmd{logger: zap.NewNop()}, + }, + { + name: "process already killed", cmd: func() Cmd { - cmd := Command(zap.NewNop(), "echo", "Hello", "World") - err := cmd.process.Run() + cmd := Command(zap.NewNop(), "sleep", "60") + err := cmd.process.Start() if err != nil { - t.Fatalf("expected no error but got: %v", err) + t.Fatalf("expected no error from cmd.process.Start(), but got: %v", err) + } + + err = cmd.Kill() + if err != nil { + t.Fatalf("expected no error from cmd.Kill(), but got: %v", err) } return cmd }(), }, - } { - err := tc.cmd.Kill() + } - if tc.expectErr && err == nil { - t.Errorf("test %d: expected error but got: %v", i, err) - } - - if !tc.expectErr && err != nil { - t.Errorf("test %d: expected no error but got: %v", i, err) - } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := tc.cmd.Kill() + if err != nil { + t.Errorf("expected no error from cmd.Kill(), but got: %v", err) + } + }) } } diff --git a/pkg/gotenberg/mocks.go b/pkg/gotenberg/mocks.go new file mode 100644 index 00000000..134dbe1a --- /dev/null +++ b/pkg/gotenberg/mocks.go @@ -0,0 +1,66 @@ +package gotenberg + +import ( + "context" + + "go.uber.org/zap" +) + +// ModuleMock is a mock for the Module interface. +type ModuleMock struct { + DescriptorMock func() ModuleDescriptor +} + +func (mod ModuleMock) Descriptor() ModuleDescriptor { + return mod.DescriptorMock() +} + +// ValidatorMock is a mock for the Validator interface. +type ValidatorMock struct { + ValidateMock func() error +} + +func (mod ValidatorMock) Validate() error { + return mod.ValidateMock() +} + +// PDFEngineMock is a mock for the PDFEngine interface. +type PDFEngineMock struct { + MergeMock func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error + ConvertMock func(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error +} + +func (engine PDFEngineMock) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { + return engine.MergeMock(ctx, logger, inputPaths, outputPath) +} + +func (engine PDFEngineMock) Convert(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error { + return engine.ConvertMock(ctx, logger, format, inputPath, outputPath) +} + +// PDFEngineProviderMock is a mock for the PDFEngineProvider interface. +type PDFEngineProviderMock struct { + PDFEngineMock func() (PDFEngine, error) +} + +func (provider PDFEngineProviderMock) PDFEngine() (PDFEngine, error) { + return provider.PDFEngineMock() +} + +// LoggerProviderMock is a mock for the LoggerProvider interface. +type LoggerProviderMock struct { + LoggerMock func(mod Module) (*zap.Logger, error) +} + +func (provider LoggerProviderMock) Logger(mod Module) (*zap.Logger, error) { + return provider.LoggerMock(mod) +} + +// Interface guards. +var ( + _ Module = (*ModuleMock)(nil) + _ Validator = (*ValidatorMock)(nil) + _ PDFEngine = (*PDFEngineMock)(nil) + _ PDFEngineProvider = (*PDFEngineProviderMock)(nil) + _ LoggerProvider = (*LoggerProviderMock)(nil) +) diff --git a/pkg/gotenberg/mocks_test.go b/pkg/gotenberg/mocks_test.go new file mode 100644 index 00000000..1748c314 --- /dev/null +++ b/pkg/gotenberg/mocks_test.go @@ -0,0 +1,82 @@ +package gotenberg + +import ( + "context" + "testing" + + "go.uber.org/zap" +) + +func TestModuleMock(t *testing.T) { + mock := ModuleMock{ + DescriptorMock: func() ModuleDescriptor { + return ModuleDescriptor{ID: "foo", New: func() Module { + return nil + }} + }, + } + + if mock.Descriptor().ID != "foo" { + t.Errorf("expected ID '%s' from mock.Descriptor(), but got '%s'", "foo", mock.Descriptor().ID) + } +} + +func TestValidatorMock(t *testing.T) { + mock := ValidatorMock{ + ValidateMock: func() error { + return nil + }, + } + + err := mock.Validate() + if err != nil { + t.Errorf("expected no error from mock.Validate(), but got: %v", err) + } +} + +func TestPDFEngineMock(t *testing.T) { + mock := PDFEngineMock{ + MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { + return nil + }, + ConvertMock: func(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error { + return nil + }, + } + + err := mock.Merge(context.Background(), zap.NewNop(), nil, "") + if err != nil { + t.Errorf("expected no error from mock.Merge(), but got: %v", err) + } + + err = mock.Convert(context.Background(), zap.NewNop(), "", "", "") + if err != nil { + t.Errorf("expected no error from mock.Convert(), but got: %v", err) + } +} + +func TestPDFEngineProvider(t *testing.T) { + mock := PDFEngineProviderMock{ + PDFEngineMock: func() (PDFEngine, error) { + return PDFEngineMock{}, nil + }, + } + + _, err := mock.PDFEngine() + if err != nil { + t.Errorf("expected no error from mock.PDFEngine(), but got: %v", err) + } +} + +func TestLoggerProviderMock(t *testing.T) { + mock := LoggerProviderMock{ + LoggerMock: func(mod Module) (*zap.Logger, error) { + return nil, nil + }, + } + + _, err := mock.Logger(ModuleMock{}) + if err != nil { + t.Errorf("expected no error from mock.Logger(), but got: %v", err) + } +} diff --git a/pkg/modules/api/context.go b/pkg/modules/api/context.go index 44929e75..c1c5f7bf 100644 --- a/pkg/modules/api/context.go +++ b/pkg/modules/api/context.go @@ -277,6 +277,8 @@ func (ctx Context) OutputFilename(outputPath string) string { return fmt.Sprintf("%s%s", filename, filepath.Ext(outputPath)) } +// TODO: move MockContext to mocks.go (and rename it ContextMock). + // MockContext is a helper for tests. // // ctx := &api.MockContext{Context: &api.Context{}} diff --git a/pkg/modules/libreoffice/doc.go b/pkg/modules/libreoffice/doc.go index 763f6c96..b628b195 100644 --- a/pkg/modules/libreoffice/doc.go +++ b/pkg/modules/libreoffice/doc.go @@ -1,3 +1,3 @@ // Package libreoffice provides a module which adds a route for converting -// document to PDF with LibreOffice. +// documents to PDF with LibreOffice. package libreoffice diff --git a/pkg/modules/libreoffice/libreoffice.go b/pkg/modules/libreoffice/libreoffice.go index cf874d5c..adc70179 100644 --- a/pkg/modules/libreoffice/libreoffice.go +++ b/pkg/modules/libreoffice/libreoffice.go @@ -5,7 +5,7 @@ import ( "github.com/gotenberg/gotenberg/v7/pkg/gotenberg" "github.com/gotenberg/gotenberg/v7/pkg/modules/api" - "github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/unoconv" + "github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/uno" flag "github.com/spf13/pflag" ) @@ -16,7 +16,7 @@ func init() { // LibreOffice is a module which provides a route for converting documents to // PDF with LibreOffice. type LibreOffice struct { - unoconv unoconv.API + unoAPI uno.API engine gotenberg.PDFEngine disableRoutes bool } @@ -40,17 +40,17 @@ func (mod *LibreOffice) Provision(ctx *gotenberg.Context) error { flags := ctx.ParsedFlags() mod.disableRoutes = flags.MustBool("libreoffice-disable-routes") - provider, err := ctx.Module(new(unoconv.Provider)) + provider, err := ctx.Module(new(uno.Provider)) if err != nil { - return fmt.Errorf("get unoconv provider: %w", err) + return fmt.Errorf("get unoAPI provider: %w", err) } - uno, err := provider.(unoconv.Provider).Unoconv() + unoAPI, err := provider.(uno.Provider).UNO() if err != nil { - return fmt.Errorf("get unoconv API: %w", err) + return fmt.Errorf("get unoAPI API: %w", err) } - mod.unoconv = uno + mod.unoAPI = unoAPI provider, err = ctx.Module(new(gotenberg.PDFEngineProvider)) if err != nil { @@ -74,7 +74,7 @@ func (mod LibreOffice) Routes() ([]api.Route, error) { } return []api.Route{ - convertRoute(mod.unoconv, mod.engine), + convertRoute(mod.unoAPI, mod.engine), }, nil } diff --git a/pkg/modules/libreoffice/libreoffice_test.go b/pkg/modules/libreoffice/libreoffice_test.go index 6d1d5875..0a8cea18 100644 --- a/pkg/modules/libreoffice/libreoffice_test.go +++ b/pkg/modules/libreoffice/libreoffice_test.go @@ -1,68 +1,14 @@ package libreoffice import ( - "context" "errors" "reflect" "testing" "github.com/gotenberg/gotenberg/v7/pkg/gotenberg" - "github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/unoconv" - "go.uber.org/zap" + "github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/uno" ) -type ProtoModule struct { - descriptor func() gotenberg.ModuleDescriptor -} - -func (mod ProtoModule) Descriptor() gotenberg.ModuleDescriptor { - return mod.descriptor() -} - -type ProtoUnoconvProvider struct { - ProtoModule - unoconv func() (unoconv.API, error) -} - -func (mod ProtoUnoconvProvider) Unoconv() (unoconv.API, error) { - return mod.unoconv() -} - -type ProtoUnoconvAPI struct { - pdf func(_ context.Context, _ *zap.Logger, _, _ string, _ unoconv.Options) error - extensions func() []string -} - -func (mod ProtoUnoconvAPI) PDF(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options unoconv.Options) error { - return mod.pdf(ctx, logger, inputPath, outputPath, options) -} - -func (mod ProtoUnoconvAPI) Extensions() []string { - return mod.extensions() -} - -type ProtoPDFEngineProvider struct { - ProtoModule - pdfEngine func() (gotenberg.PDFEngine, error) -} - -func (mod ProtoPDFEngineProvider) PDFEngine() (gotenberg.PDFEngine, error) { - return mod.pdfEngine() -} - -type ProtoPDFEngine struct { - merge func(_ context.Context, _ *zap.Logger, _ []string, _ string) error - convert func(_ context.Context, _ *zap.Logger, _, _, _ string) error -} - -func (mod ProtoPDFEngine) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { - return mod.merge(ctx, logger, inputPaths, outputPath) -} - -func (mod ProtoPDFEngine) Convert(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error { - return mod.convert(ctx, logger, format, inputPath, outputPath) -} - func TestLibreOffice_Descriptor(t *testing.T) { descriptor := LibreOffice{}.Descriptor() @@ -75,15 +21,38 @@ func TestLibreOffice_Descriptor(t *testing.T) { } func TestLibreOffice_Provision(t *testing.T) { - for i, tc := range []struct { - ctx *gotenberg.Context - expectErr bool + tests := []struct { + name string + ctx *gotenberg.Context + expectProvisionErr bool }{ { + name: "nominal behavior", ctx: func() *gotenberg.Context { - mod := struct{ ProtoModule }{} - mod.descriptor = func() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }} + provider1 := struct { + gotenberg.ModuleMock + uno.ProviderMock + }{} + provider1.DescriptorMock = func() gotenberg.ModuleDescriptor { + return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { + return provider1 + }} + } + provider1.UNOMock = func() (uno.API, error) { + return uno.APIMock{}, nil + } + + provider2 := struct { + gotenberg.ModuleMock + gotenberg.PDFEngineProviderMock + }{} + provider2.DescriptorMock = func() gotenberg.ModuleDescriptor { + return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { + return provider2 + }} + } + provider2.PDFEngineMock = func() (gotenberg.PDFEngine, error) { + return gotenberg.PDFEngineMock{}, nil } return gotenberg.NewContext( @@ -91,41 +60,36 @@ func TestLibreOffice_Provision(t *testing.T) { FlagSet: new(LibreOffice).Descriptor().FlagSet, }, []gotenberg.ModuleDescriptor{ - mod.Descriptor(), + provider1.Descriptor(), + provider2.Descriptor(), }, ) }(), - expectErr: true, }, { - ctx: func() *gotenberg.Context { - mod := struct{ ProtoUnoconvProvider }{} - mod.descriptor = func() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }} - } - mod.unoconv = func() (unoconv.API, error) { - return nil, errors.New("foo") - } - - return gotenberg.NewContext( - gotenberg.ParsedFlags{ - FlagSet: new(LibreOffice).Descriptor().FlagSet, - }, - []gotenberg.ModuleDescriptor{ - mod.Descriptor(), - }, - ) - }(), - expectErr: true, + name: "no UNO API provider", + ctx: gotenberg.NewContext( + gotenberg.ParsedFlags{ + FlagSet: new(LibreOffice).Descriptor().FlagSet, + }, + []gotenberg.ModuleDescriptor{}, + ), + expectProvisionErr: true, }, { + name: "no API from UNO API provider", ctx: func() *gotenberg.Context { - mod := struct{ ProtoUnoconvProvider }{} - mod.descriptor = func() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }} + provider := struct { + gotenberg.ModuleMock + uno.ProviderMock + }{} + provider.DescriptorMock = func() gotenberg.ModuleDescriptor { + return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { + return provider + }} } - mod.unoconv = func() (unoconv.API, error) { - return struct{ ProtoUnoconvAPI }{}, nil + provider.UNOMock = func() (uno.API, error) { + return uno.APIMock{}, errors.New("foo") } return gotenberg.NewContext( @@ -133,28 +97,26 @@ func TestLibreOffice_Provision(t *testing.T) { FlagSet: new(LibreOffice).Descriptor().FlagSet, }, []gotenberg.ModuleDescriptor{ - mod.Descriptor(), + provider.Descriptor(), }, ) }(), - expectErr: true, + expectProvisionErr: true, }, { + name: "no PDF engine provider", ctx: func() *gotenberg.Context { - mod1 := struct{ ProtoUnoconvProvider }{} - mod1.descriptor = func() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod1 }} + provider := struct { + gotenberg.ModuleMock + uno.ProviderMock + }{} + provider.DescriptorMock = func() gotenberg.ModuleDescriptor { + return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { + return provider + }} } - mod1.unoconv = func() (unoconv.API, error) { - return struct{ ProtoUnoconvAPI }{}, nil - } - - mod2 := struct{ ProtoPDFEngineProvider }{} - mod2.descriptor = func() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod2 }} - } - mod2.pdfEngine = func() (gotenberg.PDFEngine, error) { - return nil, errors.New("foo") + provider.UNOMock = func() (uno.API, error) { + return uno.APIMock{}, nil } return gotenberg.NewContext( @@ -162,29 +124,39 @@ func TestLibreOffice_Provision(t *testing.T) { FlagSet: new(LibreOffice).Descriptor().FlagSet, }, []gotenberg.ModuleDescriptor{ - mod1.Descriptor(), - mod2.Descriptor(), + provider.Descriptor(), }, ) }(), - expectErr: true, + expectProvisionErr: true, }, { + name: "no PDF engine from PDF engine provider", ctx: func() *gotenberg.Context { - mod1 := struct{ ProtoUnoconvProvider }{} - mod1.descriptor = func() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod1 }} + provider1 := struct { + gotenberg.ModuleMock + uno.ProviderMock + }{} + provider1.DescriptorMock = func() gotenberg.ModuleDescriptor { + return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { + return provider1 + }} } - mod1.unoconv = func() (unoconv.API, error) { - return struct{ ProtoUnoconvAPI }{}, nil + provider1.UNOMock = func() (uno.API, error) { + return uno.APIMock{}, nil } - mod2 := struct{ ProtoPDFEngineProvider }{} - mod2.descriptor = func() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod2 }} + provider2 := struct { + gotenberg.ModuleMock + gotenberg.PDFEngineProviderMock + }{} + provider2.DescriptorMock = func() gotenberg.ModuleDescriptor { + return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { + return provider2 + }} } - mod2.pdfEngine = func() (gotenberg.PDFEngine, error) { - return struct{ ProtoPDFEngine }{}, nil + provider2.PDFEngineMock = func() (gotenberg.PDFEngine, error) { + return gotenberg.PDFEngineMock{}, errors.New("foo") } return gotenberg.NewContext( @@ -192,59 +164,60 @@ func TestLibreOffice_Provision(t *testing.T) { FlagSet: new(LibreOffice).Descriptor().FlagSet, }, []gotenberg.ModuleDescriptor{ - mod1.Descriptor(), - mod2.Descriptor(), + provider1.Descriptor(), + provider2.Descriptor(), }, ) }(), + expectProvisionErr: true, }, - } { - mod := new(LibreOffice) - err := mod.Provision(tc.ctx) + } - if tc.expectErr && err == nil { - t.Errorf("test %d: expected error but got: %v", i, err) - } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + mod := new(LibreOffice) + err := mod.Provision(tc.ctx) - if !tc.expectErr && err != nil { - t.Errorf("test %d: expected no error but got: %v", i, err) - } + if tc.expectProvisionErr && err == nil { + t.Error("expected mod.Provision() error, but got none") + } + + if !tc.expectProvisionErr && err != nil { + t.Errorf("expected no error from mod.Provision(), but got: %v", err) + } + }) } } func TestLibreOffice_Routes(t *testing.T) { - for i, tc := range []struct { - expectRoutes int - disableRoutes bool + tests := []struct { + name string + mod LibreOffice + expectRoutesCount int }{ { - expectRoutes: 1, + name: "route not disabled", + mod: LibreOffice{}, + expectRoutesCount: 1, }, { - disableRoutes: true, + name: "route disabled", + mod: LibreOffice{ + disableRoutes: true, + }, }, - } { - mod := new(LibreOffice) - mod.disableRoutes = tc.disableRoutes + } - routes, err := mod.Routes() - if err != nil { - t.Fatalf("test %d: expected no error but got: %v", i, err) - } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + routes, err := tc.mod.Routes() + if err != nil { + t.Fatalf("expected no error from mod.Routes(), but got: %v", err) + } - if tc.expectRoutes != len(routes) { - t.Errorf("test %d: expected %d routes but got %d", i, tc.expectRoutes, len(routes)) - } + if tc.expectRoutesCount != len(routes) { + t.Errorf("expected %d routes from mod.Routes(), but got %d", tc.expectRoutesCount, len(routes)) + } + }) } } - -// Interface guards. -var ( - _ gotenberg.Module = (*ProtoModule)(nil) - _ unoconv.Provider = (*ProtoUnoconvProvider)(nil) - _ gotenberg.Module = (*ProtoUnoconvProvider)(nil) - _ unoconv.API = (*ProtoUnoconvAPI)(nil) - _ gotenberg.PDFEngineProvider = (*ProtoPDFEngineProvider)(nil) - _ gotenberg.Module = (*ProtoPDFEngineProvider)(nil) - _ gotenberg.PDFEngine = (*ProtoPDFEngine)(nil) -) diff --git a/pkg/modules/libreoffice/pdfengine/doc.go b/pkg/modules/libreoffice/pdfengine/doc.go index 56c906c0..b8861337 100644 --- a/pkg/modules/libreoffice/pdfengine/doc.go +++ b/pkg/modules/libreoffice/pdfengine/doc.go @@ -1,3 +1,4 @@ -// Package pdfengine provides a module which abstracts the CLI tool unoconv and -// implements the gotenberg.PDFEngine interface. +// Package pdfengine provides a module which interacts with the UNO +// (Universal Network Objects) API and implements the gotenberg.PDFEngine +// interface. package pdfengine diff --git a/pkg/modules/libreoffice/pdfengine/pdfengine.go b/pkg/modules/libreoffice/pdfengine/pdfengine.go index 828dbd5a..c6a39ac4 100644 --- a/pkg/modules/libreoffice/pdfengine/pdfengine.go +++ b/pkg/modules/libreoffice/pdfengine/pdfengine.go @@ -2,75 +2,76 @@ package pdfengine import ( "context" + "errors" "fmt" "github.com/gotenberg/gotenberg/v7/pkg/gotenberg" - "github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/unoconv" + "github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/uno" "go.uber.org/zap" ) func init() { - gotenberg.MustRegisterModule(UnoconvPDFEngine{}) + gotenberg.MustRegisterModule(UNO{}) } -// UnoconvPDFEngine abstracts the CLI tool unoconv and implements the -// gotenberg.PDFEngine interface. -type UnoconvPDFEngine struct { - unoconv unoconv.API +// UNO interacts with the UNO (Universal Network Objects) API and implements +// the gotenberg.PDFEngine interface. +type UNO struct { + unoAPI uno.API } -// Descriptor returns a UnoconvPDFEngine's module descriptor. -func (UnoconvPDFEngine) Descriptor() gotenberg.ModuleDescriptor { +// Descriptor returns a UNO's module descriptor. +func (UNO) Descriptor() gotenberg.ModuleDescriptor { return gotenberg.ModuleDescriptor{ - ID: "unoconv-pdfengine", - New: func() gotenberg.Module { return new(UnoconvPDFEngine) }, + ID: "uno-pdfengine", + New: func() gotenberg.Module { return new(UNO) }, } } // Provision sets the module properties. -func (engine *UnoconvPDFEngine) Provision(ctx *gotenberg.Context) error { - provider, err := ctx.Module(new(unoconv.Provider)) +func (engine *UNO) Provision(ctx *gotenberg.Context) error { + provider, err := ctx.Module(new(uno.Provider)) if err != nil { return fmt.Errorf("get unoconv provider: %w", err) } - uno, err := provider.(unoconv.Provider).Unoconv() + unoAPI, err := provider.(uno.Provider).UNO() if err != nil { return fmt.Errorf("get unoconv API: %w", err) } - engine.unoconv = uno + engine.unoAPI = unoAPI return nil } // Merge is not available for this PDF engine. -func (engine UnoconvPDFEngine) Merge(_ context.Context, _ *zap.Logger, _ []string, _ string) error { +func (engine UNO) Merge(_ context.Context, _ *zap.Logger, _ []string, _ string) error { return fmt.Errorf("merge PDFs with unoconv: %w", gotenberg.ErrPDFEngineMethodNotAvailable) } // Convert converts the given PDF to a specific PDF format. Currently, only the -// PDF/A-1 format is available. If another PDF format is requested, it returns -// a gotenberg.ErrPDFFormatNotAvailable error. -func (engine UnoconvPDFEngine) Convert(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error { - if format != gotenberg.FormatPDFA1a { - return fmt.Errorf("convert PDF to '%s' with unoconv: %w", format, gotenberg.ErrPDFFormatNotAvailable) - } - - err := engine.unoconv.PDF(ctx, logger, inputPath, outputPath, unoconv.Options{ - PDFArchive: true, +// PDF/A-1a, PDF/A-2b and PDF/A-3b formats are available. If another PDF format +// is requested, it returns a gotenberg.ErrPDFFormatNotAvailable error. +func (engine UNO) Convert(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error { + err := engine.unoAPI.PDF(ctx, logger, inputPath, outputPath, uno.Options{ + PDFformat: format, }) if err == nil { return nil } + if errors.Is(err, uno.ErrInvalidPDFformat) { + return fmt.Errorf("convert PDF to '%s' with unoconv: %w", format, gotenberg.ErrPDFFormatNotAvailable) + } + return fmt.Errorf("convert PDF to '%s' with unoconv: %w", format, err) } // Interface guards. var ( - _ gotenberg.Module = (*UnoconvPDFEngine)(nil) - _ gotenberg.Provisioner = (*UnoconvPDFEngine)(nil) - _ gotenberg.PDFEngine = (*UnoconvPDFEngine)(nil) + _ gotenberg.Module = (*UNO)(nil) + _ gotenberg.Provisioner = (*UNO)(nil) + _ gotenberg.PDFEngine = (*UNO)(nil) ) diff --git a/pkg/modules/libreoffice/pdfengine/pdfengine_test.go b/pkg/modules/libreoffice/pdfengine/pdfengine_test.go index fb2787f3..46a5f636 100644 --- a/pkg/modules/libreoffice/pdfengine/pdfengine_test.go +++ b/pkg/modules/libreoffice/pdfengine/pdfengine_test.go @@ -7,191 +7,168 @@ import ( "testing" "github.com/gotenberg/gotenberg/v7/pkg/gotenberg" - "github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/unoconv" - flag "github.com/spf13/pflag" + "github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/uno" "go.uber.org/zap" ) -type ProtoModule struct { - descriptor func() gotenberg.ModuleDescriptor -} - -func (mod ProtoModule) Descriptor() gotenberg.ModuleDescriptor { - return mod.descriptor() -} - -type ProtoUnoconvProvider struct { - ProtoModule - unoconv func() (unoconv.API, error) -} - -func (mod ProtoUnoconvProvider) Unoconv() (unoconv.API, error) { - return mod.unoconv() -} - -type ProtoUnoconvAPI struct { - pdf func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options unoconv.Options) error -} - -func (mod ProtoUnoconvAPI) PDF(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options unoconv.Options) error { - return mod.pdf(ctx, logger, inputPath, outputPath, options) -} - -func (mod ProtoUnoconvAPI) Extensions() []string { - return nil -} - -func TestUnoconvPDFEngine_Descriptor(t *testing.T) { - descriptor := UnoconvPDFEngine{}.Descriptor() +func TestUNO_Descriptor(t *testing.T) { + descriptor := UNO{}.Descriptor() actual := reflect.TypeOf(descriptor.New()) - expect := reflect.TypeOf(new(UnoconvPDFEngine)) + expect := reflect.TypeOf(new(UNO)) if actual != expect { t.Errorf("expected '%s' but got '%s'", expect, actual) } } -func TestUnoconvPDFEngine_Provision(t *testing.T) { - for i, tc := range []struct { - ctx *gotenberg.Context - expectErr bool +func TestUNO_Provider(t *testing.T) { + tests := []struct { + name string + ctx *gotenberg.Context + expectProvisionErr bool }{ { + name: "nominal behavior", ctx: func() *gotenberg.Context { - mod := struct{ ProtoModule }{} - mod.descriptor = func() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }} + provider := struct { + gotenberg.ModuleMock + uno.ProviderMock + }{} + provider.DescriptorMock = func() gotenberg.ModuleDescriptor { + return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { + return provider + }} + } + provider.UNOMock = func() (uno.API, error) { + return uno.APIMock{}, nil } return gotenberg.NewContext( gotenberg.ParsedFlags{ - FlagSet: flag.NewFlagSet("foo", flag.ExitOnError), + FlagSet: new(UNO).Descriptor().FlagSet, }, []gotenberg.ModuleDescriptor{ - mod.Descriptor(), + provider.Descriptor(), }, ) }(), - expectErr: true, }, { - ctx: func() *gotenberg.Context { - mod := struct{ ProtoUnoconvProvider }{} - mod.descriptor = func() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }} - } - mod.unoconv = func() (unoconv.API, error) { - return nil, errors.New("foo") - } - - return gotenberg.NewContext( - gotenberg.ParsedFlags{ - FlagSet: flag.NewFlagSet("foo", flag.ExitOnError), - }, - []gotenberg.ModuleDescriptor{ - mod.Descriptor(), - }, - ) - }(), - expectErr: true, + name: "no UNO API provider", + ctx: gotenberg.NewContext( + gotenberg.ParsedFlags{ + FlagSet: new(UNO).Descriptor().FlagSet, + }, + []gotenberg.ModuleDescriptor{}, + ), + expectProvisionErr: true, }, { + name: "no API from UNO API provider", ctx: func() *gotenberg.Context { - mod := struct{ ProtoUnoconvProvider }{} - mod.descriptor = func() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }} + provider := struct { + gotenberg.ModuleMock + uno.ProviderMock + }{} + provider.DescriptorMock = func() gotenberg.ModuleDescriptor { + return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { + return provider + }} } - mod.unoconv = func() (unoconv.API, error) { - return struct{ ProtoUnoconvAPI }{}, nil + provider.UNOMock = func() (uno.API, error) { + return uno.APIMock{}, errors.New("foo") } return gotenberg.NewContext( gotenberg.ParsedFlags{ - FlagSet: flag.NewFlagSet("foo", flag.ExitOnError), + FlagSet: new(UNO).Descriptor().FlagSet, }, []gotenberg.ModuleDescriptor{ - mod.Descriptor(), + provider.Descriptor(), }, ) }(), + expectProvisionErr: true, }, - } { - mod := new(UnoconvPDFEngine) - err := mod.Provision(tc.ctx) + } - if tc.expectErr && err == nil { - t.Errorf("test %d: expected error but got: %v", i, err) - } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + mod := new(UNO) + err := mod.Provision(tc.ctx) - if !tc.expectErr && err != nil { - t.Errorf("test %d: expected no error but got: %v", i, err) - } + if tc.expectProvisionErr && err == nil { + t.Error("expected mod.Provision() error, but got none") + } + + if !tc.expectProvisionErr && err != nil { + t.Errorf("expected no error from mod.Provision(), but got: %v", err) + } + }) } } -func TestUnoconvPDFEngine_Merge(t *testing.T) { - mod := new(UnoconvPDFEngine) - err := mod.Merge(context.TODO(), zap.NewNop(), nil, "") +func TestUNO_Merge(t *testing.T) { + mod := new(UNO) + err := mod.Merge(context.Background(), zap.NewNop(), nil, "") if !errors.Is(err, gotenberg.ErrPDFEngineMethodNotAvailable) { - t.Errorf("expected error %v, but got: %v", gotenberg.ErrPDFEngineMethodNotAvailable, err) + t.Errorf("expected error %v from mod.Merge(), but got: %v", gotenberg.ErrPDFEngineMethodNotAvailable, err) } } -func TestUnoconvPDFEngine_Convert(t *testing.T) { - for i, tc := range []struct { - api unoconv.API - format string - expectErr bool +func TestUNO_Convert(t *testing.T) { + tests := []struct { + name string + mod UNO + expectConvertErr bool }{ { - format: "", - expectErr: true, + name: "nominal behavior", + mod: UNO{ + unoAPI: uno.APIMock{ + PDFMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options uno.Options) error { + return nil + }, + }, + }, }, { - api: func() unoconv.API { - unoconvAPI := struct{ ProtoUnoconvAPI }{} - unoconvAPI.pdf = func(_ context.Context, _ *zap.Logger, _, __ string, _ unoconv.Options) error { - return errors.New("foo") - } - - return unoconvAPI - }(), - format: gotenberg.FormatPDFA1a, - expectErr: true, + name: "invalid PDF format", + mod: UNO{ + unoAPI: uno.APIMock{ + PDFMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options uno.Options) error { + return uno.ErrInvalidPDFformat + }, + }, + }, + expectConvertErr: true, }, { - api: func() unoconv.API { - unoconvAPI := struct{ ProtoUnoconvAPI }{} - unoconvAPI.pdf = func(_ context.Context, _ *zap.Logger, _, __ string, _ unoconv.Options) error { - return nil - } - - return unoconvAPI - }(), - format: gotenberg.FormatPDFA1a, + name: "convert fail", + mod: UNO{ + unoAPI: uno.APIMock{ + PDFMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options uno.Options) error { + return errors.New("foo") + }, + }, + }, + expectConvertErr: true, }, - } { - mod := new(UnoconvPDFEngine) - mod.unoconv = tc.api + } - err := mod.Convert(context.TODO(), zap.NewNop(), tc.format, "", "") + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := tc.mod.Convert(context.Background(), zap.NewNop(), "", "", "") - if tc.expectErr && err == nil { - t.Errorf("test %d: expected error but got: %v", i, err) - } + if tc.expectConvertErr && err == nil { + t.Errorf("expected mod.Convert() error, but got none") + } - if !tc.expectErr && err != nil { - t.Errorf("test %d: expected no error but got: %v", i, err) - } + if !tc.expectConvertErr && err != nil { + t.Fatalf("expected no error from mod.Convert(), but got: %v", err) + } + }) } } - -// Interface guards. -var ( - _ gotenberg.Module = (*ProtoModule)(nil) - _ unoconv.Provider = (*ProtoUnoconvProvider)(nil) - _ gotenberg.Module = (*ProtoUnoconvProvider)(nil) - _ unoconv.API = (*ProtoUnoconvAPI)(nil) -) diff --git a/pkg/modules/libreoffice/routes.go b/pkg/modules/libreoffice/routes.go index 70cc01e4..12915761 100644 --- a/pkg/modules/libreoffice/routes.go +++ b/pkg/modules/libreoffice/routes.go @@ -7,13 +7,13 @@ import ( "github.com/gotenberg/gotenberg/v7/pkg/gotenberg" "github.com/gotenberg/gotenberg/v7/pkg/modules/api" - "github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/unoconv" + "github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/uno" "github.com/labstack/echo/v4" ) // convertRoute returns an api.Route which can convert LibreOffice documents // to PDF. -func convertRoute(uno unoconv.API, engine gotenberg.PDFEngine) api.Route { +func convertRoute(unoAPI uno.API, engine gotenberg.PDFEngine) api.Route { return api.Route{ Method: http.MethodPost, Path: "/forms/libreoffice/convert", @@ -27,15 +27,17 @@ func convertRoute(uno unoconv.API, engine gotenberg.PDFEngine) api.Route { landscape bool nativePageRanges string nativePDFA1aFormat bool + nativePDFformat string PDFformat string merge bool ) err := ctx.FormData(). - MandatoryPaths(uno.Extensions(), &inputPaths). + MandatoryPaths(unoAPI.Extensions(), &inputPaths). Bool("landscape", &landscape, false). String("nativePageRanges", &nativePageRanges, ""). Bool("nativePdfA1aFormat", &nativePDFA1aFormat, false). + String("nativePdfFormat", &nativePDFformat, ""). String("pdfFormat", &PDFformat, ""). Bool("merge", &merge, false). Validate() @@ -44,13 +46,35 @@ func convertRoute(uno unoconv.API, engine gotenberg.PDFEngine) api.Route { return fmt.Errorf("validate form data: %w", err) } + if nativePDFA1aFormat { + ctx.Log().Warn("'nativePdfA1aFormat' is deprecated; prefer 'nativePdfFormat' or 'pdfFormat' form fields instead") + } + + if nativePDFA1aFormat && nativePDFformat != "" { + return api.WrapError( + errors.New("got both 'nativePdfFormat' and 'nativePdfA1aFormat' form fields"), + api.NewSentinelHTTPError(http.StatusBadRequest, "Both 'nativePdfFormat' and 'nativePdfA1aFormat' form fields are provided"), + ) + } + if nativePDFA1aFormat && PDFformat != "" { return api.WrapError( - errors.New("got both 'pdfFormat' and 'nativePdfA1aFormat' form values"), - api.NewSentinelHTTPError(http.StatusBadRequest, "Both 'pdfFormat' and 'nativePdfA1aFormat' form values are provided"), + errors.New("got both 'pdfFormat' and 'nativePdfA1aFormat' form fields"), + api.NewSentinelHTTPError(http.StatusBadRequest, "Both 'pdfFormat' and 'nativePdfA1aFormat' form fields are provided"), ) } + if nativePDFformat != "" && PDFformat != "" { + return api.WrapError( + errors.New("got both 'pdfFormat' and 'nativePdfFormat' form fields"), + api.NewSentinelHTTPError(http.StatusBadRequest, "Both 'pdfFormat' and 'nativePdfFormat' form fields are provided"), + ) + } + + if nativePDFA1aFormat { + nativePDFformat = gotenberg.FormatPDFA1a + } + // Alright, let's convert each document to PDF. outputPaths := make([]string, len(inputPaths)) @@ -58,16 +82,16 @@ func convertRoute(uno unoconv.API, engine gotenberg.PDFEngine) api.Route { for i, inputPath := range inputPaths { outputPaths[i] = ctx.GeneratePath(".pdf") - options := unoconv.Options{ + options := uno.Options{ Landscape: landscape, PageRanges: nativePageRanges, - PDFArchive: nativePDFA1aFormat, + PDFformat: nativePDFformat, } - err = uno.PDF(ctx, ctx.Log(), inputPath, outputPaths[i], options) + err = unoAPI.PDF(ctx, ctx.Log(), inputPath, outputPaths[i], options) if err != nil { - if errors.Is(err, unoconv.ErrMalformedPageRanges) { + if errors.Is(err, uno.ErrMalformedPageRanges) { return api.WrapError( fmt.Errorf("convert to PDF: %w", err), api.NewSentinelHTTPError(http.StatusBadRequest, fmt.Sprintf("Malformed page ranges '%s' (nativePageRanges)", options.PageRanges)), @@ -92,10 +116,8 @@ func convertRoute(uno unoconv.API, engine gotenberg.PDFEngine) api.Route { // Now, let's check if the client want to convert this result // PDF to a specific PDF format. - // Note: nativePdfA1aFormat has not been specified if we reach - // this part of the code. Indeed, the handler returns early on - // an error if both nativePdfA1aFormat and pdfFormat are - // present. + // Note: nativePdfA1aFormat/nativePdfFormat have not been + // specified if PDFformat is not empty. if PDFformat != "" { convertInputPath := outputPath @@ -135,9 +157,8 @@ func convertRoute(uno unoconv.API, engine gotenberg.PDFEngine) api.Route { // Ok, we don't have to merge the PDFs. Let's check if the client // want to convert each PDF to a specific PDF format. - // Note: nativePdfA1aFormat has not been specified if we reach this - // part of the code. Indeed, the handler returns early on an error - // if both nativePdfA1aFormat and pdfFormat are present. + // Note: nativePdfA1aFormat/nativePdfFormat have not been + // specified if PDFformat is not empty. if PDFformat != "" { convertOutputPaths := make([]string, len(outputPaths)) diff --git a/pkg/modules/libreoffice/routes_test.go b/pkg/modules/libreoffice/routes_test.go index 54618270..10e8234d 100644 --- a/pkg/modules/libreoffice/routes_test.go +++ b/pkg/modules/libreoffice/routes_test.go @@ -8,15 +8,16 @@ import ( "github.com/gotenberg/gotenberg/v7/pkg/gotenberg" "github.com/gotenberg/gotenberg/v7/pkg/modules/api" - "github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/unoconv" + "github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/uno" "github.com/labstack/echo/v4" "go.uber.org/zap" ) func TestConvertHandler(t *testing.T) { - for i, tc := range []struct { + tests := []struct { + name string ctx *api.MockContext - api unoconv.API + unoAPI uno.API engine gotenberg.PDFEngine expectErr bool expectHTTPErr bool @@ -24,25 +25,122 @@ func TestConvertHandler(t *testing.T) { expectOutputPathsCount int }{ { - ctx: &api.MockContext{Context: &api.Context{}}, - api: func() unoconv.API { - unoconvAPI := struct{ ProtoUnoconvAPI }{} - unoconvAPI.extensions = func() []string { - return []string{ - ".foo", - } - } + name: "nominal behavior", + ctx: func() *api.MockContext { + ctx := &api.MockContext{Context: &api.Context{}} + ctx.SetFiles(map[string]string{ + "foo.docx": "/foo/foo.docx", + }) - return unoconvAPI + 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", + } + }, + }, + expectOutputPathsCount: 1, + }, + { + name: "nominal behavior, but with 3 documents", + ctx: func() *api.MockContext { + ctx := &api.MockContext{Context: &api.Context{}} + ctx.SetFiles(map[string]string{ + "foo.docx": "/foo/foo.docx", + "bar.docx": "/bar/bar.docx", + "baz.docx": "/baz/baz.docx", + }) + + 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", + } + }, + }, + expectOutputPathsCount: 3, + }, + { + name: "cannot add output paths", + ctx: func() *api.MockContext { + ctx := &api.MockContext{Context: &api.Context{}} + ctx.SetFiles(map[string]string{ + "foo.docx": "/foo/foo.docx", + }) + ctx.SetCancelled(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", + } + }, + }, + expectErr: true, + }, + { + name: "invalid form data: no documents", + ctx: &api.MockContext{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", ctx: func() *api.MockContext { ctx := &api.MockContext{Context: &api.Context{}} + ctx.SetFiles(map[string]string{ + "foo.docx": "/foo/foo.docx", + }) + ctx.SetValues(map[string][]string{ + "nativePdfA1aFormat": { + "true", + }, + "nativePdfFormat": { + 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 nativePdfA1aFormat and pdfFormat are set", + ctx: func() *api.MockContext { + ctx := &api.MockContext{Context: &api.Context{}} ctx.SetFiles(map[string]string{ "foo.docx": "/foo/foo.docx", }) @@ -51,303 +149,260 @@ func TestConvertHandler(t *testing.T) { "true", }, "pdfFormat": { - "foo", + 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.MockContext { + ctx := &api.MockContext{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 }(), - api: func() unoconv.API { - unoconvAPI := struct{ ProtoUnoconvAPI }{} - unoconvAPI.extensions = func() []string { + unoAPI: uno.APIMock{ + ExtensionsMock: func() []string { return []string{ ".docx", } - } - - return unoconvAPI - }(), + }, + }, expectErr: true, expectHTTPErr: true, expectHTTPStatus: http.StatusBadRequest, }, { + name: "convert to PDF fail", ctx: func() *api.MockContext { ctx := &api.MockContext{Context: &api.Context{}} - ctx.SetFiles(map[string]string{ "foo.docx": "/foo/foo.docx", }) return ctx }(), - api: func() unoconv.API { - unoconvAPI := struct{ ProtoUnoconvAPI }{} - unoconvAPI.pdf = func(_ context.Context, _ *zap.Logger, _, _ string, _ unoconv.Options) error { - return unoconv.ErrMalformedPageRanges - } - unoconvAPI.extensions = func() []string { - return []string{ - ".docx", - } - } - - return unoconvAPI - }(), - expectErr: true, - expectHTTPErr: true, - expectHTTPStatus: http.StatusBadRequest, - }, - { - ctx: func() *api.MockContext { - ctx := &api.MockContext{Context: &api.Context{}} - - ctx.SetFiles(map[string]string{ - "foo.docx": "/foo/foo.docx", - }) - - return ctx - }(), - api: func() unoconv.API { - unoconvAPI := struct{ ProtoUnoconvAPI }{} - unoconvAPI.pdf = func(_ context.Context, _ *zap.Logger, _, _ string, _ unoconv.Options) error { + unoAPI: uno.APIMock{ + PDFMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options uno.Options) error { return errors.New("foo") - } - unoconvAPI.extensions = func() []string { + }, + ExtensionsMock: func() []string { return []string{ ".docx", } - } - - return unoconvAPI - }(), + }, + }, expectErr: true, }, { + name: "invalid page ranges", ctx: func() *api.MockContext { ctx := &api.MockContext{Context: &api.Context{}} - ctx.SetFiles(map[string]string{ "foo.docx": "/foo/foo.docx", - "bar.docx": "/foo/bar.docx", - }) - ctx.SetValues(map[string][]string{ - "merge": { - "true", - }, }) return ctx }(), - api: func() unoconv.API { - unoconvAPI := struct{ ProtoUnoconvAPI }{} - unoconvAPI.pdf = func(_ context.Context, _ *zap.Logger, _, _ string, _ unoconv.Options) error { - return nil - } - unoconvAPI.extensions = func() []string { + 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", } - } - - return unoconvAPI - }(), - engine: func() gotenberg.PDFEngine { - return &ProtoPDFEngine{ - merge: func(_ context.Context, _ *zap.Logger, _ []string, _ string) error { - return errors.New("foo") - }, - } - }(), - expectErr: true, - }, - { - ctx: func() *api.MockContext { - ctx := &api.MockContext{Context: &api.Context{}} - - ctx.SetFiles(map[string]string{ - "foo.docx": "/foo/foo.docx", - "bar.docx": "/foo/bar.docx", - }) - ctx.SetValues(map[string][]string{ - "merge": { - "true", - }, - "pdfFormat": { - "foo", - }, - }) - - return ctx - }(), - api: func() unoconv.API { - unoconvAPI := struct{ ProtoUnoconvAPI }{} - unoconvAPI.pdf = func(_ context.Context, _ *zap.Logger, _, _ string, _ unoconv.Options) error { - return nil - } - unoconvAPI.extensions = func() []string { - return []string{ - ".docx", - } - } - - return unoconvAPI - }(), - engine: func() gotenberg.PDFEngine { - return &ProtoPDFEngine{ - merge: func(_ context.Context, _ *zap.Logger, _ []string, _ string) error { - return nil - }, - convert: func(_ context.Context, _ *zap.Logger, _, _, _ string) error { - return gotenberg.ErrPDFFormatNotAvailable - }, - } - }(), + }, + }, expectErr: true, expectHTTPErr: true, expectHTTPStatus: http.StatusBadRequest, }, { + name: "convert 3 documents and merge them", ctx: func() *api.MockContext { ctx := &api.MockContext{Context: &api.Context{}} - ctx.SetFiles(map[string]string{ "foo.docx": "/foo/foo.docx", - "bar.docx": "/foo/bar.docx", + "bar.docx": "/bar/bar.docx", + "baz.docx": "/baz/baz.docx", }) ctx.SetValues(map[string][]string{ "merge": { "true", }, - "pdfFormat": { - "foo", - }, }) return ctx }(), - api: func() unoconv.API { - unoconvAPI := struct{ ProtoUnoconvAPI }{} - unoconvAPI.pdf = func(_ context.Context, _ *zap.Logger, _, _ string, _ unoconv.Options) error { + unoAPI: uno.APIMock{ + PDFMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options uno.Options) error { return nil - } - unoconvAPI.extensions = func() []string { + }, + ExtensionsMock: func() []string { return []string{ ".docx", } - } - - return unoconvAPI - }(), - engine: func() gotenberg.PDFEngine { - return &ProtoPDFEngine{ - merge: func(_ context.Context, _ *zap.Logger, _ []string, _ string) error { - return nil - }, - convert: func(_ context.Context, _ *zap.Logger, _, _, _ string) error { - return errors.New("foo") - }, - } - }(), - expectErr: true, - }, - { - ctx: func() *api.MockContext { - ctx := &api.MockContext{Context: &api.Context{}} - - ctx.SetCancelled(true) - ctx.SetFiles(map[string]string{ - "foo.docx": "/foo/foo.docx", - "bar.docx": "/foo/bar.docx", - }) - ctx.SetValues(map[string][]string{ - "merge": { - "true", - }, - "pdfFormat": { - "foo", - }, - }) - - return ctx - }(), - api: func() unoconv.API { - unoconvAPI := struct{ ProtoUnoconvAPI }{} - unoconvAPI.pdf = func(_ context.Context, _ *zap.Logger, _, _ string, _ unoconv.Options) error { + }, + }, + engine: gotenberg.PDFEngineMock{ + MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { return nil - } - unoconvAPI.extensions = func() []string { - return []string{ - ".docx", - } - } - - return unoconvAPI - }(), - engine: func() gotenberg.PDFEngine { - return &ProtoPDFEngine{ - merge: func(_ context.Context, _ *zap.Logger, _ []string, _ string) error { - return nil - }, - convert: func(_ context.Context, _ *zap.Logger, _, _, _ string) error { - return nil - }, - } - }(), - expectErr: true, - }, - { - ctx: func() *api.MockContext { - ctx := &api.MockContext{Context: &api.Context{}} - - ctx.SetFiles(map[string]string{ - "foo.docx": "/foo/foo.docx", - "bar.docx": "/foo/bar.docx", - }) - ctx.SetValues(map[string][]string{ - "merge": { - "true", - }, - "pdfFormat": { - "foo", - }, - }) - - return ctx - }(), - api: func() unoconv.API { - unoconvAPI := struct{ ProtoUnoconvAPI }{} - unoconvAPI.pdf = func(_ context.Context, _ *zap.Logger, _, _ string, _ unoconv.Options) error { - return nil - } - unoconvAPI.extensions = func() []string { - return []string{ - ".docx", - } - } - - return unoconvAPI - }(), - engine: func() gotenberg.PDFEngine { - return &ProtoPDFEngine{ - merge: func(_ context.Context, _ *zap.Logger, _ []string, _ string) error { - return nil - }, - convert: func(_ context.Context, _ *zap.Logger, _, _, _ string) error { - return nil - }, - } - }(), + }, + }, expectOutputPathsCount: 1, }, { + name: "merge fail", ctx: func() *api.MockContext { ctx := &api.MockContext{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.MockContext { + ctx := &api.MockContext{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 + }, + }, + expectOutputPathsCount: 1, + }, + { + name: "convert 3 documents, merge them, but convert them to PDF format fail", + ctx: func() *api.MockContext { + ctx := &api.MockContext{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 errors.New("foo") + }, + }, + expectErr: true, + }, + { + name: "convert 3 documents, merge them, but PDF format not available", + ctx: func() *api.MockContext { + ctx := &api.MockContext{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": { "foo", }, @@ -355,123 +410,166 @@ func TestConvertHandler(t *testing.T) { return ctx }(), - api: func() unoconv.API { - unoconvAPI := struct{ ProtoUnoconvAPI }{} - unoconvAPI.pdf = func(_ context.Context, _ *zap.Logger, _, _ string, _ unoconv.Options) error { + unoAPI: uno.APIMock{ + PDFMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options uno.Options) error { return nil - } - unoconvAPI.extensions = func() []string { + }, + ExtensionsMock: func() []string { return []string{ ".docx", } - } - - return unoconvAPI - }(), - engine: func() gotenberg.PDFEngine { - return &ProtoPDFEngine{ - merge: func(_ context.Context, _ *zap.Logger, _ []string, _ string) error { - return nil - }, - convert: func(_ context.Context, _ *zap.Logger, _, _, _ string) error { - return gotenberg.ErrPDFFormatNotAvailable - }, - } - }(), + }, + }, + engine: gotenberg.PDFEngineMock{ + MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { + return nil + }, + ConvertMock: func(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error { + return gotenberg.ErrPDFFormatNotAvailable + }, + }, expectErr: true, expectHTTPErr: true, expectHTTPStatus: http.StatusBadRequest, }, { + name: "convert 3 documents and merge them, but cannot add output paths", ctx: func() *api.MockContext { ctx := &api.MockContext{Context: &api.Context{}} - ctx.SetFiles(map[string]string{ "foo.docx": "/foo/foo.docx", - "bar.docx": "/foo/bar.docx", + "bar.docx": "/bar/bar.docx", + "baz.docx": "/baz/baz.docx", }) ctx.SetValues(map[string][]string{ - "pdfFormat": { - "foo", + "merge": { + "true", }, }) - - return ctx - }(), - api: func() unoconv.API { - unoconvAPI := struct{ ProtoUnoconvAPI }{} - unoconvAPI.pdf = func(_ context.Context, _ *zap.Logger, _, _ string, _ unoconv.Options) error { - return nil - } - unoconvAPI.extensions = func() []string { - return []string{ - ".docx", - } - } - - return unoconvAPI - }(), - engine: func() gotenberg.PDFEngine { - return &ProtoPDFEngine{ - merge: func(_ context.Context, _ *zap.Logger, _ []string, _ string) error { - return nil - }, - convert: func(_ context.Context, _ *zap.Logger, _, _, _ string) error { - return errors.New("foo") - }, - } - }(), - expectErr: true, - }, - { - ctx: func() *api.MockContext { - ctx := &api.MockContext{Context: &api.Context{}} - ctx.SetCancelled(true) - ctx.SetFiles(map[string]string{ - "foo.docx": "/foo/foo.docx", - "bar.docx": "/foo/bar.docx", - }) - ctx.SetValues(map[string][]string{ - "pdfFormat": { - "foo", - }, - }) return ctx }(), - api: func() unoconv.API { - unoconvAPI := struct{ ProtoUnoconvAPI }{} - unoconvAPI.pdf = func(_ context.Context, _ *zap.Logger, _, _ string, _ unoconv.Options) error { + unoAPI: uno.APIMock{ + PDFMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options uno.Options) error { return nil - } - unoconvAPI.extensions = func() []string { + }, + ExtensionsMock: func() []string { return []string{ ".docx", } - } - - return unoconvAPI - }(), - engine: func() gotenberg.PDFEngine { - return &ProtoPDFEngine{ - merge: func(_ context.Context, _ *zap.Logger, _ []string, _ string) error { - return nil - }, - convert: func(_ context.Context, _ *zap.Logger, _, _, _ string) error { - return nil - }, - } - }(), + }, + }, + engine: gotenberg.PDFEngineMock{ + MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { + return nil + }, + }, expectErr: true, }, { + name: "convert to PDF format", + ctx: func() *api.MockContext { + ctx := &api.MockContext{Context: &api.Context{}} + ctx.SetFiles(map[string]string{ + "foo.docx": "/foo/foo.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.MockContext { + ctx := &api.MockContext{Context: &api.Context{}} + ctx.SetFiles(map[string]string{ + "foo.docx": "/foo/foo.docx", + }) + ctx.SetValues(map[string][]string{ + "nativePdfA1aFormat": { + "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 { + 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 fail", + ctx: func() *api.MockContext { + ctx := &api.MockContext{Context: &api.Context{}} + ctx.SetFiles(map[string]string{ + "foo.docx": "/foo/foo.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 errors.New("foo") + }, + }, + expectErr: true, + }, + { + name: "PDF format not available", ctx: func() *api.MockContext { ctx := &api.MockContext{Context: &api.Context{}} - ctx.SetFiles(map[string]string{ "foo.docx": "/foo/foo.docx", - "bar.docx": "/foo/bar.docx", }) ctx.SetValues(map[string][]string{ "pdfFormat": { @@ -481,65 +579,63 @@ func TestConvertHandler(t *testing.T) { return ctx }(), - api: func() unoconv.API { - unoconvAPI := struct{ ProtoUnoconvAPI }{} - unoconvAPI.pdf = func(_ context.Context, _ *zap.Logger, _, _ string, _ unoconv.Options) error { + unoAPI: uno.APIMock{ + PDFMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options uno.Options) error { return nil - } - unoconvAPI.extensions = func() []string { + }, + ExtensionsMock: func() []string { return []string{ ".docx", } - } - - return unoconvAPI - }(), - engine: func() gotenberg.PDFEngine { - return &ProtoPDFEngine{ - merge: func(_ context.Context, _ *zap.Logger, _ []string, _ string) error { - return nil - }, - convert: func(_ context.Context, _ *zap.Logger, _, _, _ string) error { - return nil - }, - } - }(), - expectOutputPathsCount: 2, + }, + }, + engine: gotenberg.PDFEngineMock{ + ConvertMock: func(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error { + return gotenberg.ErrPDFFormatNotAvailable + }, + }, + expectErr: true, + expectHTTPErr: true, + expectHTTPStatus: http.StatusBadRequest, }, - } { - c := echo.New().NewContext(nil, nil) - c.Set("context", tc.ctx.Context) + } - err := convertRoute(tc.api, tc.engine).Handler(c) + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + c := echo.New().NewContext(nil, nil) + c.Set("context", tc.ctx.Context) - if tc.expectErr && err == nil { - t.Errorf("test %d: expected error but got: %v", i, err) - } + err := convertRoute(tc.unoAPI, tc.engine).Handler(c) - if !tc.expectErr && err != nil { - t.Errorf("test %d: expected no error but got: %v", i, err) - } - - var httpErr api.HTTPError - isHTTPErr := errors.As(err, &httpErr) - - if tc.expectHTTPErr && !isHTTPErr { - t.Errorf("test %d: expected HTTP error but got: %v", i, err) - } - - if !tc.expectHTTPErr && isHTTPErr { - t.Errorf("test %d: expected no HTTP error but got one: %v", i, httpErr) - } - - if err != nil && tc.expectHTTPErr && isHTTPErr { - status, _ := httpErr.HTTPError() - if status != tc.expectHTTPStatus { - t.Errorf("test %d: expected %d HTTP status code but got %d", i, tc.expectHTTPStatus, status) + if tc.expectErr && err == nil { + t.Fatal("expected error from convert handler, but got none") } - } - if tc.expectOutputPathsCount != len(tc.ctx.OutputPaths()) { - t.Errorf("test %d: expected %d output paths but got %d", i, tc.expectOutputPathsCount, len(tc.ctx.OutputPaths())) - } + if !tc.expectErr && err != nil { + t.Fatalf("expected no error from convert handler, but got: %v", err) + } + + var httpErr api.HTTPError + isHTTPErr := errors.As(err, &httpErr) + + if tc.expectHTTPErr && !isHTTPErr { + t.Errorf("expected HTTP error from convert handler, but got: %v", err) + } + + if !tc.expectHTTPErr && isHTTPErr { + t.Errorf("expected no HTTP error from convert handler, but got one: %v", httpErr) + } + + if err != nil && tc.expectHTTPErr && isHTTPErr { + status, _ := httpErr.HTTPError() + if status != tc.expectHTTPStatus { + t.Errorf("expected %d HTTP status code from convert handler, but got %d", tc.expectHTTPStatus, status) + } + } + + if tc.expectOutputPathsCount != len(tc.ctx.OutputPaths()) { + t.Errorf("expected %d output paths from convert handler, but got %d", tc.expectOutputPathsCount, len(tc.ctx.OutputPaths())) + } + }) } } diff --git a/pkg/modules/libreoffice/uno/doc.go b/pkg/modules/libreoffice/uno/doc.go new file mode 100644 index 00000000..7e38fd41 --- /dev/null +++ b/pkg/modules/libreoffice/uno/doc.go @@ -0,0 +1,3 @@ +// Package uno provides a module which interacts with the UNO +// (Universal Network Objects) API. +package uno diff --git a/pkg/modules/libreoffice/unoconv/freeport.go b/pkg/modules/libreoffice/uno/freeport.go similarity index 66% rename from pkg/modules/libreoffice/unoconv/freeport.go rename to pkg/modules/libreoffice/uno/freeport.go index c926f789..1af4984c 100644 --- a/pkg/modules/libreoffice/unoconv/freeport.go +++ b/pkg/modules/libreoffice/uno/freeport.go @@ -1,4 +1,4 @@ -package unoconv +package uno import ( "fmt" @@ -9,18 +9,18 @@ import ( ) func freePort(logger *zap.Logger) (int, error) { - listener, err := net.Listen("tcp", "127.0.0.1:0") + netListener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { return 0, fmt.Errorf("listen on the local network address: %w", err) } defer func() { - err := listener.Close() + err := netListener.Close() if err != nil { - logger.Error(fmt.Sprintf("close listener: %s", err.Error())) + logger.Error(fmt.Sprintf("close network listener: %s", err.Error())) } }() - addr := listener.Addr().String() + addr := netListener.Addr().String() _, portStr, err := net.SplitHostPort(addr) if err != nil { diff --git a/pkg/modules/libreoffice/uno/listener.go b/pkg/modules/libreoffice/uno/listener.go new file mode 100644 index 00000000..2d789b0b --- /dev/null +++ b/pkg/modules/libreoffice/uno/listener.go @@ -0,0 +1,248 @@ +package uno + +import ( + "context" + "fmt" + "net" + "os" + "sync" + "time" + + "github.com/gotenberg/gotenberg/v7/pkg/gotenberg" + "go.uber.org/zap" +) + +type listener interface { + start(logger *zap.Logger) error + stop(logger *zap.Logger) error + lock(ctx context.Context, logger *zap.Logger) error + unlock(logger *zap.Logger) error + port() int + queue() int + healthy() bool +} + +type libreOfficeListener struct { + binPath string + startTimeout time.Duration + threshold int + + socketPort int + userProfileDirPath string + cmd gotenberg.Cmd + cfgMu sync.RWMutex + + usage int + queueLength int + queueLengthMu sync.RWMutex + lockChan chan struct{} + logger *zap.Logger +} + +func newLibreOfficeListener(logger *zap.Logger, binPath string, startTimeout time.Duration, threshold int) listener { + return &libreOfficeListener{ + binPath: binPath, + startTimeout: startTimeout, + threshold: threshold, + lockChan: make(chan struct{}, 1), + logger: logger.Named("listener"), + } +} + +func (listener *libreOfficeListener) start(logger *zap.Logger) error { + port, err := freePort(logger) + if err != nil { + return fmt.Errorf("get free port: %w", err) + } + + // Good to know: when the supervisor manages the LibreOffice listener, + // the garbage collector might delete the next directory while it is + // still running. It does seem to cause any issue though. + userProfileDirPath := gotenberg.NewDirPath() + + args := []string{ + "--headless", + "--invisible", + "--nocrashreport", + "--nodefault", + "--nologo", + "--nofirststartwizard", + "--norestore", + fmt.Sprintf("-env:UserInstallation=file://%s", userProfileDirPath), + fmt.Sprintf("--accept=socket,host=127.0.0.1,port=%d,tcpNoDelay=1;urp;StarOffice.ComponentContext", port), + } + + ctx, cancel := context.WithTimeout(context.Background(), listener.startTimeout) + defer cancel() + + cmd, err := gotenberg.CommandContext(ctx, logger, listener.binPath, args...) + if err != nil { + return fmt.Errorf("create LibreOffice listener command: %w", err) + } + + // For whatever reason, LibreOffice requires a first start before being + // able to run as a daemon. + exitCode, err := cmd.Exec() + if err != nil && exitCode != 81 { + return fmt.Errorf("execute LibreOffice listener: %w", err) + } + + logger.Debug("got exit code 81, e.g., LibreOffice listener first start") + + // Second start (daemon). + cmd = gotenberg.Command(logger, listener.binPath, args...) + + err = cmd.Start() + if err != nil { + return fmt.Errorf("start LibreOffice listener: %w", err) + } + + // As the LibreOffice socket may take some time to be available, we have to + // ensure that it is indeed accepting connections. + logger.Debug("waiting for the LibreOffice listener socket to be available...") + + for { + if ctx.Err() != nil { + return fmt.Errorf("waiting for the LibreOffice listener socket to be available: %w", ctx.Err()) + } + + _, err = net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), time.Duration(1)*time.Second) + if err == nil { + break + } + } + + logger.Debug("LibreOffice listener socket available") + + listener.cfgMu.Lock() + listener.socketPort = port + listener.userProfileDirPath = userProfileDirPath + listener.cmd = cmd + listener.cfgMu.Unlock() + + return nil +} + +func (listener *libreOfficeListener) stop(logger *zap.Logger) error { + listener.cfgMu.RLock() + + defer func() { + defer listener.cfgMu.RUnlock() + + err := os.RemoveAll(listener.userProfileDirPath) + if err != nil { + logger.Error(fmt.Sprintf("remove LibreOffice listener user profile directory: %v", err)) + } + }() + + err := listener.cmd.Kill() + if err != nil { + return fmt.Errorf("kill LibreOffice listener process: %w", err) + } + + // Let's wait to make sure the process is no more. + err = listener.cmd.Wait() + if err != nil { + logger.Debug(fmt.Sprintf("wait for the LibreOffice listener: %v", err)) + } + + return nil +} + +func (listener *libreOfficeListener) lock(ctx context.Context, logger *zap.Logger) error { + listener.queueLengthMu.Lock() + listener.queueLength += 1 + listener.queueLengthMu.Unlock() + + select { + case listener.lockChan <- struct{}{}: + logger.Debug("LibreOffice listener lock acquired") + + listener.queueLengthMu.Lock() + listener.queueLength -= 1 + listener.queueLengthMu.Unlock() + + return nil + case <-ctx.Done(): + logger.Debug("failed to acquire LibreOffice listener lock before deadline") + + listener.queueLengthMu.Lock() + listener.queueLength -= 1 + listener.queueLengthMu.Unlock() + + return fmt.Errorf("acquire LibreOffice listener lock: %w", ctx.Err()) + } +} + +func (listener *libreOfficeListener) unlock(logger *zap.Logger) error { + restart := func() error { + 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 + } + + defer func() { + <-listener.lockChan + logger.Debug("LibreOffice listener lock released") + }() + + if !listener.healthy() { + logger.Debug("LibreOffice listener is unhealthy, restarting it...") + + err := restart() + if err == nil { + return nil + } + + return fmt.Errorf("restart LibreOffice listener: %w", err) + } + + listener.usage += 1 + if listener.usage < listener.threshold { + return nil + } + + logger.Debug("LibreOffice listener threshold reached, restarting it...") + + err := restart() + 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 { + _, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", listener.port()), time.Duration(1)*time.Second) + + return err == nil +} + +// Interface guards. +var ( + _ listener = (*libreOfficeListener)(nil) +) diff --git a/pkg/modules/libreoffice/uno/listener_test.go b/pkg/modules/libreoffice/uno/listener_test.go new file mode 100644 index 00000000..a2153c0f --- /dev/null +++ b/pkg/modules/libreoffice/uno/listener_test.go @@ -0,0 +1,288 @@ +package uno + +import ( + "context" + "errors" + "os" + "testing" + "time" + + "go.uber.org/zap" +) + +func TestListener_start(t *testing.T) { + tests := []struct { + name string + listener listener + expectStartErr bool + }{ + { + name: "nominal behavior", + listener: newLibreOfficeListener(zap.NewNop(), os.Getenv("LIBREOFFICE_BIN_PATH"), time.Duration(10)*time.Second, 10), + }, + { + name: "non-exit code 81 on first start", + listener: newLibreOfficeListener(zap.NewNop(), "foo", time.Duration(10)*time.Second, 10), + expectStartErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := tc.listener.start(zap.NewNop()) + + if tc.expectStartErr && err == nil { + t.Fatalf("expected listener.start() error, but got none") + } + + if !tc.expectStartErr && err != nil { + t.Fatalf("expected no error from listener.start(), but got: %v", err) + } + + if err != nil { + if tc.listener.healthy() { + t.Error("expected a non-running LibreOffice listener") + } + + return + } + + err = tc.listener.stop(zap.NewNop()) + if err != nil { + t.Fatalf("expected no error from listener.stop(), but got: %v", err) + } + }) + } +} + +func TestListener_stop(t *testing.T) { + listener := newLibreOfficeListener( + zap.NewNop(), + os.Getenv("LIBREOFFICE_BIN_PATH"), + time.Duration(10)*time.Second, + 10, + ) + + err := listener.start(zap.NewNop()) + if err != nil { + t.Fatalf("expected no error from listener.start(), but got: %v", err) + } + + err = listener.stop(zap.NewNop()) + if err != nil { + t.Errorf("expected no error from listener.stop(), but got: %v", err) + } +} + +func TestListener_lock(t *testing.T) { + listener := newLibreOfficeListener( + zap.NewNop(), + os.Getenv("LIBREOFFICE_BIN_PATH"), + time.Duration(10)*time.Second, + 10, + ) + + 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) + } + + cancel() + + err = listener.lock(ctx, zap.NewNop()) + if !errors.Is(err, context.Canceled) { + t.Errorf("expected %v error, but got: %v", context.Canceled, err) + } +} + +func TestListener_unlock(t *testing.T) { + tests := []struct { + name string + listener listener + teardown func(listener listener) error + }{ + { + name: "nominal behavior", + listener: func() listener { + listener := newLibreOfficeListener(zap.NewNop(), os.Getenv("LIBREOFFICE_BIN_PATH"), time.Duration(10)*time.Second, 10) + + err := listener.start(zap.NewNop()) + if err != nil { + t.Fatalf("expected no error from listener.start(), but got: %v", err) + } + return listener + }(), + teardown: func(listener listener) error { + return listener.stop(zap.NewNop()) + }, + }, + { + name: "unhealthy listener", + listener: func() listener { + listener := newLibreOfficeListener(zap.NewNop(), os.Getenv("LIBREOFFICE_BIN_PATH"), time.Duration(10)*time.Second, 10) + + err := listener.start(zap.NewNop()) + if err != nil { + t.Fatalf("expected no error from listener.start(), but got: %v", err) + } + + err = listener.stop(zap.NewNop()) + if err != nil { + t.Fatalf("expected no error from listener.stop(), but got: %v", err) + } + + return listener + }(), + teardown: func(listener listener) error { + return listener.stop(zap.NewNop()) + }, + }, + { + name: "threshold reached", + listener: func() listener { + listener := newLibreOfficeListener(zap.NewNop(), os.Getenv("LIBREOFFICE_BIN_PATH"), time.Duration(10)*time.Second, 1) + + err := listener.start(zap.NewNop()) + if err != nil { + t.Fatalf("expected no error from listener.start(), but got: %v", err) + } + return listener + }(), + teardown: func(listener listener) error { + return listener.stop(zap.NewNop()) + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(10)*time.Second) + defer cancel() + + err := tc.listener.lock(ctx, zap.NewNop()) + if err != nil { + t.Fatalf("expected no error from listener.lock(), 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) + } + + err = tc.teardown(tc.listener) + if err != nil { + t.Errorf("expected no error from tc.teardown(), but got: %v", err) + } + }) + } +} + +func TestListener_port(t *testing.T) { + listener := newLibreOfficeListener( + zap.NewNop(), + os.Getenv("LIBREOFFICE_BIN_PATH"), + time.Duration(10)*time.Second, + 10, + ) + + err := listener.start(zap.NewNop()) + if err != nil { + t.Fatalf("expected no error from listener.start(), but got: %v", err) + } + + port := listener.port() + if port == 0 { + t.Error("expected a non-zero value from listener.port") + } + + err = listener.stop(zap.NewNop()) + if err != nil { + t.Errorf("expected no error from listener.stop(), but got: %v", err) + } +} + +func TestListener_queue(t *testing.T) { + listener := newLibreOfficeListener( + zap.NewNop(), + os.Getenv("LIBREOFFICE_BIN_PATH"), + time.Duration(10)*time.Second, + 10, + ) + + 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 := newLibreOfficeListener( + zap.NewNop(), + os.Getenv("LIBREOFFICE_BIN_PATH"), + time.Duration(10)*time.Second, + 10, + ) + + err := listener.start(zap.NewNop()) + if err != nil { + t.Fatalf("expected no error from listener.start(), but got: %v", err) + } + + if !listener.healthy() { + t.Error("expected an healthy LibreOffice listener") + } + + err = listener.stop(zap.NewNop()) + if err != nil { + t.Fatalf("expected no error from listener.stop(), but got: %v", err) + } + + if listener.healthy() { + t.Errorf("expected a non-healthy LibreOffice listener") + } +} diff --git a/pkg/modules/libreoffice/uno/mocks.go b/pkg/modules/libreoffice/uno/mocks.go new file mode 100644 index 00000000..252b3bfa --- /dev/null +++ b/pkg/modules/libreoffice/uno/mocks.go @@ -0,0 +1,36 @@ +package uno + +import ( + "context" + + "go.uber.org/zap" +) + +// APIMock is a mock for the API interface. +type APIMock struct { + PDFMock func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error + ExtensionsMock func() []string +} + +func (api APIMock) PDF(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error { + return api.PDFMock(ctx, logger, inputPath, outputPath, options) +} + +func (api APIMock) Extensions() []string { + return api.ExtensionsMock() +} + +// ProviderMock is a mock for the Provider interface. +type ProviderMock struct { + UNOMock func() (API, error) +} + +func (provider ProviderMock) UNO() (API, error) { + return provider.UNOMock() +} + +// Interface guards. +var ( + _ API = (*APIMock)(nil) + _ Provider = (*ProviderMock)(nil) +) diff --git a/pkg/modules/libreoffice/uno/mocks_test.go b/pkg/modules/libreoffice/uno/mocks_test.go new file mode 100644 index 00000000..7020e931 --- /dev/null +++ b/pkg/modules/libreoffice/uno/mocks_test.go @@ -0,0 +1,42 @@ +package uno + +import ( + "context" + "testing" + + "go.uber.org/zap" +) + +func TestAPIMock(t *testing.T) { + mock := APIMock{ + PDFMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error { + return nil + }, + ExtensionsMock: func() []string { + return nil + }, + } + + err := mock.PDF(context.Background(), zap.NewNop(), "", "", Options{}) + if err != nil { + t.Errorf("expected no error from mock.PDF(), but got: %v", err) + } + + ext := mock.Extensions() + if ext != nil { + t.Errorf("expected no extensions from mock.Extensions(), but got: %+v", ext) + } +} + +func TestProviderMock(t *testing.T) { + mock := ProviderMock{ + UNOMock: func() (API, error) { + return APIMock{}, nil + }, + } + + _, err := mock.UNO() + if err != nil { + t.Errorf("expected no error from mock.UNO(), but got: %v", err) + } +} diff --git a/pkg/modules/libreoffice/uno/uno.go b/pkg/modules/libreoffice/uno/uno.go new file mode 100644 index 00000000..88de6e96 --- /dev/null +++ b/pkg/modules/libreoffice/uno/uno.go @@ -0,0 +1,522 @@ +package uno + +import ( + "context" + "errors" + "fmt" + "os" + "sync" + "time" + + "github.com/alexliesenfeld/health" + "github.com/gotenberg/gotenberg/v7/pkg/gotenberg" + "github.com/gotenberg/gotenberg/v7/pkg/modules/api" + flag "github.com/spf13/pflag" + "go.uber.org/multierr" + "go.uber.org/zap" +) + +func init() { + gotenberg.MustRegisterModule(UNO{}) +} + +var ( + // ErrInvalidPDFformat happens if the PDF format option cannot be handled + // by LibreOffice. + ErrInvalidPDFformat = errors.New("invalid PDF format") + + // ErrMalformedPageRanges happens if the page ranges option cannot be + // interpreted by LibreOffice. + ErrMalformedPageRanges = errors.New("page ranges are malformed") +) + +// UNO is a module which provides an API to interact with LibreOffice. +type UNO struct { + unoconvBinPath string + libreOfficeBinPath string + libreOfficeStartTimeout time.Duration + libreOfficeRestartThreshold int + + listener listener + logger *zap.Logger +} + +// Options gathers available options when converting a document to PDF. +type Options struct { + // Landscape allows to change the orientation of the resulting PDF. + // Optional. + Landscape bool + + // PageRanges allows to select the pages to convert. + // TODO: should prefer a method form PDFEngine. + // Optional. + PageRanges string + + // PDFformat allows to convert the resulting PDF to PDF/A-1a, PDF/A-2b, or + // PDF/A-3b. + // Optional. + PDFformat string +} + +// API is an abstraction on top of uno. +type API interface { + PDF(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error + Extensions() []string +} + +// Provider is a module interface which exposes a method for creating an API +// for other modules. +// +// func (m *YourModule) Provision(ctx *gotenberg.Context) error { +// provider, _ := ctx.Module(new(uno.Provider)) +// unoAPI, _ := provider.(uno.Provider).UNO() +// } +type Provider interface { + UNO() (API, error) +} + +// Descriptor returns a UNO's module descriptor. +func (UNO) Descriptor() gotenberg.ModuleDescriptor { + return gotenberg.ModuleDescriptor{ + ID: "uno", + FlagSet: func() *flag.FlagSet { + fs := flag.NewFlagSet("uno", flag.ExitOnError) + fs.Duration("uno-listener-start-timeout", time.Duration(10)*time.Second, "Time limit for starting the LibreOffice listener") + fs.Int("uno-listener-restart-threshold", 10, "Operations limit after which the LibreOffice listener is restarted - 0 means no long-running LibreOffice listener") + fs.Bool("unoconv-disable-listener", false, "Do not start a long-running listener - save resources in detriment of unitary performance") + + err := fs.MarkDeprecated("unoconv-disable-listener", "use uno-listener-restart-threshold with 0 instead") + if err != nil { + panic(fmt.Errorf("create deprecated flags for the uno module: %v", err)) + } + + return fs + }(), + New: func() gotenberg.Module { return new(UNO) }, + } +} + +// Provision sets the module properties. It returns an error if the environment +// variables UNOCONV_BIN_PATH and LIBREOFFICE_BIN_PATH are not set. +func (mod *UNO) Provision(ctx *gotenberg.Context) error { + flags := ctx.ParsedFlags() + mod.libreOfficeStartTimeout = flags.MustDuration("uno-listener-start-timeout") + mod.libreOfficeRestartThreshold = flags.MustInt("uno-listener-restart-threshold") + + disableListener := flags.MustBool("unoconv-disable-listener") + if disableListener { + mod.libreOfficeRestartThreshold = 0 + } + + unoconvBinPath, ok := os.LookupEnv("UNOCONV_BIN_PATH") + if !ok { + return errors.New("UNOCONV_BIN_PATH environment variable is not set") + } + + mod.unoconvBinPath = unoconvBinPath + + libreOfficeBinPath, ok := os.LookupEnv("LIBREOFFICE_BIN_PATH") + if !ok { + return errors.New("LIBREOFFICE_BIN_PATH environment variable is not set") + } + + mod.libreOfficeBinPath = libreOfficeBinPath + + loggerProvider, err := ctx.Module(new(gotenberg.LoggerProvider)) + if err != nil { + return fmt.Errorf("get logger provider: %w", err) + } + + logger, err := loggerProvider.(gotenberg.LoggerProvider).Logger(mod) + if err != nil { + return fmt.Errorf("get logger: %w", err) + } + + mod.logger = logger + + mod.listener = newLibreOfficeListener( + mod.logger, + mod.libreOfficeBinPath, + mod.libreOfficeStartTimeout, + mod.libreOfficeRestartThreshold, + ) + + return nil +} + +// Validate validates the module properties. +func (mod UNO) Validate() error { + var err error + + _, statErr := os.Stat(mod.unoconvBinPath) + if os.IsNotExist(statErr) { + err = multierr.Append(err, fmt.Errorf("unoconv binary path does not exist: %w", statErr)) + } + + _, statErr = os.Stat(mod.libreOfficeBinPath) + if os.IsNotExist(statErr) { + err = multierr.Append(err, fmt.Errorf("LibreOffice binary path does not exist: %w", statErr)) + } + + return err +} + +// Start starts the long-running LibreOffice listener if the threshold is +// superior to zero. +func (mod UNO) Start() error { + if mod.libreOfficeRestartThreshold == 0 { + return nil + } + + err := mod.listener.start(mod.logger) + if err == nil { + return nil + } + + return fmt.Errorf("start long-running LibreOffice listener: %w", err) +} + +// StartupMessage returns a custom startup message. +func (mod UNO) StartupMessage() string { + if mod.libreOfficeRestartThreshold == 0 { + return "Long-running LibreOffice listener disabled" + } + + return "Long-running LibreOffice listener started" +} + +// Stop stops the long-running LibreOffice Listener if it exists. +func (mod UNO) Stop(ctx context.Context) error { + if mod.libreOfficeRestartThreshold == 0 { + return nil + } + + // Block until the context is done so that other module may gracefully stop + // before we do a shutdown cleanup. + mod.logger.Debug("wait for the end of grace duration") + + <-ctx.Done() + + err := mod.listener.stop(mod.logger) + if err == nil { + return nil + } + + return fmt.Errorf("stop long-running LibreOffice supervisor") +} + +// Metrics returns the metrics. +func (mod UNO) Metrics() ([]gotenberg.Metric, error) { + return []gotenberg.Metric{ + { + Name: "unoconv_active_instances_count", + Description: "Current number of active unoconv instances.", + Read: func() float64 { + activeInstancesCountMu.RLock() + defer activeInstancesCountMu.RUnlock() + + return activeInstancesCount + }, + }, + { + Name: "libreoffice_listener_active_instances_count", + Description: "Current number of active LibreOffice listener instances.", + Read: func() float64 { + if mod.libreOfficeRestartThreshold == 0 { + listenerActiveInstancesCountMu.RLock() + defer listenerActiveInstancesCountMu.RUnlock() + + return listenerActiveInstancesCount + } + + if mod.listener.healthy() { + return 1 + } + + return 0 + }, + }, + { + Name: "unoconv_listener_active_instances_count", + Description: "Current number of active unoconv listener instances - deprecated, prefer libreoffice_listener_active_instances_count.", + Read: func() float64 { + if mod.libreOfficeRestartThreshold == 0 { + listenerActiveInstancesCountMu.RLock() + defer listenerActiveInstancesCountMu.RUnlock() + + return listenerActiveInstancesCount + } + + if mod.listener.healthy() { + return 1 + } + + return 0 + }, + }, + { + Name: "libreoffice_listener_queue_length", + Description: "Current number of processes in the 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") + }, + // The long-running LibreOffice listener may be restarting, so we + // wait a given amount of time until we consider the module + // unavailable. + MaxTimeInError: mod.libreOfficeStartTimeout, + }), + }, nil +} + +// PDF converts a document to PDF. +// +// If there is no long-running LibreOffice listener, it creates a dedicated +// LibreOffice instance for the conversion. Substantial calls to this method +// may increase CPU and memory usage drastically +// +// If there is a long-running LibreOffice listener, the conversion performance +// improves substantially. However, it cannot perform parallel operations. +func (mod UNO) PDF(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error { + args := []string{ + "--no-launch", + "--format", + "pdf", + } + + switch mod.libreOfficeRestartThreshold { + case 0: + listener := newLibreOfficeListener(logger, mod.libreOfficeBinPath, mod.libreOfficeStartTimeout, 0) + + err := listener.start(logger) + if err != nil { + return fmt.Errorf("start LibreOffice listener: %w", err) + } + + defer func() { + err := listener.stop(logger) + if err != nil { + logger.Error(fmt.Sprintf("stop LibreOffice listener: %v", err)) + } + }() + + args = append(args, "--port", fmt.Sprintf("%d", listener.port())) + default: + err := mod.listener.lock(ctx, logger) + if err != nil { + return fmt.Errorf("lock long-running LibreOffice listener: %w", err) + } + + defer func() { + go func() { + err := mod.listener.unlock(logger) + if err != nil { + mod.logger.Error(fmt.Sprintf("unlock long-running LibreOffice listener: %v", err)) + } + }() + }() + + // If the LibreOffice listener is restarting while acquiring the lock, + // the port will change. It's therefore important to add the port args + // after we acquire the lock. + args = append(args, "--port", fmt.Sprintf("%d", mod.listener.port())) + } + + checkedEntry := logger.Check(zap.DebugLevel, "check for debug level before setting high verbosity") + if checkedEntry != nil { + args = append(args, "-vvv") + } + + if options.Landscape { + args = append(args, "--printer", "PaperOrientation=landscape") + } + + if options.PageRanges != "" { + args = append(args, "--export", fmt.Sprintf("PageRange=%s", options.PageRanges)) + } + + switch options.PDFformat { + case "": + case gotenberg.FormatPDFA1a: + args = append(args, "--export", "SelectPdfVersion=1") + case gotenberg.FormatPDFA2b: + args = append(args, "--export", "SelectPdfVersion=2") + case gotenberg.FormatPDFA3b: + args = append(args, "--export", "SelectPdfVersion=3") + default: + return ErrInvalidPDFformat + } + + args = append(args, "--output", outputPath, inputPath) + + cmd, err := gotenberg.CommandContext(ctx, logger, mod.unoconvBinPath, args...) + if err != nil { + return fmt.Errorf("create unoconv command: %w", err) + } + + logger.Debug(fmt.Sprintf("print to PDF with: %+v", options)) + + activeInstancesCountMu.Lock() + activeInstancesCount += 1 + activeInstancesCountMu.Unlock() + + exitCode, err := cmd.Exec() + + activeInstancesCountMu.Lock() + activeInstancesCount -= 1 + activeInstancesCountMu.Unlock() + + if err == nil { + return nil + } + + // Unoconv/LibreOffice errors are not explicit. + // That's why we have to make an educated guess according to the exit code + // and given inputs. + + if exitCode == 5 && options.PageRanges != "" { + return ErrMalformedPageRanges + } + + // Possible errors: + // 1. Unoconv/LibreOffice failed for some reason. + // 2. Context done. + // + // On the second scenario, LibreOffice might not have time to remove some + // of its temporary files, as it has been killed without warning. The + // garbage collector will delete them for us (if the module is loaded). + return fmt.Errorf("unoconv PDF: %w", err) +} + +// Extensions returns the file extensions available for conversions. +func (mod UNO) Extensions() []string { + return []string{ + ".bib", + ".doc", + ".xml", + ".docx", + ".fodt", + ".html", + ".ltx", + ".txt", + ".odt", + ".ott", + ".pdb", + ".pdf", + ".psw", + ".rtf", + ".sdw", + ".stw", + ".sxw", + ".uot", + ".vor", + ".wps", + ".epub", + ".png", + ".bmp", + ".emf", + ".eps", + ".fodg", + ".gif", + ".jpg", + ".jpeg", + ".met", + ".odd", + ".otg", + ".pbm", + ".pct", + ".pgm", + ".ppm", + ".ras", + ".std", + ".svg", + ".svm", + ".swf", + ".sxd", + ".sxw", + ".tif", + ".tiff", + ".xhtml", + ".xpm", + ".odp", + ".fodp", + ".potm", + ".pot", + ".pptx", + ".pps", + ".ppt", + ".pwp", + ".sda", + ".sdd", + ".sti", + ".sxi", + ".uop", + ".wmf", + ".csv", + ".dbf", + ".dif", + ".fods", + ".ods", + ".ots", + ".pxl", + ".sdc", + ".slk", + ".stc", + ".sxc", + ".uos", + ".xls", + ".xlt", + ".xlsx", + } +} + +// UNO returns an API for interacting with LibreOffice. +func (mod UNO) UNO() (API, error) { + return mod, nil +} + +var ( + listenerActiveInstancesCount float64 + listenerActiveInstancesCountMu sync.RWMutex + activeInstancesCount float64 + activeInstancesCountMu sync.RWMutex +) + +// Interface guards. +var ( + _ gotenberg.Module = (*UNO)(nil) + _ gotenberg.Provisioner = (*UNO)(nil) + _ gotenberg.Validator = (*UNO)(nil) + _ gotenberg.App = (*UNO)(nil) + _ gotenberg.MetricsProvider = (*UNO)(nil) + _ api.HealthChecker = (*UNO)(nil) + _ API = (*UNO)(nil) + _ Provider = (*UNO)(nil) +) diff --git a/pkg/modules/libreoffice/uno/uno_test.go b/pkg/modules/libreoffice/uno/uno_test.go new file mode 100644 index 00000000..73f5b454 --- /dev/null +++ b/pkg/modules/libreoffice/uno/uno_test.go @@ -0,0 +1,865 @@ +package uno + +import ( + "context" + "errors" + "os" + "reflect" + "testing" + "time" + + "github.com/alexliesenfeld/health" + "github.com/gotenberg/gotenberg/v7/pkg/gotenberg" + flag "github.com/spf13/pflag" + "go.uber.org/zap" +) + +func TestUNO_Descriptor(t *testing.T) { + descriptor := UNO{}.Descriptor() + + actual := reflect.TypeOf(descriptor.New()) + expect := reflect.TypeOf(new(UNO)) + + if actual != expect { + t.Errorf("expected '%s' but got '%s'", expect, actual) + } +} + +func TestUNO_Provision(t *testing.T) { + tests := []struct { + name string + ctx *gotenberg.Context + expectProvisionErr bool + }{ + { + name: "nominal behavior", + ctx: func() *gotenberg.Context { + provider := struct { + gotenberg.ModuleMock + gotenberg.LoggerProviderMock + }{} + provider.DescriptorMock = func() gotenberg.ModuleDescriptor { + return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { + return provider + }} + } + provider.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) { + return zap.NewNop(), nil + } + + return gotenberg.NewContext( + gotenberg.ParsedFlags{ + FlagSet: new(UNO).Descriptor().FlagSet, + }, + []gotenberg.ModuleDescriptor{ + provider.Descriptor(), + }, + ) + }(), + }, + { + name: "threshold from deprecated flag --unoconv-disable-listener", + ctx: func() *gotenberg.Context { + provider := struct { + gotenberg.ModuleMock + gotenberg.LoggerProviderMock + }{} + provider.DescriptorMock = func() gotenberg.ModuleDescriptor { + return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { + return provider + }} + } + provider.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) { + return zap.NewNop(), nil + } + + return gotenberg.NewContext( + gotenberg.ParsedFlags{ + FlagSet: func() *flag.FlagSet { + fs := new(UNO).Descriptor().FlagSet + err := fs.Parse([]string{"--unoconv-disable-listener=true"}) + + if err != nil { + t.Fatalf("expected no error from fs.Parse(), but got: %v", err) + } + + return fs + }(), + }, + []gotenberg.ModuleDescriptor{ + provider.Descriptor(), + }, + ) + }(), + }, + { + name: "no logger provider", + ctx: func() *gotenberg.Context { + return gotenberg.NewContext( + gotenberg.ParsedFlags{ + FlagSet: new(UNO).Descriptor().FlagSet, + }, + []gotenberg.ModuleDescriptor{}, + ) + }(), + expectProvisionErr: true, + }, + { + name: "no logger from logger provider", + ctx: func() *gotenberg.Context { + provider := struct { + gotenberg.ModuleMock + gotenberg.LoggerProviderMock + }{} + provider.DescriptorMock = func() gotenberg.ModuleDescriptor { + return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { + return provider + }} + } + provider.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) { + return nil, errors.New("foo") + } + + return gotenberg.NewContext( + gotenberg.ParsedFlags{ + FlagSet: new(UNO).Descriptor().FlagSet, + }, + []gotenberg.ModuleDescriptor{ + provider.Descriptor(), + }, + ) + }(), + expectProvisionErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + mod := new(UNO) + err := mod.Provision(tc.ctx) + + if tc.expectProvisionErr && err == nil { + t.Errorf("expected mod.Provision() error, but got none") + } + + if !tc.expectProvisionErr && err != nil { + t.Errorf("expected no error from mod.Provision(), but got: %v", err) + } + }) + } +} + +func TestUNO_Validate(t *testing.T) { + tests := []struct { + name string + unoconvBinPath string + libreOfficeBinPath string + expectValidateErr bool + }{ + { + name: "nominal behavior", + unoconvBinPath: os.Getenv("UNOCONV_BIN_PATH"), + libreOfficeBinPath: os.Getenv("LIBREOFFICE_BIN_PATH"), + }, + { + name: "unoconv bin path does not exist", + unoconvBinPath: "/foo", + libreOfficeBinPath: os.Getenv("LIBREOFFICE_BIN_PATH"), + expectValidateErr: true, + }, + { + name: "LibreOffice bin path does not exist", + unoconvBinPath: os.Getenv("UNOCONV_BIN_PATH"), + libreOfficeBinPath: "/foo", + expectValidateErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + mod := UNO{ + unoconvBinPath: tc.unoconvBinPath, + libreOfficeBinPath: tc.libreOfficeBinPath, + } + + err := mod.Validate() + + if tc.expectValidateErr && err == nil { + t.Errorf("expected mod.Validate() error, but got none") + } + + if !tc.expectValidateErr && err != nil { + t.Errorf("expected no error from mod.Validate(), but got: %v", err) + } + }) + } +} + +func TestUNO_Start(t *testing.T) { + tests := []struct { + name string + mod UNO + expectStartErr bool + }{ + { + name: "nominal behavior", + mod: UNO{ + libreOfficeRestartThreshold: 10, + listener: listenerMock{ + startMock: func(logger *zap.Logger) error { + return nil + }, + }, + }, + }, + { + name: "no long-running LibreOffice listener", + mod: UNO{ + libreOfficeRestartThreshold: 0, + }, + }, + { + name: "start error", + mod: UNO{ + libreOfficeRestartThreshold: 10, + listener: listenerMock{ + startMock: func(logger *zap.Logger) error { + return errors.New("foo") + }, + }, + }, + expectStartErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := tc.mod.Start() + + if tc.expectStartErr && err == nil { + t.Errorf("expected mod.Start() error, but got none") + } + + if !tc.expectStartErr && err != nil { + t.Errorf("expected no error from mod.Start(), but got: %v", err) + } + }) + } +} + +func TestUNO_StartupMessage(t *testing.T) { + tests := []struct { + name string + mod UNO + expectMessage string + }{ + { + name: "long-running LibreOffice listener started", + mod: UNO{ + libreOfficeRestartThreshold: 10, + }, + expectMessage: "Long-running LibreOffice listener started", + }, + { + name: "long-running LibreOffice listener disabled", + mod: UNO{ + libreOfficeRestartThreshold: 0, + }, + expectMessage: "Long-running LibreOffice listener disabled", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + actual := tc.mod.StartupMessage() + + if tc.expectMessage != actual { + t.Errorf("expected '%s' from mod.StartupMessage(), but got '%s'", tc.expectMessage, actual) + } + }) + } +} + +func TestUNO_Stop(t *testing.T) { + tests := []struct { + name string + mod UNO + expectStopErr bool + }{ + { + name: "nominal behavior", + mod: UNO{ + libreOfficeRestartThreshold: 10, + listener: listenerMock{ + stopMock: func(logger *zap.Logger) error { + return nil + }, + }, + logger: zap.NewNop(), + }, + }, + { + name: "no long-running LibreOffice listener", + mod: UNO{ + libreOfficeRestartThreshold: 0, + logger: zap.NewNop(), + }, + }, + { + name: "stop error", + mod: UNO{ + libreOfficeRestartThreshold: 10, + listener: listenerMock{ + stopMock: func(logger *zap.Logger) error { + return errors.New("foo") + }, + }, + logger: zap.NewNop(), + }, + expectStopErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(10)*time.Second) + cancel() + + err := tc.mod.Stop(ctx) + + if tc.expectStopErr && err == nil { + t.Errorf("expected mod.Stop() error, but got none") + } + + if !tc.expectStopErr && err != nil { + t.Errorf("expected no error from mod.Stop(), but got: %v", err) + } + }) + } +} + +func TestUNO_Metrics(t *testing.T) { + tests := []struct { + name string + mod UNO + expectUnoconvActiveInstancesCount float64 + expectLibreOfficeListenerActiveInstancesCount float64 + expectLibreOfficeListenerQueueLength float64 + }{ + { + name: "with healthy long-running LibreOffice listener", + mod: UNO{ + libreOfficeRestartThreshold: 10, + listener: listenerMock{ + queueMock: func() int { + return 0 + }, + healthyMock: func() bool { + return true + }, + }, + }, + expectLibreOfficeListenerActiveInstancesCount: 1, + }, + { + name: "with unhealthy long-running LibreOffice listener", + mod: UNO{ + libreOfficeRestartThreshold: 10, + listener: listenerMock{ + queueMock: func() int { + return 0 + }, + healthyMock: func() bool { + return false + }, + }, + }, + }, + { + name: "with no long-running LibreOffice listener", + mod: UNO{ + libreOfficeRestartThreshold: 0, + listener: listenerMock{ + queueMock: func() int { + return 0 + }, + healthyMock: func() bool { + return false + }, + }, + }, + }, + { + name: "with a queue of 3", + mod: UNO{ + libreOfficeRestartThreshold: 0, + listener: listenerMock{ + queueMock: func() int { + return 3 + }, + healthyMock: func() bool { + return true + }, + }, + }, + expectLibreOfficeListenerQueueLength: 3, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + metrics, err := tc.mod.Metrics() + if err != nil { + t.Fatalf("expected no error from mod.Metrics(), but got: %v", err) + } + + for _, metric := range metrics { + switch metric.Name { + case "unoconv_active_instances_count": + actual := metric.Read() + if actual != tc.expectUnoconvActiveInstancesCount { + t.Errorf("expected 'unoconv_active_instances_count' to be %.0f, but got %.0f", tc.expectUnoconvActiveInstancesCount, actual) + } + case "libreoffice_listener_active_instances_count": + actual := metric.Read() + if actual != tc.expectLibreOfficeListenerActiveInstancesCount { + t.Errorf("expected 'libreoffice_listener_active_instances_count' to be %.0f, but got %.0f", tc.expectLibreOfficeListenerActiveInstancesCount, actual) + } + case "unoconv_listener_active_instances_count": + actual := metric.Read() + if actual != tc.expectLibreOfficeListenerActiveInstancesCount { + t.Errorf("expected 'unoconv_listener_active_instances_count' to be %.0f, but got %.0f", tc.expectLibreOfficeListenerActiveInstancesCount, actual) + } + case "libreoffice_listener_queue_length": + actual := metric.Read() + if actual != tc.expectLibreOfficeListenerQueueLength { + t.Errorf("expected 'libreoffice_listener_queue_length' to be %.0f, but got %.0f", tc.expectLibreOfficeListenerQueueLength, actual) + } + case "unoconv_listener_queue_length": + actual := metric.Read() + if actual != tc.expectLibreOfficeListenerQueueLength { + t.Errorf("expected 'unoconv_listener_queue_length' to be %.0f, but got %.0f", tc.expectLibreOfficeListenerQueueLength, actual) + } + } + } + }) + } +} + +func TestUNO_Checks(t *testing.T) { + tests := []struct { + name string + mod UNO + expectAvailabilityStatus health.AvailabilityStatus + }{ + { + name: "no long-running LibreOffice listener", + mod: UNO{ + libreOfficeRestartThreshold: 0, + }, + }, + { + name: "with healthy long-running LibreOffice listener", + mod: UNO{ + libreOfficeRestartThreshold: 10, + listener: listenerMock{ + healthyMock: func() bool { + return true + }, + }, + }, + expectAvailabilityStatus: health.StatusUp, + }, + { + name: "with unhealthy long-running LibreOffice listener", + mod: UNO{ + libreOfficeRestartThreshold: 10, + listener: listenerMock{ + healthyMock: func() bool { + return false + }, + }, + }, + expectAvailabilityStatus: health.StatusDown, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + checks, err := tc.mod.Checks() + if err != nil { + t.Fatalf("expected no error from mod.Checks(), but got: %v", err) + } + + if len(checks) == 0 { + return + } + + if len(checks) != 1 { + t.Fatalf("expected 1 check from mod.Checks(), but got %d", len(checks)) + } + + checker := health.NewChecker(checks...) + result := checker.Check(context.Background()) + + if result.Status != tc.expectAvailabilityStatus { + t.Errorf("expected '%s' as availability status, but got '%s'", tc.expectAvailabilityStatus, result.Status) + } + }) + } +} + +func TestUNO_PDF(t *testing.T) { + tests := []struct { + name string + mod UNO + ctx context.Context + logger *zap.Logger + inputPath string + options Options + expectPDFErr bool + teardown func(mod UNO) error + }{ + { + name: "nominal behavior with no long-running LibreOffice listener", + mod: UNO{ + unoconvBinPath: os.Getenv("UNOCONV_BIN_PATH"), + libreOfficeBinPath: os.Getenv("LIBREOFFICE_BIN_PATH"), + libreOfficeStartTimeout: time.Duration(10) * time.Second, + libreOfficeRestartThreshold: 0, + }, + ctx: context.Background(), + logger: zap.NewNop(), + inputPath: "/tests/test/testdata/libreoffice/sample1.docx", + }, + { + name: "nominal behavior with a long-running LibreOffice listener", + mod: func() UNO { + mod := UNO{ + unoconvBinPath: os.Getenv("UNOCONV_BIN_PATH"), + libreOfficeBinPath: os.Getenv("LIBREOFFICE_BIN_PATH"), + libreOfficeStartTimeout: time.Duration(10) * time.Second, + libreOfficeRestartThreshold: 10, + logger: zap.NewNop(), + } + mod.listener = newLibreOfficeListener( + mod.logger, + mod.libreOfficeBinPath, + mod.libreOfficeStartTimeout, + mod.libreOfficeRestartThreshold, + ) + + err := mod.Start() + if err != nil { + t.Fatalf("expected no error from mod.Start(), but got: %v", err) + } + + return mod + }(), + ctx: context.Background(), + logger: zap.NewNop(), + inputPath: "/tests/test/testdata/libreoffice/sample1.docx", + teardown: func(mod UNO) error { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + return mod.Stop(ctx) + }, + }, + { + name: "convert with a debug logger", + mod: UNO{ + unoconvBinPath: os.Getenv("UNOCONV_BIN_PATH"), + libreOfficeBinPath: os.Getenv("LIBREOFFICE_BIN_PATH"), + libreOfficeStartTimeout: time.Duration(10) * time.Second, + libreOfficeRestartThreshold: 0, + }, + ctx: context.Background(), + logger: zap.NewExample(), + inputPath: "/tests/test/testdata/libreoffice/sample1.docx", + }, + { + name: "convert with landscape", + mod: UNO{ + unoconvBinPath: os.Getenv("UNOCONV_BIN_PATH"), + libreOfficeBinPath: os.Getenv("LIBREOFFICE_BIN_PATH"), + libreOfficeStartTimeout: time.Duration(10) * time.Second, + libreOfficeRestartThreshold: 0, + }, + ctx: context.Background(), + logger: zap.NewNop(), + inputPath: "/tests/test/testdata/libreoffice/sample1.docx", + options: Options{ + Landscape: true, + }, + }, + { + name: "convert with page ranges", + mod: UNO{ + unoconvBinPath: os.Getenv("UNOCONV_BIN_PATH"), + libreOfficeBinPath: os.Getenv("LIBREOFFICE_BIN_PATH"), + libreOfficeStartTimeout: time.Duration(10) * time.Second, + libreOfficeRestartThreshold: 0, + }, + ctx: context.Background(), + logger: zap.NewNop(), + inputPath: "/tests/test/testdata/libreoffice/sample1.docx", + options: Options{ + PageRanges: "1-2", + }, + }, + { + name: "convert with invalid page ranges", + mod: UNO{ + unoconvBinPath: os.Getenv("UNOCONV_BIN_PATH"), + libreOfficeBinPath: os.Getenv("LIBREOFFICE_BIN_PATH"), + libreOfficeStartTimeout: time.Duration(10) * time.Second, + libreOfficeRestartThreshold: 0, + }, + ctx: context.Background(), + logger: zap.NewNop(), + inputPath: "/tests/test/testdata/libreoffice/sample1.docx", + options: Options{ + PageRanges: "foo", + }, + expectPDFErr: true, + }, + { + name: "convert to PDF/A-1a", + mod: UNO{ + unoconvBinPath: os.Getenv("UNOCONV_BIN_PATH"), + libreOfficeBinPath: os.Getenv("LIBREOFFICE_BIN_PATH"), + libreOfficeStartTimeout: time.Duration(10) * time.Second, + libreOfficeRestartThreshold: 0, + }, + ctx: context.Background(), + logger: zap.NewNop(), + inputPath: "/tests/test/testdata/libreoffice/sample1.docx", + options: Options{ + PDFformat: gotenberg.FormatPDFA1a, + }, + }, + { + name: "convert to PDF/A-2b", + mod: UNO{ + unoconvBinPath: os.Getenv("UNOCONV_BIN_PATH"), + libreOfficeBinPath: os.Getenv("LIBREOFFICE_BIN_PATH"), + libreOfficeStartTimeout: time.Duration(10) * time.Second, + libreOfficeRestartThreshold: 0, + }, + ctx: context.Background(), + logger: zap.NewNop(), + inputPath: "/tests/test/testdata/libreoffice/sample1.docx", + options: Options{ + PDFformat: gotenberg.FormatPDFA2b, + }, + }, + { + name: "convert to PDF/A-3b", + mod: UNO{ + unoconvBinPath: os.Getenv("UNOCONV_BIN_PATH"), + libreOfficeBinPath: os.Getenv("LIBREOFFICE_BIN_PATH"), + libreOfficeStartTimeout: time.Duration(10) * time.Second, + libreOfficeRestartThreshold: 0, + }, + ctx: context.Background(), + logger: zap.NewNop(), + inputPath: "/tests/test/testdata/libreoffice/sample1.docx", + options: Options{ + PDFformat: gotenberg.FormatPDFA3b, + }, + }, + { + name: "convert to invalid PDF format", + mod: UNO{ + unoconvBinPath: os.Getenv("UNOCONV_BIN_PATH"), + libreOfficeBinPath: os.Getenv("LIBREOFFICE_BIN_PATH"), + libreOfficeStartTimeout: time.Duration(10) * time.Second, + libreOfficeRestartThreshold: 0, + }, + ctx: context.Background(), + logger: zap.NewNop(), + inputPath: "/tests/test/testdata/libreoffice/sample1.docx", + options: Options{ + PDFformat: "foo", + }, + expectPDFErr: true, + }, + { + name: "nil context", + mod: UNO{ + unoconvBinPath: os.Getenv("UNOCONV_BIN_PATH"), + libreOfficeBinPath: os.Getenv("LIBREOFFICE_BIN_PATH"), + libreOfficeStartTimeout: time.Duration(10) * time.Second, + libreOfficeRestartThreshold: 0, + }, + ctx: nil, + logger: zap.NewNop(), + inputPath: "/tests/test/testdata/libreoffice/sample1.docx", + expectPDFErr: true, + }, + { + name: "expired context", + mod: UNO{ + unoconvBinPath: os.Getenv("UNOCONV_BIN_PATH"), + libreOfficeBinPath: os.Getenv("LIBREOFFICE_BIN_PATH"), + libreOfficeStartTimeout: time.Duration(10) * time.Second, + libreOfficeRestartThreshold: 0, + }, + ctx: func() context.Context { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + return ctx + }(), + logger: zap.NewNop(), + inputPath: "/tests/test/testdata/libreoffice/sample1.docx", + expectPDFErr: true, + }, + { + name: "cannot lock long-running LibreOffice listener", + mod: UNO{ + unoconvBinPath: os.Getenv("UNOCONV_BIN_PATH"), + libreOfficeBinPath: os.Getenv("LIBREOFFICE_BIN_PATH"), + libreOfficeStartTimeout: time.Duration(10) * time.Second, + libreOfficeRestartThreshold: 10, + listener: listenerMock{ + lockMock: func(ctx context.Context, logger *zap.Logger) error { + return errors.New("foo") + }, + }, + logger: zap.NewNop(), + }, + ctx: context.Background(), + logger: zap.NewNop(), + inputPath: "/tests/test/testdata/libreoffice/sample1.docx", + expectPDFErr: true, + }, + { + name: "cannot unlock long-running LibreOffice listener", + mod: UNO{ + unoconvBinPath: os.Getenv("UNOCONV_BIN_PATH"), + libreOfficeBinPath: os.Getenv("LIBREOFFICE_BIN_PATH"), + libreOfficeStartTimeout: time.Duration(10) * time.Second, + libreOfficeRestartThreshold: 10, + listener: listenerMock{ + lockMock: func(ctx context.Context, logger *zap.Logger) error { + return nil + }, + unlockMock: func(logger *zap.Logger) error { + return errors.New("foo") + }, + portMock: func() int { + return 2002 + }, + }, + logger: zap.NewNop(), + }, + ctx: nil, + logger: zap.NewNop(), + inputPath: "/tests/test/testdata/libreoffice/sample1.docx", + expectPDFErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + defer func() { + if tc.teardown == nil { + return + } + + err := tc.teardown(tc.mod) + if err != nil { + t.Errorf("expected no error from tc.teardown(), but got: %v", err) + } + }() + + outputDir, err := gotenberg.MkdirAll() + if err != nil { + t.Fatalf("expected no error from gotenberg.MkdirAll(), but got: %v", err) + } + + defer func() { + err := os.RemoveAll(outputDir) + if err != nil { + t.Errorf("expected no error from os.RemoveAll(), but got: %v", err) + } + }() + + err = tc.mod.PDF(tc.ctx, tc.logger, tc.inputPath, outputDir+"/foo.pdf", tc.options) + + if tc.expectPDFErr && err == nil { + t.Fatalf("expected mod.PDF() error, but got none") + } + + if !tc.expectPDFErr && err != nil { + t.Fatalf("expected no error from mod.PDF(), but got: %v", err) + } + }) + } +} + +func TestUNO_Extensions(t *testing.T) { + mod := new(UNO) + extensions := mod.Extensions() + + actual := len(extensions) + expect := 76 + + if actual != expect { + t.Errorf("expected %d extensions, but got %d", expect, actual) + } +} + +func TestUNO_UNO(t *testing.T) { + mod := new(UNO) + + _, err := mod.UNO() + if err != nil { + t.Errorf("expected no error from mod.UNO(), but got: %v", err) + } +} + +type listenerMock struct { + startMock func(logger *zap.Logger) error + stopMock func(logger *zap.Logger) error + 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) 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/libreoffice/unoconv/doc.go b/pkg/modules/libreoffice/unoconv/doc.go deleted file mode 100644 index b495a98c..00000000 --- a/pkg/modules/libreoffice/unoconv/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package unoconv provides a module which abstracts the CLI tool unoconv. -package unoconv diff --git a/pkg/modules/libreoffice/unoconv/unoconv.go b/pkg/modules/libreoffice/unoconv/unoconv.go deleted file mode 100644 index f8b9e844..00000000 --- a/pkg/modules/libreoffice/unoconv/unoconv.go +++ /dev/null @@ -1,482 +0,0 @@ -package unoconv - -import ( - "context" - "errors" - "fmt" - "os" - "strings" - "sync" - - "github.com/gotenberg/gotenberg/v7/pkg/gotenberg" - flag "github.com/spf13/pflag" - "go.uber.org/zap" -) - -func init() { - gotenberg.MustRegisterModule(Unoconv{}) -} - -// ErrMalformedPageRanges happens if the page ranges option cannot be -// interpreted by LibreOffice. -var ErrMalformedPageRanges = errors.New("page ranges are malformed") - -// Unoconv is a module which provides an API to interact with unoconv. -type Unoconv struct { - binPath string - disableListener bool - - listenerCmd gotenberg.Cmd - listenerPort int - 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 - - // PDFArchive allows to convert the resulting PDF to PDF/A-1a. - // In a module, prefer the Convert method from the gotenberg.PDFEngine - // interface. - // Optional. - PDFArchive bool -} - -// API is an abstraction on top of unoconv. -// -// See https://github.com/unoconv/unoconv. -type API interface { - PDF(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error - Extensions() []string -} - -// Provider is a module interface which exposes a method for creating an API -// for other modules. -// -// func (m *YourModule) Provision(ctx *gotenberg.Context) error { -// provider, _ := ctx.Module(new(unoconv.Provider)) -// uno, _ := provider.(unoconv.Provider).Unoconv() -// } -type Provider interface { - Unoconv() (API, error) -} - -// Descriptor returns a Unoconv's module descriptor. -func (Unoconv) Descriptor() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ - ID: "unoconv", - FlagSet: func() *flag.FlagSet { - fs := flag.NewFlagSet("unoconv", flag.ExitOnError) - fs.Bool("unoconv-disable-listener", false, "Do not start a unoconv listener - save resources in detriment of performance") - - return fs - }(), - New: func() gotenberg.Module { return new(Unoconv) }, - } -} - -// Provision sets the module properties. It returns an error if the environment -// variable UNOCONV_BIN_PATH is not set. -func (mod *Unoconv) Provision(ctx *gotenberg.Context) error { - flags := ctx.ParsedFlags() - mod.disableListener = flags.MustBool("unoconv-disable-listener") - - binPath, ok := os.LookupEnv("UNOCONV_BIN_PATH") - if !ok { - return errors.New("UNOCONV_BIN_PATH environment variable is not set") - } - - mod.binPath = binPath - - 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 - - return nil -} - -// Validate validates the module properties. -func (mod Unoconv) Validate() error { - _, err := os.Stat(mod.binPath) - if os.IsNotExist(err) { - return fmt.Errorf("unoconv binary path does not exist: %w", err) - } - - return nil -} - -func (mod *Unoconv) Start() error { - if mod.disableListener { - return nil - } - - port, err := freePort(mod.logger) - if err != nil { - return fmt.Errorf("get free port: %w", err) - } - - mod.listenerPort = port - - args := []string{ - "--listener", - "--user-profile", - // Just to make sure LibreOffice does not leak files in an unknown - // directory. The directory will be removed anyway by the garbage - // collector. - fmt.Sprintf("//%s", gotenberg.NewDirPath()), - "--port", - fmt.Sprintf("%d", mod.listenerPort), - } - - checkedEntry := mod.logger.Check(zap.DebugLevel, "check for debug level before setting high verbosity") - if checkedEntry != nil { - args = append(args, "-vvv") - } - - mod.listenerCmd = gotenberg.Command(mod.logger, mod.binPath, args...) - - err = mod.listenerCmd.Start() - if err != nil { - return fmt.Errorf("start unoconv listener: %w", err) - } - - listenerActiveInstancesCountMu.Lock() - listenerActiveInstancesCount += 1 - listenerActiveInstancesCountMu.Unlock() - - return nil -} - -// StartupMessage returns a custom startup message. -func (mod Unoconv) StartupMessage() string { - if mod.disableListener { - return "listener disabled" - } - - return fmt.Sprintf("listener started on port %d", mod.listenerPort) -} - -// Stop stops the HTTP server. -func (mod *Unoconv) Stop(ctx context.Context) error { - if mod.disableListener { - return nil - } - - _, ok := ctx.Deadline() - if !ok { - return errors.New("no context dead line") - } - - // Block until the context is done so that other module may gracefully stop - // before we do a shutdown cleanup. - mod.logger.Debug("wait for the end of grace duration") - - <-ctx.Done() - - err := mod.listenerCmd.Kill() - if err != nil { - return fmt.Errorf("kill unoconv listener: %w", err) - } - - listenerActiveInstancesCountMu.Lock() - listenerActiveInstancesCount -= 1 - listenerActiveInstancesCountMu.Unlock() - - return nil -} - -// Metrics returns the metrics. -func (mod Unoconv) Metrics() ([]gotenberg.Metric, error) { - return []gotenberg.Metric{ - { - Name: "unoconv_active_instances_count", - Description: "Current number of active unoconv instances.", - Read: func() float64 { - activeInstancesCountMu.RLock() - defer activeInstancesCountMu.RUnlock() - - return activeInstancesCount - }, - }, - { - Name: "unoconv_listener_active_instances_count", - Description: "Current number of active unoconv listener instances.", - Read: func() float64 { - listenerActiveInstancesCountMu.RLock() - defer listenerActiveInstancesCountMu.RUnlock() - - return listenerActiveInstancesCount - }, - }, - { - Name: "unoconv_listener_queue_length", - Description: "Current number of processes in the queue.", - Read: func() float64 { - listenerQueueLengthMu.RLock() - defer listenerQueueLengthMu.RUnlock() - - return listenerQueueLength - }, - }, - }, nil -} - -// Unoconv returns an API for interacting with unoconv. -func (mod *Unoconv) Unoconv() (API, error) { - return mod, nil -} - -// PDF converts a document to PDF. -// -// In stateless mode, it creates a dedicated LibreOffice instance thanks to a -// custom user profile directory and a free port. Substantial calls to this -// method may increase CPU and memory usage drastically. In such a scenario, -// the given context may also be done before the end of the conversion. -// -// In listener mode, it calls the unoconv listener to interact with -// LibreOffice, improving substantially the performance. However, it cannot -// perform parallel operations and have to wait for the lock to be available. -func (mod Unoconv) PDF(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error { - args := []string{ - "--format", - "pdf", - } - - var userProfileDirPath string - - if mod.disableListener { - port, err := freePort(logger) - if err != nil { - return fmt.Errorf("get free port: %w", err) - } - - userProfileDirPath = gotenberg.NewDirPath() - - args = append(args, - "--port", - fmt.Sprintf("%d", port), - "--user-profile", - fmt.Sprintf("//%s", userProfileDirPath), - ) - } else { - args = append(args, "--port", fmt.Sprintf("%d", mod.listenerPort)) - } - - 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)) - } - - if options.PDFArchive { - args = append(args, "--export", "SelectPdfVersion=1") - } - - args = append(args, "--output", outputPath, inputPath) - - if !mod.disableListener { - listenerQueueLengthMu.Lock() - listenerQueueLength += 1 - listenerQueueLengthMu.Unlock() - - select { - case listenerLock <- struct{}{}: - logger.Debug("unoconv lock acquired") - - listenerQueueLengthMu.Lock() - listenerQueueLength -= 1 - listenerQueueLengthMu.Unlock() - - break - case <-ctx.Done(): - logger.Debug("failed to acquire the unoconv lock before deadline") - - listenerQueueLengthMu.Lock() - listenerQueueLength -= 1 - listenerQueueLengthMu.Unlock() - - return fmt.Errorf("acquire unoconv lock: %w", ctx.Err()) - } - - defer func() { - <-listenerLock - logger.Debug("unoconv lock released") - }() - } - - cmd, err := gotenberg.CommandContext(ctx, logger, mod.binPath, args...) - if err != nil { - return fmt.Errorf("create unoconv command: %w", err) - } - - logger.Debug(fmt.Sprintf("print to PDF with: %+v", options)) - - activeInstancesCountMu.Lock() - activeInstancesCount += 1 - activeInstancesCountMu.Unlock() - - err = cmd.Exec() - - activeInstancesCountMu.Lock() - activeInstancesCount -= 1 - activeInstancesCountMu.Unlock() - - if mod.disableListener { - // Always remove the user profile directory created by LibreOffice. - // See https://github.com/gotenberg/gotenberg/issues/192. - go func() { - logger.Debug(fmt.Sprintf("remove user profile directory '%s'", userProfileDirPath)) - - err := os.RemoveAll(userProfileDirPath) - if err != nil { - logger.Error(fmt.Sprintf("remove user profile directory: %s", err)) - } - }() - } - - if err == nil { - return nil - } - - // Unoconv/LibreOffice errors are not explicit. - // That's why we have to make an educated guess according to the exit code - // and given inputs. - - if strings.Contains(err.Error(), "exit status 5") && options.PageRanges != "" { - return ErrMalformedPageRanges - } - - // Possible errors: - // 1. Unoconv/LibreOffice failed for some reason. - // 2. Context done. - // - // On the second scenario, LibreOffice might not had time to remove some of - // its temporary files, as it has been killed without warning. The garbage - // collector will delete them for us (if the module is loaded). - return fmt.Errorf("unoconv PDF: %w", err) -} - -// Extensions returns the file extensions available with unoconv. -func (mod Unoconv) Extensions() []string { - return []string{ - ".bib", - ".doc", - ".xml", - ".docx", - ".fodt", - ".html", - ".ltx", - ".txt", - ".odt", - ".ott", - ".pdb", - ".pdf", - ".psw", - ".rtf", - ".sdw", - ".stw", - ".sxw", - ".uot", - ".vor", - ".wps", - ".epub", - ".png", - ".bmp", - ".emf", - ".eps", - ".fodg", - ".gif", - ".jpg", - ".jpeg", - ".met", - ".odd", - ".otg", - ".pbm", - ".pct", - ".pgm", - ".ppm", - ".ras", - ".std", - ".svg", - ".svm", - ".swf", - ".sxd", - ".sxw", - ".tif", - ".tiff", - ".xhtml", - ".xpm", - ".odp", - ".fodp", - ".potm", - ".pot", - ".pptx", - ".pps", - ".ppt", - ".pwp", - ".sda", - ".sdd", - ".sti", - ".sxi", - ".uop", - ".wmf", - ".csv", - ".dbf", - ".dif", - ".fods", - ".ods", - ".ots", - ".pxl", - ".sdc", - ".slk", - ".stc", - ".sxc", - ".uos", - ".xls", - ".xlt", - ".xlsx", - } -} - -var ( - listenerLock = make(chan struct{}, 1) - listenerQueueLength float64 - listenerQueueLengthMu sync.RWMutex - listenerActiveInstancesCount float64 - listenerActiveInstancesCountMu sync.RWMutex - activeInstancesCount float64 - activeInstancesCountMu sync.RWMutex -) - -// Interface guards. -var ( - _ gotenberg.Module = (*Unoconv)(nil) - _ gotenberg.Provisioner = (*Unoconv)(nil) - _ gotenberg.Validator = (*Unoconv)(nil) - _ gotenberg.App = (*Unoconv)(nil) - _ gotenberg.MetricsProvider = (*Unoconv)(nil) - _ API = (*Unoconv)(nil) - _ Provider = (*Unoconv)(nil) -) diff --git a/pkg/modules/libreoffice/unoconv/unoconv_test.go b/pkg/modules/libreoffice/unoconv/unoconv_test.go deleted file mode 100644 index 3e5df02b..00000000 --- a/pkg/modules/libreoffice/unoconv/unoconv_test.go +++ /dev/null @@ -1,453 +0,0 @@ -package unoconv - -import ( - "context" - "errors" - "os" - "reflect" - "testing" - "time" - - "github.com/gotenberg/gotenberg/v7/pkg/gotenberg" - "go.uber.org/zap" -) - -type ProtoModule struct { - descriptor func() gotenberg.ModuleDescriptor -} - -func (mod ProtoModule) Descriptor() gotenberg.ModuleDescriptor { - return mod.descriptor() -} - -type ProtoValidator struct { - ProtoModule - validate func() error -} - -func (mod ProtoValidator) Validate() error { - return mod.validate() -} - -type ProtoLoggerProvider struct { - ProtoModule - logger func(mod gotenberg.Module) (*zap.Logger, error) -} - -func (factory ProtoLoggerProvider) Logger(mod gotenberg.Module) (*zap.Logger, error) { - return factory.logger(mod) -} - -func TestUnoconv_Descriptor(t *testing.T) { - descriptor := Unoconv{}.Descriptor() - - actual := reflect.TypeOf(descriptor.New()) - expect := reflect.TypeOf(new(Unoconv)) - - if actual != expect { - t.Errorf("expected '%'s' but got '%s'", expect, actual) - } -} - -func TestUnoconv_Provision(t *testing.T) { - for i, tc := range []struct { - ctx *gotenberg.Context - expectErr bool - }{ - { - ctx: gotenberg.NewContext( - gotenberg.ParsedFlags{FlagSet: new(Unoconv).Descriptor().FlagSet}, - make([]gotenberg.ModuleDescriptor, 0), - ), - expectErr: true, - }, - { - ctx: func() *gotenberg.Context { - mod := struct { - ProtoLoggerProvider - }{} - mod.descriptor = func() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }} - } - mod.logger = func(mod gotenberg.Module) (*zap.Logger, error) { return nil, errors.New("foo") } - - return gotenberg.NewContext( - gotenberg.ParsedFlags{ - FlagSet: new(Unoconv).Descriptor().FlagSet, - }, - []gotenberg.ModuleDescriptor{ - mod.Descriptor(), - }, - ) - }(), - expectErr: true, - }, - { - ctx: func() *gotenberg.Context { - mod := struct { - ProtoLoggerProvider - }{} - mod.descriptor = func() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }} - } - mod.logger = func(mod gotenberg.Module) (*zap.Logger, error) { return zap.NewNop(), nil } - - return gotenberg.NewContext( - gotenberg.ParsedFlags{ - FlagSet: new(Unoconv).Descriptor().FlagSet, - }, - []gotenberg.ModuleDescriptor{ - mod.Descriptor(), - }, - ) - }(), - }, - } { - mod := new(Unoconv) - err := mod.Provision(tc.ctx) - - if tc.expectErr && err == nil { - t.Errorf("test %d: expected error but got: %v", i, err) - } - - if !tc.expectErr && err != nil { - t.Errorf("test %d: expected no error but got: %v", i, err) - } - } -} - -func TestUnoconv_Validate(t *testing.T) { - for i, tc := range []struct { - binPath string - expectErr bool - }{ - { - expectErr: true, - }, - { - binPath: "/foo", - expectErr: true, - }, - { - binPath: os.Getenv("UNOCONV_BIN_PATH"), - }, - } { - mod := Unoconv{ - binPath: tc.binPath, - } - - err := mod.Validate() - - if tc.expectErr && err == nil { - t.Errorf("test %d: expected error but got: %v", i, err) - } - - if !tc.expectErr && err != nil { - t.Errorf("test %d: expected no error but got: %v", i, err) - } - } -} - -func TestUnoconv_Start(t *testing.T) { - for i, tc := range []struct { - mod *Unoconv - expectErr bool - }{ - { - mod: &Unoconv{ - disableListener: true, - logger: zap.NewNop(), - }, - }, - { - mod: &Unoconv{ - binPath: os.Getenv("UNOCONV_BIN_PATH"), - disableListener: false, - logger: zap.NewExample(), - }, - }, - } { - func() { - err := tc.mod.Start() - - if tc.expectErr && err == nil { - t.Errorf("test %d: expected error but got: %v", i, err) - } - - if !tc.expectErr && err != nil { - t.Errorf("test %d: expected no error but got: %v", i, err) - } - - ctx, cancel := context.WithTimeout(context.Background(), time.Duration(1)*time.Nanosecond) - defer cancel() - - err = tc.mod.Stop(ctx) - if err != nil { - t.Errorf("test %d: expected not error but got: %v", i, err) - } - }() - } -} - -func TestUnoconv_StartupMessage(t *testing.T) { - for i, tc := range []struct { - disableListener bool - expectMessage string - }{ - { - disableListener: true, - expectMessage: "listener disabled", - }, - { - expectMessage: "listener started on port 0", - }, - } { - mod := Unoconv{ - disableListener: tc.disableListener, - } - - actual := mod.StartupMessage() - if actual != tc.expectMessage { - t.Errorf("test %d: expected '%s' but got '%s'", i, tc.expectMessage, actual) - } - } -} - -func TestUnoconv_Stop(t *testing.T) { - for i, tc := range []struct { - start bool - disableListener bool - timeout time.Duration - expectErr bool - }{ - { - disableListener: true, - }, - { - expectErr: true, - }, - { - start: true, - timeout: time.Duration(1) * time.Nanosecond, - }, - } { - func() { - mod := &Unoconv{ - binPath: os.Getenv("UNOCONV_BIN_PATH"), - disableListener: tc.disableListener, - logger: zap.NewNop(), - } - - if tc.start { - err := mod.Start() - if err != nil { - t.Fatalf("test %d: expected no error but got: %v", i, err) - } - } - - var err error - - if tc.timeout == 0 { - err = mod.Stop(context.TODO()) - } else { - ctx, cancel := context.WithTimeout(context.Background(), tc.timeout) - defer cancel() - - err = mod.Stop(ctx) - } - - if tc.expectErr && err == nil { - t.Errorf("test %d: expected error but got: %v", i, err) - } - - if !tc.expectErr && err != nil { - t.Errorf("test %d: expected no error but got: %v", i, err) - } - }() - } -} - -func TestUnoconv_Metrics(t *testing.T) { - metrics, err := new(Unoconv).Metrics() - if err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - if len(metrics) != 3 { - t.Fatalf("expected %d metrics, but got %d", 1, len(metrics)) - } - - actual := metrics[0].Read() - if actual != 0 { - t.Errorf("expected %d unoconv instances, but got %f", 0, actual) - } - - actual = metrics[1].Read() - if actual != 0 { - t.Errorf("expected %d unoconv listener instances, but got %f", 0, actual) - } - - actual = metrics[2].Read() - if actual != 0 { - t.Errorf("expected %d processes in the queue, but got %f", 0, actual) - } -} - -func TestUnoconv_Unoconv(t *testing.T) { - mod := new(Unoconv) - - _, err := mod.Unoconv() - if err != nil { - t.Errorf("expected no error but got: %v", err) - } -} - -func TestUnoconv_PDF(t *testing.T) { - for i, tc := range []struct { - ctx context.Context - mod Unoconv - logger *zap.Logger - inputPath string - options Options - expectErr bool - }{ - { - mod: Unoconv{ - binPath: os.Getenv("UNOCONV_BIN_PATH"), - disableListener: true, - }, - logger: zap.NewNop(), - expectErr: true, - }, - { - ctx: context.Background(), - mod: Unoconv{ - binPath: os.Getenv("UNOCONV_BIN_PATH"), - disableListener: true, - }, - logger: zap.NewExample(), - inputPath: "/tests/test/testdata/libreoffice/sample1.docx", - options: Options{ - Landscape: true, - PageRanges: "1-2", - PDFArchive: true, - }, - }, - { - ctx: context.Background(), - mod: Unoconv{ - binPath: os.Getenv("UNOCONV_BIN_PATH"), - disableListener: true, - }, - logger: zap.NewNop(), - inputPath: "/tests/test/testdata/libreoffice/sample1.docx", - options: Options{ - PageRanges: "foo", - }, - expectErr: true, - }, - { - ctx: func() context.Context { - ctx, cancel := context.WithCancel(context.TODO()) - defer cancel() - - return ctx - }(), - mod: Unoconv{ - binPath: os.Getenv("UNOCONV_BIN_PATH"), - disableListener: true, - }, - logger: zap.NewNop(), - inputPath: "/tests/test/testdata/libreoffice/sample1.docx", - expectErr: true, - }, - { - ctx: context.Background(), - mod: Unoconv{ - binPath: os.Getenv("UNOCONV_BIN_PATH"), - logger: zap.NewNop(), - }, - logger: zap.NewNop(), - inputPath: "/tests/test/testdata/libreoffice/sample1.docx", - }, - { - ctx: func() context.Context { - ctx, cancel := context.WithCancel(context.TODO()) - defer cancel() - - return ctx - }(), - mod: Unoconv{ - binPath: os.Getenv("UNOCONV_BIN_PATH"), - logger: zap.NewNop(), - }, - logger: zap.NewNop(), - inputPath: "/tests/test/testdata/libreoffice/sample1.docx", - expectErr: true, - }, - } { - func() { - outputDir, err := gotenberg.MkdirAll() - if err != nil { - t.Fatalf("test %d: expected error but got: %v", i, err) - } - - defer func() { - err := os.RemoveAll(outputDir) - if err != nil { - t.Fatalf("test %d: expected no error but got: %v", i, err) - } - }() - - if !tc.mod.disableListener { - err = tc.mod.Start() - if err != nil { - t.Fatalf("test %d: expected no error but got: %v", i, err) - } - - // Let's give it some room to start. - time.Sleep(time.Duration(1) * time.Second) - } - - err = tc.mod.PDF(tc.ctx, tc.logger, tc.inputPath, outputDir+"/foo.pdf", tc.options) - - if tc.expectErr && err == nil { - t.Errorf("test %d: expected error but got: %v", i, err) - } - - if !tc.expectErr && err != nil { - t.Errorf("test %d: expected no error but got: %v", i, err) - } - - if !tc.mod.disableListener { - ctx, cancel := context.WithTimeout(context.Background(), time.Duration(1)*time.Nanosecond) - defer cancel() - - err = tc.mod.Stop(ctx) - if err != nil { - t.Fatalf("test %d: expected no error but got: %v", i, err) - } - } - }() - } -} - -func TestUnoconv_Extensions(t *testing.T) { - mod := new(Unoconv) - extensions := mod.Extensions() - - actual := len(extensions) - expect := 76 - - if actual != expect { - t.Errorf("expected %d extensions but got %d", expect, actual) - } -} - -// Interface guards. -var ( - _ gotenberg.Module = (*ProtoModule)(nil) - _ gotenberg.Validator = (*ProtoValidator)(nil) - _ gotenberg.LoggerProvider = (*ProtoLoggerProvider)(nil) - _ gotenberg.Module = (*ProtoLoggerProvider)(nil) -) diff --git a/pkg/modules/pdfengines/multi_test.go b/pkg/modules/pdfengines/multi_test.go index 7401903b..014d2baf 100644 --- a/pkg/modules/pdfengines/multi_test.go +++ b/pkg/modules/pdfengines/multi_test.go @@ -3,213 +3,178 @@ package pdfengines import ( "context" "errors" - "reflect" "testing" "github.com/gotenberg/gotenberg/v7/pkg/gotenberg" "go.uber.org/zap" ) -func TestNewMultiPDFEngines(t *testing.T) { - engine1 := &ProtoPDFEngine{ - merge: func(_ context.Context, _ *zap.Logger, _ []string, _ string) error { - return nil - }, - convert: func(_ context.Context, _ *zap.Logger, _, _, _ string) error { - return nil - }, - } - - engine2 := &ProtoPDFEngine{ - merge: func(_ context.Context, _ *zap.Logger, _ []string, _ string) error { - return errors.New("foo") - }, - convert: func(_ context.Context, _ *zap.Logger, _, _, _ string) error { - return errors.New("foo") - }, - } - - multi := newMultiPDFEngines(engine1, engine2) - - if len(multi.engines) != 2 { - t.Fatalf("expected %d engines but got %d", 2, len(multi.engines)) - } - - if !reflect.DeepEqual(engine1, multi.engines[0]) { - t.Errorf("expected %v, but got: %v", engine1, multi.engines[0]) - } - - if !reflect.DeepEqual(engine2, multi.engines[1]) { - t.Errorf("expected %v, but got: %v", engine2, multi.engines[1]) - } -} - func TestMultiPDFEngines_Merge(t *testing.T) { - for i, tc := range []struct { - ctx context.Context - engines []gotenberg.PDFEngine - expectErr bool + tests := []struct { + name string + engine *multiPDFEngines + ctx context.Context + expectMergeErr bool }{ { - ctx: context.TODO(), - engines: func() []gotenberg.PDFEngine { - return []gotenberg.PDFEngine{ - ProtoPDFEngine{ - merge: func(_ context.Context, _ *zap.Logger, _ []string, _ string) error { - return nil - }, + name: "nominal behavior", + engine: newMultiPDFEngines( + gotenberg.PDFEngineMock{ + MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { + return nil }, - } - }(), + }, + ), + ctx: context.Background(), }, { - ctx: context.TODO(), - engines: func() []gotenberg.PDFEngine { - return []gotenberg.PDFEngine{ - ProtoPDFEngine{ - merge: func(_ context.Context, _ *zap.Logger, _ []string, _ string) error { - return errors.New("foo") - }, + name: "at least one engine does not return an error", + engine: newMultiPDFEngines( + gotenberg.PDFEngineMock{ + MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { + return errors.New("foo") }, - ProtoPDFEngine{ - merge: func(_ context.Context, _ *zap.Logger, _ []string, _ string) error { - return nil - }, + }, + gotenberg.PDFEngineMock{ + MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { + return nil }, - } - }(), + }, + ), + ctx: context.Background(), }, { - ctx: context.TODO(), - engines: func() []gotenberg.PDFEngine { - return []gotenberg.PDFEngine{ - ProtoPDFEngine{ - merge: func(_ context.Context, _ *zap.Logger, _ []string, _ string) error { - return errors.New("foo") - }, + name: "all engines return an error", + engine: newMultiPDFEngines( + gotenberg.PDFEngineMock{ + MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { + return errors.New("foo") }, - ProtoPDFEngine{ - merge: func(_ context.Context, _ *zap.Logger, _ []string, _ string) error { - return errors.New("bar") - }, + }, + gotenberg.PDFEngineMock{ + MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { + return errors.New("foo") }, - } - }(), - expectErr: true, + }, + ), + ctx: context.Background(), + expectMergeErr: true, }, { + name: "context expired", + engine: newMultiPDFEngines( + gotenberg.PDFEngineMock{ + MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { + return nil + }, + }, + ), ctx: func() context.Context { - ctx, cancel := context.WithCancel(context.TODO()) - defer cancel() + ctx, cancel := context.WithCancel(context.Background()) + cancel() return ctx }(), - engines: func() []gotenberg.PDFEngine { - return []gotenberg.PDFEngine{ - ProtoPDFEngine{ - merge: func(_ context.Context, _ *zap.Logger, _ []string, _ string) error { - return nil - }, - }, - } - }(), - expectErr: true, + expectMergeErr: true, }, - } { - multi := newMultiPDFEngines(tc.engines...) - err := multi.Merge(tc.ctx, nil, nil, "") + } - if tc.expectErr && err == nil { - t.Errorf("test %d: expected error but got: %v", i, err) - } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := tc.engine.Merge(tc.ctx, zap.NewNop(), nil, "") - if !tc.expectErr && err != nil { - t.Errorf("test %d: expected no error but got: %v", i, err) - } + if tc.expectMergeErr && err == nil { + t.Errorf("expected engine.Merge() error, but got none") + } + + if !tc.expectMergeErr && err != nil { + t.Errorf("expected no error from engine.Merge(), but got: %v", err) + } + }) } } func TestMultiPDFEngines_Convert(t *testing.T) { - for i, tc := range []struct { - ctx context.Context - engines []gotenberg.PDFEngine - expectErr bool + tests := []struct { + name string + engine *multiPDFEngines + ctx context.Context + expectConvertErr bool }{ { - ctx: context.TODO(), - engines: func() []gotenberg.PDFEngine { - return []gotenberg.PDFEngine{ - ProtoPDFEngine{ - convert: func(_ context.Context, _ *zap.Logger, _, _, _ string) error { - return nil - }, + name: "nominal behavior", + engine: newMultiPDFEngines( + gotenberg.PDFEngineMock{ + ConvertMock: func(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error { + return nil }, - } - }(), + }, + ), + ctx: context.Background(), }, { - ctx: context.TODO(), - engines: func() []gotenberg.PDFEngine { - return []gotenberg.PDFEngine{ - ProtoPDFEngine{ - convert: func(_ context.Context, _ *zap.Logger, _, _, _ string) error { - return errors.New("foo") - }, + name: "at least one engine does not return an error", + engine: newMultiPDFEngines( + gotenberg.PDFEngineMock{ + ConvertMock: func(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error { + return errors.New("foo") }, - ProtoPDFEngine{ - convert: func(_ context.Context, _ *zap.Logger, _, _, _ string) error { - return nil - }, + }, + gotenberg.PDFEngineMock{ + ConvertMock: func(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error { + return nil }, - } - }(), + }, + ), + ctx: context.Background(), }, { - ctx: context.TODO(), - engines: func() []gotenberg.PDFEngine { - return []gotenberg.PDFEngine{ - ProtoPDFEngine{ - convert: func(_ context.Context, _ *zap.Logger, _, _, _ string) error { - return errors.New("foo") - }, + name: "all engines return an error", + engine: newMultiPDFEngines( + gotenberg.PDFEngineMock{ + ConvertMock: func(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error { + return errors.New("foo") }, - ProtoPDFEngine{ - convert: func(_ context.Context, _ *zap.Logger, _, _, _ string) error { - return errors.New("bar") - }, + }, + gotenberg.PDFEngineMock{ + ConvertMock: func(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error { + return errors.New("foo") }, - } - }(), - expectErr: true, + }, + ), + ctx: context.Background(), + expectConvertErr: true, }, { + name: "context expired", + engine: newMultiPDFEngines( + gotenberg.PDFEngineMock{ + ConvertMock: func(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error { + return nil + }, + }, + ), ctx: func() context.Context { - ctx, cancel := context.WithCancel(context.TODO()) - defer cancel() + ctx, cancel := context.WithCancel(context.Background()) + cancel() return ctx }(), - engines: func() []gotenberg.PDFEngine { - return []gotenberg.PDFEngine{ - ProtoPDFEngine{ - convert: func(_ context.Context, _ *zap.Logger, _, _, _ string) error { - return nil - }, - }, - } - }(), - expectErr: true, + expectConvertErr: true, }, - } { - multi := newMultiPDFEngines(tc.engines...) - err := multi.Convert(tc.ctx, nil, "", "", "") + } - if tc.expectErr && err == nil { - t.Errorf("test %d: expected error but got: %v", i, err) - } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := tc.engine.Convert(tc.ctx, zap.NewNop(), "", "", "") - if !tc.expectErr && err != nil { - t.Errorf("test %d: expected no error but got: %v", i, err) - } + if tc.expectConvertErr && err == nil { + t.Errorf("expected engine.Convert() error, but got none") + } + + if !tc.expectConvertErr && err != nil { + t.Errorf("expected no error from engine.Convert(), but got: %v", err) + } + }) } } diff --git a/pkg/modules/pdfengines/pdfengines.go b/pkg/modules/pdfengines/pdfengines.go index 8d98aca5..761c0b3c 100644 --- a/pkg/modules/pdfengines/pdfengines.go +++ b/pkg/modules/pdfengines/pdfengines.go @@ -53,6 +53,18 @@ func (mod *PDFEngines) Provision(ctx *gotenberg.Context) error { names := flags.MustStringSlice("pdfengines-engines") mod.disableRoutes = flags.MustBool("pdfengines-disable-routes") + loggerProvider, err := ctx.Module(new(gotenberg.LoggerProvider)) + if err != nil { + return fmt.Errorf("get logger provider: %w", err) + } + + logger, err := loggerProvider.(gotenberg.LoggerProvider).Logger(mod) + if err != nil { + return fmt.Errorf("get logger: %w", err) + } + + logger = logger.Named("pdfengines") + engines, err := ctx.Modules(new(gotenberg.PDFEngine)) if err != nil { return fmt.Errorf("get PDF engines: %w", err) @@ -68,6 +80,13 @@ func (mod *PDFEngines) Provision(ctx *gotenberg.Context) error { // Selection from user. mod.names = names + for i, name := range names { + logger.Warn("unoconv-pdfengine is deprecated; prefer uno-pdfengine instead") + if name == "unoconv-pdfengine" { + mod.names[i] = "uno-pdfengine" + } + } + return nil } diff --git a/pkg/modules/pdfengines/pdfengines_test.go b/pkg/modules/pdfengines/pdfengines_test.go index 9369b99f..b10e3345 100644 --- a/pkg/modules/pdfengines/pdfengines_test.go +++ b/pkg/modules/pdfengines/pdfengines_test.go @@ -1,7 +1,6 @@ package pdfengines import ( - "context" "errors" "reflect" "strings" @@ -11,38 +10,7 @@ import ( "go.uber.org/zap" ) -type ProtoModule struct { - descriptor func() gotenberg.ModuleDescriptor -} - -func (mod ProtoModule) Descriptor() gotenberg.ModuleDescriptor { - return mod.descriptor() -} - -type ProtoValidator struct { - ProtoModule - validate func() error -} - -func (mod ProtoValidator) Validate() error { - return mod.validate() -} - -type ProtoPDFEngine struct { - ProtoValidator - merge func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error - convert func(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error -} - -func (mod ProtoPDFEngine) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { - return mod.merge(ctx, logger, inputPaths, outputPath) -} - -func (mod ProtoPDFEngine) Convert(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error { - return mod.convert(ctx, logger, format, inputPath, outputPath) -} - -func TestPDFEngine_Descriptor(t *testing.T) { +func TestPDFEngines_Descriptor(t *testing.T) { descriptor := PDFEngines{}.Descriptor() actual := reflect.TypeOf(descriptor.New()) @@ -53,71 +21,98 @@ func TestPDFEngine_Descriptor(t *testing.T) { } } -func TestPDFEngine_Provision(t *testing.T) { - for i, tc := range []struct { - ctx *gotenberg.Context - expectNames []string - expectEnginesCount int - expectErr bool +func TestPDFEngines_Provision(t *testing.T) { + tests := []struct { + name string + ctx *gotenberg.Context + expectPDFEngineNames []string + expectProvisionErr bool }{ { + name: "no selection from user", ctx: func() *gotenberg.Context { - engine := struct{ ProtoPDFEngine }{} - engine.descriptor = func() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return engine }} + provider := struct { + gotenberg.ModuleMock + gotenberg.LoggerProviderMock + }{} + provider.DescriptorMock = func() gotenberg.ModuleDescriptor { + return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { + return provider + }} + } + provider.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) { + return zap.NewNop(), nil + } + + engine := struct { + gotenberg.ModuleMock + gotenberg.ValidatorMock + gotenberg.PDFEngineMock + }{} + engine.DescriptorMock = func() gotenberg.ModuleDescriptor { + return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return engine }} + } + engine.ValidateMock = func() error { + return nil } - engine.validate = func() error { return errors.New("foo") } return gotenberg.NewContext( gotenberg.ParsedFlags{ FlagSet: new(PDFEngines).Descriptor().FlagSet, }, []gotenberg.ModuleDescriptor{ + provider.Descriptor(), engine.Descriptor(), }, ) }(), - expectErr: true, + expectPDFEngineNames: []string{"bar"}, }, { + name: "selection from user", ctx: func() *gotenberg.Context { - engine := struct{ ProtoPDFEngine }{} - engine.descriptor = func() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return engine }} + provider := struct { + gotenberg.ModuleMock + gotenberg.LoggerProviderMock + }{} + provider.DescriptorMock = func() gotenberg.ModuleDescriptor { + return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { + return provider + }} + } + provider.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) { + return zap.NewNop(), nil } - engine.validate = func() error { return nil } - return gotenberg.NewContext( - gotenberg.ParsedFlags{ - FlagSet: new(PDFEngines).Descriptor().FlagSet, - }, - []gotenberg.ModuleDescriptor{ - engine.Descriptor(), - }, - ) - }(), - expectNames: []string{"foo"}, - expectEnginesCount: 1, - }, - { - ctx: func() *gotenberg.Context { - engine1 := struct{ ProtoPDFEngine }{} - engine1.descriptor = func() gotenberg.ModuleDescriptor { + engine1 := struct { + gotenberg.ModuleMock + gotenberg.ValidatorMock + gotenberg.PDFEngineMock + }{} + engine1.DescriptorMock = func() gotenberg.ModuleDescriptor { return gotenberg.ModuleDescriptor{ID: "a", New: func() gotenberg.Module { return engine1 }} } - engine1.validate = func() error { return nil } + engine1.ValidateMock = func() error { + return nil + } - engine2 := struct{ ProtoPDFEngine }{} - engine2.descriptor = func() gotenberg.ModuleDescriptor { + engine2 := struct { + gotenberg.ModuleMock + gotenberg.ValidatorMock + gotenberg.PDFEngineMock + }{} + engine2.DescriptorMock = func() gotenberg.ModuleDescriptor { return gotenberg.ModuleDescriptor{ID: "b", New: func() gotenberg.Module { return engine2 }} } - engine2.validate = func() error { return nil } + engine2.ValidateMock = func() error { + return nil + } fs := new(PDFEngines).Descriptor().FlagSet err := fs.Parse([]string{"--pdfengines-engines=b", "--pdfengines-engines=a"}) if err != nil { - t.Fatalf("expected no error but got: %v", err) + t.Fatalf("expected no error from fs.Parse(), but got: %v", err) } return gotenberg.NewContext( @@ -125,33 +120,47 @@ func TestPDFEngine_Provision(t *testing.T) { FlagSet: fs, }, []gotenberg.ModuleDescriptor{ + provider.Descriptor(), engine1.Descriptor(), engine2.Descriptor(), }, ) }(), - expectNames: []string{"b", "a"}, - expectEnginesCount: 2, + expectPDFEngineNames: []string{"b", "a"}, }, { + name: "user select deprecated unoconv-pdfengine", ctx: func() *gotenberg.Context { - engine1 := struct{ ProtoPDFEngine }{} - engine1.descriptor = func() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ID: "a", New: func() gotenberg.Module { return engine1 }} + provider := struct { + gotenberg.ModuleMock + gotenberg.LoggerProviderMock + }{} + provider.DescriptorMock = func() gotenberg.ModuleDescriptor { + return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { + return provider + }} + } + provider.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) { + return zap.NewNop(), nil } - engine1.validate = func() error { return nil } - engine2 := struct{ ProtoPDFEngine }{} - engine2.descriptor = func() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ID: "b", New: func() gotenberg.Module { return engine2 }} + engine := struct { + gotenberg.ModuleMock + gotenberg.ValidatorMock + gotenberg.PDFEngineMock + }{} + engine.DescriptorMock = func() gotenberg.ModuleDescriptor { + return gotenberg.ModuleDescriptor{ID: "uno-pdfengine", New: func() gotenberg.Module { return engine }} + } + engine.ValidateMock = func() error { + return nil } - engine2.validate = func() error { return nil } fs := new(PDFEngines).Descriptor().FlagSet - err := fs.Parse([]string{"--pdfengines-engines=b"}) + err := fs.Parse([]string{"--pdfengines-engines=unoconv-pdfengine"}) if err != nil { - t.Fatalf("expected error but got: %v", err) + t.Fatalf("expected no error from fs.Parse(), but got: %v", err) } return gotenberg.NewContext( @@ -159,56 +168,136 @@ func TestPDFEngine_Provision(t *testing.T) { FlagSet: fs, }, []gotenberg.ModuleDescriptor{ - engine1.Descriptor(), - engine2.Descriptor(), + provider.Descriptor(), + engine.Descriptor(), }, ) }(), - expectNames: []string{"b"}, - expectEnginesCount: 2, + expectPDFEngineNames: []string{"uno-pdfengine"}, }, - } { - mod := new(PDFEngines) - err := mod.Provision(tc.ctx) + { + name: "no logger provider", + ctx: func() *gotenberg.Context { + return gotenberg.NewContext( + gotenberg.ParsedFlags{ + FlagSet: new(PDFEngines).Descriptor().FlagSet, + }, + []gotenberg.ModuleDescriptor{}, + ) + }(), + expectProvisionErr: true, + }, + { + name: "no logger from logger provider", + ctx: func() *gotenberg.Context { + provider := struct { + gotenberg.ModuleMock + gotenberg.LoggerProviderMock + }{} + provider.DescriptorMock = func() gotenberg.ModuleDescriptor { + return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { + return provider + }} + } + provider.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) { + return nil, errors.New("foo") + } - if tc.expectErr && err == nil { - t.Errorf("test %d: expected error but got: %v", i, err) - } + return gotenberg.NewContext( + gotenberg.ParsedFlags{ + FlagSet: new(PDFEngines).Descriptor().FlagSet, + }, + []gotenberg.ModuleDescriptor{ + provider.Descriptor(), + }, + ) + }(), + expectProvisionErr: true, + }, + { + name: "no valid PDF engines", + ctx: func() *gotenberg.Context { + provider := struct { + gotenberg.ModuleMock + gotenberg.LoggerProviderMock + }{} + provider.DescriptorMock = func() gotenberg.ModuleDescriptor { + return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { + return provider + }} + } + provider.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) { + return zap.NewNop(), nil + } - if !tc.expectErr && err != nil { - t.Errorf("test %d: expected no error but got: %v", i, err) - } + engine := struct { + gotenberg.ModuleMock + gotenberg.ValidatorMock + gotenberg.PDFEngineMock + }{} + engine.DescriptorMock = func() gotenberg.ModuleDescriptor { + return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return engine }} + } + engine.ValidateMock = func() error { + return errors.New("foo") + } - if len(tc.expectNames) != len(mod.names) { - t.Errorf("test %d: expected %d names but got %d", i, len(tc.expectNames), len(mod.names)) - } + return gotenberg.NewContext( + gotenberg.ParsedFlags{ + FlagSet: new(PDFEngines).Descriptor().FlagSet, + }, + []gotenberg.ModuleDescriptor{ + provider.Descriptor(), + engine.Descriptor(), + }, + ) + }(), + expectProvisionErr: true, + }, + } - if tc.expectEnginesCount != len(mod.engines) { - t.Errorf("test %d: expected %d engines but got %d", i, tc.expectEnginesCount, len(mod.engines)) - } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + mod := new(PDFEngines) + err := mod.Provision(tc.ctx) - for index, name := range mod.names { - if name != tc.expectNames[index] { - t.Errorf("test %d: expected name at index %d to be %s, but got: %s", i, index, name, tc.expectNames[index]) + if tc.expectProvisionErr && err == nil { + t.Fatal("expected mod.Provision() error, but got none") } - } + + if !tc.expectProvisionErr && err != nil { + t.Fatalf("expected no error from mod.Provision(), but got: %v", err) + } + + if len(tc.expectPDFEngineNames) != len(mod.names) { + t.Errorf("expected %d names but got %d", len(tc.expectPDFEngineNames), len(mod.names)) + } + + for index, name := range mod.names { + if name != tc.expectPDFEngineNames[index] { + t.Errorf("expected name at index %d to be %s, but got: %s", index, name, tc.expectPDFEngineNames[index]) + } + } + }) } } -func TestPDFEngine_Validate(t *testing.T) { - for i, tc := range []struct { - names []string - engines []gotenberg.PDFEngine - expectErr bool +func TestPDFEngines_Validate(t *testing.T) { + tests := []struct { + name string + names []string + engines []gotenberg.PDFEngine + expectValidateErr bool }{ { - expectErr: true, - }, - { + name: "existing PDF engine", names: []string{"foo"}, engines: func() []gotenberg.PDFEngine { - engine := struct{ ProtoPDFEngine }{} - engine.descriptor = func() gotenberg.ModuleDescriptor { + engine := struct { + gotenberg.ModuleMock + gotenberg.PDFEngineMock + }{} + engine.DescriptorMock = func() gotenberg.ModuleDescriptor { return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return engine }} } @@ -218,15 +307,22 @@ func TestPDFEngine_Validate(t *testing.T) { }(), }, { + name: "non-existing bar PDF engine", names: []string{"foo", "bar", "baz"}, engines: func() []gotenberg.PDFEngine { - engine1 := struct{ ProtoPDFEngine }{} - engine1.descriptor = func() gotenberg.ModuleDescriptor { + engine1 := struct { + gotenberg.ModuleMock + gotenberg.PDFEngineMock + }{} + engine1.DescriptorMock = func() gotenberg.ModuleDescriptor { return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return engine1 }} } - engine2 := struct{ ProtoPDFEngine }{} - engine2.descriptor = func() gotenberg.ModuleDescriptor { + engine2 := struct { + gotenberg.ModuleMock + gotenberg.PDFEngineMock + }{} + engine2.DescriptorMock = func() gotenberg.ModuleDescriptor { return gotenberg.ModuleDescriptor{ID: "baz", New: func() gotenberg.Module { return engine2 }} } @@ -235,21 +331,31 @@ func TestPDFEngine_Validate(t *testing.T) { engine2, } }(), - expectErr: true, + expectValidateErr: true, }, - } { - mod := new(PDFEngines) - mod.names = tc.names - mod.engines = tc.engines - err := mod.Validate() + { + name: "no PDF engine", + expectValidateErr: true, + }, + } - if tc.expectErr && err == nil { - t.Errorf("test %d: expected error but got: %v", i, err) - } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + mod := PDFEngines{ + names: tc.names, + engines: tc.engines, + } - if !tc.expectErr && err != nil { - t.Errorf("test %d: expected no error but got: %v", i, err) - } + err := mod.Validate() + + if tc.expectValidateErr && err == nil { + t.Errorf("expected mod.Validate() error, but got none") + } + + if !tc.expectValidateErr && err != nil { + t.Errorf("expected no error from mod.Validate(), but got: %v", err) + } + }) } } @@ -259,76 +365,81 @@ func TestPDFEngines_SystemMessages(t *testing.T) { messages := mod.SystemMessages() if len(messages) != 1 { - t.Errorf("expected one and only one message but got %d", len(messages)) + t.Errorf("expected one and only one message from mod.SystemMessages(), but got %d", len(messages)) } expect := strings.Join(mod.names[:], " ") if messages[0] != expect { - t.Errorf("expected message '%s' but got '%s'", expect, messages[0]) + t.Errorf("expected message '%s' from mod.SystemMessages(), but got '%s'", expect, messages[0]) } } -func TestPDFEngine_PDFEngine(t *testing.T) { - mod := new(PDFEngines) - mod.names = []string{"foo", "bar"} - mod.engines = func() []gotenberg.PDFEngine { - engine1 := struct{ ProtoPDFEngine }{} - engine1.descriptor = func() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return engine1 }} - } +func TestPDFEngines_PDFEngine(t *testing.T) { + mod := PDFEngines{ + names: []string{"foo", "bar"}, + engines: func() []gotenberg.PDFEngine { + engine1 := struct { + gotenberg.ModuleMock + gotenberg.PDFEngineMock + }{} + engine1.DescriptorMock = func() gotenberg.ModuleDescriptor { + return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return engine1 }} + } - engine2 := struct{ ProtoPDFEngine }{} - engine2.descriptor = func() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return engine2 }} - } + engine2 := struct { + gotenberg.ModuleMock + gotenberg.PDFEngineMock + }{} + engine2.DescriptorMock = func() gotenberg.ModuleDescriptor { + return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return engine2 }} + } - return []gotenberg.PDFEngine{ - engine1, - engine2, - } - }() + return []gotenberg.PDFEngine{ + engine1, + engine2, + } + }(), + } _, err := mod.PDFEngine() if err != nil { - t.Errorf("expected no error but got: %v", err) + t.Errorf("expected no error from mod.PDFEngine, but got: %v", err) } } -func TestPDFEngine_Routes(t *testing.T) { - for i, tc := range []struct { - expectRoutes int - disableRoutes bool +func TestPDFEngines_Routes(t *testing.T) { + tests := []struct { + name string + mod PDFEngines + expectRoutesCount int }{ { - expectRoutes: 2, + name: "route not disabled", + mod: PDFEngines{ + engines: []gotenberg.PDFEngine{ + gotenberg.PDFEngineMock{}, + }, + }, + expectRoutesCount: 2, }, { - disableRoutes: true, + name: "route disabled", + mod: PDFEngines{ + disableRoutes: true, + }, }, - } { - mod := new(PDFEngines) - mod.engines = []gotenberg.PDFEngine{ - struct{ ProtoPDFEngine }{}, - } - mod.disableRoutes = tc.disableRoutes + } - routes, err := mod.Routes() - if err != nil { - t.Fatalf("test %d: expected no error but got: %v", i, err) - } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + routes, err := tc.mod.Routes() + if err != nil { + t.Fatalf("expected no error from mod.Routes(), but got: %v", err) + } - if tc.expectRoutes != len(routes) { - t.Errorf("test %d: expected %d routes but got %d", i, tc.expectRoutes, len(routes)) - } + if tc.expectRoutesCount != len(routes) { + t.Errorf("expected %d routes from mod.Routes(), but got %d", tc.expectRoutesCount, len(routes)) + } + }) } } - -// Interface guards. -var ( - _ gotenberg.Module = (*ProtoModule)(nil) - _ gotenberg.Validator = (*ProtoValidator)(nil) - _ gotenberg.Module = (*ProtoValidator)(nil) - _ gotenberg.PDFEngine = (*ProtoPDFEngine)(nil) - _ gotenberg.Module = (*ProtoPDFEngine)(nil) - _ gotenberg.Validator = (*ProtoPDFEngine)(nil) -) diff --git a/pkg/modules/pdfengines/routes_test.go b/pkg/modules/pdfengines/routes_test.go index 550b21a5..ca8aa87b 100644 --- a/pkg/modules/pdfengines/routes_test.go +++ b/pkg/modules/pdfengines/routes_test.go @@ -13,7 +13,8 @@ import ( ) func TestMergeHandler(t *testing.T) { - for i, tc := range []struct { + tests := []struct { + name string ctx *api.MockContext engine gotenberg.PDFEngine expectErr bool @@ -22,34 +23,75 @@ func TestMergeHandler(t *testing.T) { expectOutputPathsCount int }{ { + name: "nominal behavior", + ctx: func() *api.MockContext { + ctx := &api.MockContext{Context: &api.Context{}} + ctx.SetFiles(map[string]string{ + "foo.pdf": "/foo/foo.pdf", + }) + + return ctx + }(), + engine: gotenberg.PDFEngineMock{ + MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { + return nil + }, + }, + expectOutputPathsCount: 1, + }, + { + name: "invalid form data: no PDF", ctx: &api.MockContext{Context: &api.Context{}}, expectErr: true, expectHTTPErr: true, expectHTTPStatus: http.StatusBadRequest, }, { + name: "merge fail", ctx: func() *api.MockContext { ctx := &api.MockContext{Context: &api.Context{}} - ctx.SetFiles(map[string]string{ "foo.pdf": "/foo/foo.pdf", }) return ctx }(), - engine: func() gotenberg.PDFEngine { - return &ProtoPDFEngine{ - merge: func(_ context.Context, _ *zap.Logger, _ []string, _ string) error { - return errors.New("foo") - }, - } - }(), + engine: gotenberg.PDFEngineMock{ + MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { + return errors.New("foo") + }, + }, expectErr: true, }, { + name: "nominal behavior with a PDF format", ctx: func() *api.MockContext { ctx := &api.MockContext{Context: &api.Context{}} + ctx.SetFiles(map[string]string{ + "foo.pdf": "/foo/foo.pdf", + }) + ctx.SetValues(map[string][]string{ + "pdfFormat": { + gotenberg.FormatPDFA1a, + }, + }) + return ctx + }(), + engine: gotenberg.PDFEngineMock{ + MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { + return nil + }, + ConvertMock: func(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error { + return nil + }, + }, + expectOutputPathsCount: 1, + }, + { + name: "convert to PDF format fail", + ctx: func() *api.MockContext { + ctx := &api.MockContext{Context: &api.Context{}} ctx.SetFiles(map[string]string{ "foo.pdf": "/foo/foo.pdf", }) @@ -61,153 +103,106 @@ func TestMergeHandler(t *testing.T) { return ctx }(), - engine: func() gotenberg.PDFEngine { - return &ProtoPDFEngine{ - merge: func(_ context.Context, _ *zap.Logger, _ []string, _ string) error { - return nil + engine: gotenberg.PDFEngineMock{ + MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { + return nil + }, + ConvertMock: func(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error { + return errors.New("foo") + }, + }, + expectErr: true, + }, + { + name: "invalid PDF format", + ctx: func() *api.MockContext { + ctx := &api.MockContext{Context: &api.Context{}} + ctx.SetFiles(map[string]string{ + "foo.pdf": "/foo/foo.pdf", + }) + ctx.SetValues(map[string][]string{ + "pdfFormat": { + "foo", }, - convert: func(_ context.Context, _ *zap.Logger, _, _, _ string) error { - return gotenberg.ErrPDFFormatNotAvailable - }, - } + }) + + return ctx }(), + engine: gotenberg.PDFEngineMock{ + MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { + return nil + }, + ConvertMock: func(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error { + return gotenberg.ErrPDFFormatNotAvailable + }, + }, expectErr: true, expectHTTPErr: true, expectHTTPStatus: http.StatusBadRequest, }, { + name: "cannot add output paths", ctx: func() *api.MockContext { ctx := &api.MockContext{Context: &api.Context{}} - ctx.SetFiles(map[string]string{ "foo.pdf": "/foo/foo.pdf", }) - ctx.SetValues(map[string][]string{ - "pdfFormat": { - "foo", - }, - }) - - return ctx - }(), - engine: func() gotenberg.PDFEngine { - return &ProtoPDFEngine{ - merge: func(_ context.Context, _ *zap.Logger, _ []string, _ string) error { - return nil - }, - convert: func(_ context.Context, _ *zap.Logger, _, _, _ string) error { - return errors.New("foo") - }, - } - }(), - expectErr: true, - }, - { - ctx: func() *api.MockContext { - ctx := &api.MockContext{Context: &api.Context{}} - ctx.SetCancelled(true) - ctx.SetFiles(map[string]string{ - "foo.pdf": "/foo/foo.pdf", - }) return ctx }(), - engine: func() gotenberg.PDFEngine { - return &ProtoPDFEngine{ - merge: func(_ context.Context, _ *zap.Logger, _ []string, _ string) error { - return nil - }, - } - }(), + engine: gotenberg.PDFEngineMock{ + MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { + return nil + }, + }, expectErr: true, }, - { - ctx: func() *api.MockContext { - ctx := &api.MockContext{Context: &api.Context{}} + } - ctx.SetFiles(map[string]string{ - "foo.pdf": "/foo/foo.pdf", - }) - ctx.SetValues(map[string][]string{ - "pdfFormat": { - "foo", - }, - }) + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + c := echo.New().NewContext(nil, nil) + c.Set("context", tc.ctx.Context) - return ctx - }(), - engine: func() gotenberg.PDFEngine { - return &ProtoPDFEngine{ - merge: func(_ context.Context, _ *zap.Logger, _ []string, _ string) error { - return nil - }, - convert: func(_ context.Context, _ *zap.Logger, _, _, _ string) error { - return nil - }, - } - }(), - expectOutputPathsCount: 1, - }, - { - ctx: func() *api.MockContext { - ctx := &api.MockContext{Context: &api.Context{}} + err := mergeRoute(tc.engine).Handler(c) - ctx.SetFiles(map[string]string{ - "foo.pdf": "/foo/foo.pdf", - }) - - return ctx - }(), - engine: func() gotenberg.PDFEngine { - return &ProtoPDFEngine{ - merge: func(_ context.Context, _ *zap.Logger, _ []string, _ string) error { - return nil - }, - } - }(), - expectOutputPathsCount: 1, - }, - } { - c := echo.New().NewContext(nil, nil) - c.Set("context", tc.ctx.Context) - - err := mergeRoute(tc.engine).Handler(c) - - if tc.expectErr && err == nil { - t.Errorf("test %d: expected error but got: %v", i, err) - } - - if !tc.expectErr && err != nil { - t.Errorf("test %d: expected no error but got: %v", i, err) - } - - var httpErr api.HTTPError - isHTTPErr := errors.As(err, &httpErr) - - if tc.expectHTTPErr && !isHTTPErr { - t.Errorf("test %d: expected HTTP error but got: %v", i, err) - } - - if !tc.expectHTTPErr && isHTTPErr { - t.Errorf("test %d: expected no HTTP error but got one: %v", i, httpErr) - } - - if err != nil && tc.expectHTTPErr && isHTTPErr { - status, _ := httpErr.HTTPError() - if status != tc.expectHTTPStatus { - t.Errorf("test %d: expected %d HTTP status code but got %d", i, tc.expectHTTPStatus, status) + if tc.expectErr && err == nil { + t.Fatal("expected error from merge handler, but got none") } - } - if tc.expectOutputPathsCount != len(tc.ctx.OutputPaths()) { - t.Errorf("test %d: expected %d output paths but got %d", i, tc.expectOutputPathsCount, len(tc.ctx.OutputPaths())) - } + if !tc.expectErr && err != nil { + t.Fatalf("expected no error from merge handler, but got: %v", err) + } + + var httpErr api.HTTPError + isHTTPErr := errors.As(err, &httpErr) + + if tc.expectHTTPErr && !isHTTPErr { + t.Errorf("expected HTTP error from merge handler, but got: %v", err) + } + + if !tc.expectHTTPErr && isHTTPErr { + t.Errorf("expected no HTTP error from merge handler, but got one: %v", httpErr) + } + + if err != nil && tc.expectHTTPErr && isHTTPErr { + status, _ := httpErr.HTTPError() + if status != tc.expectHTTPStatus { + t.Errorf("expected %d HTTP status code from merge handler, but got %d", tc.expectHTTPStatus, status) + } + } + + if tc.expectOutputPathsCount != len(tc.ctx.OutputPaths()) { + t.Errorf("expected %d output paths from merge handler, but got %d", tc.expectOutputPathsCount, len(tc.ctx.OutputPaths())) + } + }) } } func TestConvertHandler(t *testing.T) { - for i, tc := range []struct { + tests := []struct { + name string ctx *api.MockContext engine gotenberg.PDFEngine expectErr bool @@ -216,132 +211,109 @@ func TestConvertHandler(t *testing.T) { expectOutputPathsCount int }{ { - ctx: &api.MockContext{Context: &api.Context{}}, - expectErr: true, - expectHTTPErr: true, - expectHTTPStatus: http.StatusBadRequest, - }, - { + name: "nominal behavior", ctx: func() *api.MockContext { ctx := &api.MockContext{Context: &api.Context{}} - ctx.SetValues(map[string][]string{ - "pdfFormat": { - "foo", - }, - }) - - return ctx - }(), - expectErr: true, - expectHTTPErr: true, - expectHTTPStatus: http.StatusBadRequest, - }, - { - ctx: func() *api.MockContext { - ctx := &api.MockContext{Context: &api.Context{}} - ctx.SetFiles(map[string]string{ "foo.pdf": "/foo/foo.pdf", }) ctx.SetValues(map[string][]string{ "pdfFormat": { - "foo", + gotenberg.FormatPDFA1a, }, }) return ctx }(), - engine: func() gotenberg.PDFEngine { - return &ProtoPDFEngine{ - convert: func(_ context.Context, _ *zap.Logger, _, _, _ string) error { - return gotenberg.ErrPDFFormatNotAvailable - }, - } - }(), - expectErr: true, - expectHTTPErr: true, - expectHTTPStatus: http.StatusBadRequest, - }, - { - ctx: func() *api.MockContext { - ctx := &api.MockContext{Context: &api.Context{}} - - ctx.SetFiles(map[string]string{ - "foo.pdf": "/foo/foo.pdf", - }) - ctx.SetValues(map[string][]string{ - "pdfFormat": { - "foo", - }, - }) - - return ctx - }(), - engine: func() gotenberg.PDFEngine { - return &ProtoPDFEngine{ - convert: func(_ context.Context, _ *zap.Logger, _, _, _ string) error { - return errors.New("foo") - }, - } - }(), - expectErr: true, - }, - { - ctx: func() *api.MockContext { - ctx := &api.MockContext{Context: &api.Context{}} - - ctx.SetCancelled(true) - ctx.SetFiles(map[string]string{ - "foo.pdf": "/foo/foo.pdf", - }) - ctx.SetValues(map[string][]string{ - "pdfFormat": { - "foo", - }, - }) - - return ctx - }(), - engine: func() gotenberg.PDFEngine { - return &ProtoPDFEngine{ - convert: func(_ context.Context, _ *zap.Logger, _, _, _ string) error { - return nil - }, - } - }(), - expectErr: true, - }, - { - ctx: func() *api.MockContext { - ctx := &api.MockContext{Context: &api.Context{}} - - ctx.SetFiles(map[string]string{ - "foo.pdf": "/foo/foo.pdf", - }) - ctx.SetValues(map[string][]string{ - "pdfFormat": { - "foo", - }, - }) - - return ctx - }(), - engine: func() gotenberg.PDFEngine { - return &ProtoPDFEngine{ - convert: func(_ context.Context, _ *zap.Logger, _, _, _ string) error { - return nil - }, - } - }(), + engine: gotenberg.PDFEngineMock{ + ConvertMock: func(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error { + return nil + }, + }, expectOutputPathsCount: 1, }, { + name: "nominal behavior, but with 3 PDFs", + ctx: func() *api.MockContext { + ctx := &api.MockContext{Context: &api.Context{}} + ctx.SetFiles(map[string]string{ + "foo.pdf": "/foo/foo.pdf", + "bar.pdf": "/bar/bar.pdf", + "baz.pdf": "/baz/baz.pdf", + }) + ctx.SetValues(map[string][]string{ + "pdfFormat": { + gotenberg.FormatPDFA1a, + }, + }) + + return ctx + }(), + engine: gotenberg.PDFEngineMock{ + ConvertMock: func(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error { + return nil + }, + }, + expectOutputPathsCount: 3, + }, + { + name: "invalid form data: no PDF", + ctx: func() *api.MockContext { + ctx := &api.MockContext{Context: &api.Context{}} + ctx.SetValues(map[string][]string{ + "pdfFormat": { + gotenberg.FormatPDFA1a, + }, + }) + + return ctx + }(), + expectErr: true, + expectHTTPErr: true, + expectHTTPStatus: http.StatusBadRequest, + }, + { + name: "invalid form data: no PDF format", + ctx: func() *api.MockContext { + ctx := &api.MockContext{Context: &api.Context{}} + ctx.SetFiles(map[string]string{ + "foo.pdf": "/foo/foo.pdf", + }) + + return ctx + }(), + expectErr: true, + expectHTTPErr: true, + expectHTTPStatus: http.StatusBadRequest, + }, + { + name: "convert to PDF format fail", + ctx: func() *api.MockContext { + ctx := &api.MockContext{Context: &api.Context{}} + ctx.SetFiles(map[string]string{ + "foo.pdf": "/foo/foo.pdf", + }) + ctx.SetValues(map[string][]string{ + "pdfFormat": { + gotenberg.FormatPDFA1a, + }, + }) + + return ctx + }(), + engine: gotenberg.PDFEngineMock{ + ConvertMock: func(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error { + return errors.New("foo") + }, + }, + expectErr: true, + }, + { + name: "PDF format not available", ctx: func() *api.MockContext { ctx := &api.MockContext{Context: &api.Context{}} - ctx.SetFiles(map[string]string{ "foo.pdf": "/foo/foo.pdf", - "bar.pdf": "/foo/bar.pdf", }) ctx.SetValues(map[string][]string{ "pdfFormat": { @@ -351,49 +323,76 @@ func TestConvertHandler(t *testing.T) { return ctx }(), - engine: func() gotenberg.PDFEngine { - return &ProtoPDFEngine{ - convert: func(_ context.Context, _ *zap.Logger, _, _, _ string) error { - return nil - }, - } - }(), - expectOutputPathsCount: 2, + engine: gotenberg.PDFEngineMock{ + ConvertMock: func(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error { + return gotenberg.ErrPDFFormatNotAvailable + }, + }, + expectErr: true, + expectHTTPErr: true, + expectHTTPStatus: http.StatusBadRequest, }, - } { - c := echo.New().NewContext(nil, nil) - c.Set("context", tc.ctx.Context) + { + name: "cannot add output paths", + ctx: func() *api.MockContext { + ctx := &api.MockContext{Context: &api.Context{}} + ctx.SetFiles(map[string]string{ + "foo.pdf": "/foo/foo.pdf", + }) + ctx.SetValues(map[string][]string{ + "pdfFormat": { + gotenberg.FormatPDFA1a, + }, + }) + ctx.SetCancelled(true) - err := convertRoute(tc.engine).Handler(c) + return ctx + }(), + engine: gotenberg.PDFEngineMock{ + ConvertMock: func(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error { + return nil + }, + }, + expectErr: true, + }, + } - if tc.expectErr && err == nil { - t.Errorf("test %d: expected error but got: %v", i, err) - } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + c := echo.New().NewContext(nil, nil) + c.Set("context", tc.ctx.Context) - if !tc.expectErr && err != nil { - t.Errorf("test %d: expected no error but got: %v", i, err) - } + err := convertRoute(tc.engine).Handler(c) - var httpErr api.HTTPError - isHTTPErr := errors.As(err, &httpErr) - - if tc.expectHTTPErr && !isHTTPErr { - t.Errorf("test %d: expected HTTP error but got: %v", i, err) - } - - if !tc.expectHTTPErr && isHTTPErr { - t.Errorf("test %d: expected no HTTP error but got one: %v", i, httpErr) - } - - if err != nil && tc.expectHTTPErr && isHTTPErr { - status, _ := httpErr.HTTPError() - if status != tc.expectHTTPStatus { - t.Errorf("test %d: expected %d HTTP status code but got %d", i, tc.expectHTTPStatus, status) + if tc.expectErr && err == nil { + t.Fatal("expected error from convert handler, but got none") } - } - if tc.expectOutputPathsCount != len(tc.ctx.OutputPaths()) { - t.Errorf("test %d: expected %d output paths but got %d", i, tc.expectOutputPathsCount, len(tc.ctx.OutputPaths())) - } + if !tc.expectErr && err != nil { + t.Fatalf("expected no error from convert handler, but got: %v", err) + } + + var httpErr api.HTTPError + isHTTPErr := errors.As(err, &httpErr) + + if tc.expectHTTPErr && !isHTTPErr { + t.Errorf("expected HTTP error from convert handler, but got: %v", err) + } + + if !tc.expectHTTPErr && isHTTPErr { + t.Errorf("expected no HTTP error from convert handler, but got one: %v", httpErr) + } + + if err != nil && tc.expectHTTPErr && isHTTPErr { + status, _ := httpErr.HTTPError() + if status != tc.expectHTTPStatus { + t.Errorf("expected %d HTTP status code from convert handler, but got %d", tc.expectHTTPStatus, status) + } + } + + if tc.expectOutputPathsCount != len(tc.ctx.OutputPaths()) { + t.Errorf("expected %d output paths from convert handler, but got %d", tc.expectOutputPathsCount, len(tc.ctx.OutputPaths())) + } + }) } } diff --git a/pkg/modules/pdftk/pdftk.go b/pkg/modules/pdftk/pdftk.go index adbcea75..064b5548 100644 --- a/pkg/modules/pdftk/pdftk.go +++ b/pkg/modules/pdftk/pdftk.go @@ -83,7 +83,7 @@ func (engine PDFtk) Merge(ctx context.Context, logger *zap.Logger, inputPaths [] activeInstancesCount += 1 activeInstancesCountMu.Unlock() - err = cmd.Exec() + _, err = cmd.Exec() activeInstancesCountMu.Lock() activeInstancesCount -= 1 diff --git a/pkg/modules/qpdf/qpdf.go b/pkg/modules/qpdf/qpdf.go index 89923373..b04b0685 100644 --- a/pkg/modules/qpdf/qpdf.go +++ b/pkg/modules/qpdf/qpdf.go @@ -85,7 +85,7 @@ func (engine QPDF) Merge(ctx context.Context, logger *zap.Logger, inputPaths []s activeInstancesCount += 1 activeInstancesCountMu.Unlock() - err = cmd.Exec() + _, err = cmd.Exec() activeInstancesCountMu.Lock() activeInstancesCount -= 1 diff --git a/pkg/standard/imports.go b/pkg/standard/imports.go index 1046aacb..40bf3020 100644 --- a/pkg/standard/imports.go +++ b/pkg/standard/imports.go @@ -7,7 +7,7 @@ import ( _ "github.com/gotenberg/gotenberg/v7/pkg/modules/gc" _ "github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice" _ "github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/pdfengine" - _ "github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/unoconv" + _ "github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/uno" _ "github.com/gotenberg/gotenberg/v7/pkg/modules/logging" _ "github.com/gotenberg/gotenberg/v7/pkg/modules/pdfcpu" _ "github.com/gotenberg/gotenberg/v7/pkg/modules/pdfengines" diff --git a/test/Dockerfile b/test/Dockerfile index 74568541..afdd6bb4 100644 --- a/test/Dockerfile +++ b/test/Dockerfile @@ -42,4 +42,4 @@ RUN apt-get update -qq &&\ # Pristine working directory. WORKDIR /tests -ENTRYPOINT [ "docker-entrypoint.sh" ] \ No newline at end of file +ENTRYPOINT [ "/usr/bin/tini", "--", "docker-entrypoint.sh" ] \ No newline at end of file