feat: add more PDF formats, drastically improve LibreOffice long-running instance management

This commit is contained in:
Julien Neuhart
2022-02-07 18:28:58 +01:00
parent 3d855712ee
commit 24817d5707
34 changed files with 3883 additions and 2377 deletions

View File

@@ -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) \

View File

@@ -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

View File

@@ -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())
}
}

View File

@@ -12,209 +12,313 @@ func TestCommand(t *testing.T) {
cmd := Command(zap.NewNop(), "foo")
if !cmd.process.SysProcAttr.Setpgid {
t.Error("expected Setpgid to be true")
t.Error("expected cmd.process.SysProcAttr.Setpgid to be true")
}
}
func TestCommandContext(t *testing.T) {
for i, tc := range []struct {
ctx context.Context
expectErr bool
tests := []struct {
name string
ctx context.Context
expectCommandContextErr bool
}{
{
ctx: nil,
expectErr: true,
name: "nominal behavior",
ctx: context.Background(),
},
{
ctx: context.TODO(),
name: "nil context",
expectCommandContextErr: true,
},
} {
cmd, err := CommandContext(tc.ctx, zap.NewNop(), "foo")
}
if err == nil && !cmd.process.SysProcAttr.Setpgid {
t.Fatalf("test %d: expected Setpgid to be true", i)
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
cmd, err := CommandContext(tc.ctx, zap.NewNop(), "foo")
if tc.expectErr && err == nil {
t.Errorf("test %d: expected error but got: %v", i, err)
}
if err == nil && !cmd.process.SysProcAttr.Setpgid {
t.Fatal("expected cmd.process.SysProcAttr.Setpgid to be true")
}
if !tc.expectErr && err != nil {
t.Errorf("test %d: expected no error but got: %v", i, err)
}
if tc.expectCommandContextErr && err == nil {
t.Error("expected error from CommandContext(), but got none")
}
if !tc.expectCommandContextErr && err != nil {
t.Errorf("expected no error from CommandContext(), but got: %v", err)
}
})
}
}
func TestCmd_Start(t *testing.T) {
for i, tc := range []struct {
cmd Cmd
expectErr bool
tests := []struct {
name string
cmd Cmd
expectStartErr bool
}{
{
cmd: Command(zap.NewNop(), "foo"),
expectErr: true,
name: "nominal behavior",
cmd: Command(zap.NewNop(), "echo", "Hello", "World"),
},
{
cmd: Command(zap.NewNop(), "echo", "Hello", "World"),
name: "start error",
cmd: Command(zap.NewNop(), "foo"),
expectStartErr: true,
},
} {
err := tc.cmd.Start()
}
if tc.expectErr && err == nil {
t.Errorf("test %d: expected error but got: %v", i, err)
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := tc.cmd.Start()
if !tc.expectErr && err != nil {
t.Errorf("test %d: expected no error but got: %v", i, err)
}
if tc.expectStartErr && err == nil {
t.Error("expected error from cmd.Start(), but got none")
}
if !tc.expectStartErr && err != nil {
t.Errorf("expected no error from cmd.Start(), but got: %v", err)
}
})
}
}
func TestCmd_Wait(t *testing.T) {
tests := []struct {
name string
cmd Cmd
expectWaitErr bool
}{
{
name: "nominal behavior",
cmd: func() Cmd {
cmd := Command(zap.NewNop(), "echo", "Hello", "World")
err := cmd.Start()
if err != nil {
t.Fatalf("expected no error from cmd.Start(), but got: %v", err)
}
return cmd
}(),
},
{
name: "wait error",
cmd: func() Cmd {
cmd := Command(zap.NewNop(), "echo", "Hello", "World")
err := cmd.Start()
if err != nil {
t.Fatalf("expected no error from cmd.Start(), but got: %v", err)
}
err = cmd.Kill()
if err != nil {
t.Fatalf("expected no error from cmd.Kill(), but got: %v", err)
}
return cmd
}(),
expectWaitErr: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := tc.cmd.Wait()
if tc.expectWaitErr && err == nil {
t.Error("expected error from cmd.Wait(), but got none")
}
if !tc.expectWaitErr && err != nil {
t.Errorf("expected no error from cmd.Wait(), but got: %v", err)
}
})
}
}
func TestCmd_Exec(t *testing.T) {
for i, tc := range []struct {
cmd Cmd
timeout time.Duration
expectErr bool
tests := []struct {
name string
cmd Cmd
timeout time.Duration
expectExecErr bool
}{
{
cmd: Command(zap.NewNop(), "foo"),
expectErr: true,
name: "nominal behavior",
cmd: func() Cmd {
cmd, err := CommandContext(context.Background(), zap.NewNop(), "echo", "Hello", "World")
if err != nil {
t.Fatalf("expected no error from CommandContext(), but got: %v", err)
}
return cmd
}(),
},
{
cmd: Command(zap.NewNop(), "foo"),
timeout: time.Duration(5) * time.Second,
expectErr: true,
name: "nil context",
cmd: Command(zap.NewNop(), "echo", "Hello", "World"),
expectExecErr: true,
},
{
cmd: Command(zap.NewNop(), "echo", "Hello", "World"),
timeout: time.Duration(5) * time.Second,
name: "start error",
cmd: func() Cmd {
cmd, err := CommandContext(context.Background(), zap.NewNop(), "foo")
if err != nil {
t.Fatalf("expected no error from CommandContext(), but got: %v", err)
}
return cmd
}(),
expectExecErr: true,
},
{
cmd: Command(zap.NewNop(), "sleep", "3"),
timeout: time.Duration(2) * time.Second,
expectErr: true,
name: "context done",
cmd: Command(zap.NewNop(), "sleep", "2"),
timeout: time.Duration(1) * time.Second,
expectExecErr: true,
},
} {
if tc.timeout > 0 {
ctx, cancel := context.WithTimeout(context.TODO(), tc.timeout)
defer cancel()
}
tc.cmd.ctx = ctx
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if tc.timeout > 0 {
ctx, cancel := context.WithTimeout(context.TODO(), tc.timeout)
defer cancel()
err := tc.cmd.Exec()
tc.cmd.ctx = ctx
}
if tc.expectErr && err == nil {
t.Errorf("test %d: expected error but got: %v", i, err)
}
_, err := tc.cmd.Exec()
if !tc.expectErr && err != nil {
t.Errorf("test %d: expected no error but got: %v", i, err)
}
if tc.expectExecErr && err == nil {
t.Error("expected error from cmd.Exec(), but got none")
}
if !tc.expectExecErr && err != nil {
t.Errorf("expected no error from cmd.Exec(), but got: %v", err)
}
})
}
}
func TestCmd_pipeOutput(t *testing.T) {
for i, tc := range []struct {
cmd Cmd
run bool
expectErr bool
tests := []struct {
name string
cmd Cmd
run bool
expectPipeOutputErr bool
}{
{
cmd: Command(zap.NewNop(), "echo", "Hello", "World"),
name: "nominal behavior",
cmd: Command(zap.NewExample(), "echo", "Hello", "World"),
run: true,
},
{
name: "no debug, no pipe",
cmd: Command(zap.NewNop(), "echo", "Hello", "World"),
},
{
name: "stdout already piped",
cmd: func() Cmd {
cmd := Command(zap.NewExample(), "echo", "Hello", "World")
_, err := cmd.process.StdoutPipe()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
t.Fatalf("expected no error from cmd.process.StdoutPipe(), but got: %v", err)
}
return cmd
}(),
expectErr: true,
expectPipeOutputErr: true,
},
{
name: "stderr already piped",
cmd: func() Cmd {
cmd := Command(zap.NewExample(), "echo", "Hello", "World")
_, err := cmd.process.StderrPipe()
_, err := cmd.process.StderrPipe()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
t.Fatalf("expected no error from cmd.process.StderrPipe(), but got: %v", err)
}
return cmd
}(),
expectErr: true,
expectPipeOutputErr: true,
},
{
cmd: Command(zap.NewExample(), "echo", "Hello", "World"),
run: true,
},
} {
err := tc.cmd.pipeOutput()
}
if tc.run {
errStart := tc.cmd.process.Start()
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := tc.cmd.pipeOutput()
if errStart != nil {
t.Fatalf("test %d: expected no error but got: %v", i, err)
if tc.run {
errStart := tc.cmd.process.Start()
if errStart != nil {
t.Fatalf("expected no error from tc.cmd.process.Start(), but got: %v", errStart)
}
}
}
if tc.expectErr && err == nil {
t.Errorf("test %d: expected error but got: %v", i, err)
}
if tc.expectPipeOutputErr && err == nil {
t.Error("expected error from cmd.pipeOutput(), but got none")
}
if !tc.expectErr && err != nil {
t.Errorf("test %d: expected no error but got: %v", i, err)
}
if !tc.expectPipeOutputErr && err != nil {
t.Errorf("expected no error from cmd.pipeOutput(), but got: %v", err)
}
})
}
}
func TestCmd_Kill(t *testing.T) {
for i, tc := range []struct {
cmd Cmd
expectErr bool
tests := []struct {
name string
cmd Cmd
}{
{
cmd: Cmd{logger: zap.NewNop()},
},
{
name: "nominal behavior",
cmd: func() Cmd {
cmd := Command(zap.NewNop(), "sleep", "60")
err := cmd.process.Start()
err := cmd.process.Start()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
t.Fatalf("expected no error from cmd.process.Start(), but got: %v", err)
}
return cmd
}(),
},
{
name: "no process",
cmd: Cmd{logger: zap.NewNop()},
},
{
name: "process already killed",
cmd: func() Cmd {
cmd := Command(zap.NewNop(), "echo", "Hello", "World")
err := cmd.process.Run()
cmd := Command(zap.NewNop(), "sleep", "60")
err := cmd.process.Start()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
t.Fatalf("expected no error from cmd.process.Start(), but got: %v", err)
}
err = cmd.Kill()
if err != nil {
t.Fatalf("expected no error from cmd.Kill(), but got: %v", err)
}
return cmd
}(),
},
} {
err := tc.cmd.Kill()
}
if tc.expectErr && err == nil {
t.Errorf("test %d: expected error but got: %v", i, err)
}
if !tc.expectErr && err != nil {
t.Errorf("test %d: expected no error but got: %v", i, err)
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := tc.cmd.Kill()
if err != nil {
t.Errorf("expected no error from cmd.Kill(), but got: %v", err)
}
})
}
}

66
pkg/gotenberg/mocks.go Normal file
View File

@@ -0,0 +1,66 @@
package gotenberg
import (
"context"
"go.uber.org/zap"
)
// ModuleMock is a mock for the Module interface.
type ModuleMock struct {
DescriptorMock func() ModuleDescriptor
}
func (mod ModuleMock) Descriptor() ModuleDescriptor {
return mod.DescriptorMock()
}
// ValidatorMock is a mock for the Validator interface.
type ValidatorMock struct {
ValidateMock func() error
}
func (mod ValidatorMock) Validate() error {
return mod.ValidateMock()
}
// PDFEngineMock is a mock for the PDFEngine interface.
type PDFEngineMock struct {
MergeMock func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error
ConvertMock func(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error
}
func (engine PDFEngineMock) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return engine.MergeMock(ctx, logger, inputPaths, outputPath)
}
func (engine PDFEngineMock) Convert(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error {
return engine.ConvertMock(ctx, logger, format, inputPath, outputPath)
}
// PDFEngineProviderMock is a mock for the PDFEngineProvider interface.
type PDFEngineProviderMock struct {
PDFEngineMock func() (PDFEngine, error)
}
func (provider PDFEngineProviderMock) PDFEngine() (PDFEngine, error) {
return provider.PDFEngineMock()
}
// LoggerProviderMock is a mock for the LoggerProvider interface.
type LoggerProviderMock struct {
LoggerMock func(mod Module) (*zap.Logger, error)
}
func (provider LoggerProviderMock) Logger(mod Module) (*zap.Logger, error) {
return provider.LoggerMock(mod)
}
// Interface guards.
var (
_ Module = (*ModuleMock)(nil)
_ Validator = (*ValidatorMock)(nil)
_ PDFEngine = (*PDFEngineMock)(nil)
_ PDFEngineProvider = (*PDFEngineProviderMock)(nil)
_ LoggerProvider = (*LoggerProviderMock)(nil)
)

View File

@@ -0,0 +1,82 @@
package gotenberg
import (
"context"
"testing"
"go.uber.org/zap"
)
func TestModuleMock(t *testing.T) {
mock := ModuleMock{
DescriptorMock: func() ModuleDescriptor {
return ModuleDescriptor{ID: "foo", New: func() Module {
return nil
}}
},
}
if mock.Descriptor().ID != "foo" {
t.Errorf("expected ID '%s' from mock.Descriptor(), but got '%s'", "foo", mock.Descriptor().ID)
}
}
func TestValidatorMock(t *testing.T) {
mock := ValidatorMock{
ValidateMock: func() error {
return nil
},
}
err := mock.Validate()
if err != nil {
t.Errorf("expected no error from mock.Validate(), but got: %v", err)
}
}
func TestPDFEngineMock(t *testing.T) {
mock := PDFEngineMock{
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return nil
},
ConvertMock: func(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error {
return nil
},
}
err := mock.Merge(context.Background(), zap.NewNop(), nil, "")
if err != nil {
t.Errorf("expected no error from mock.Merge(), but got: %v", err)
}
err = mock.Convert(context.Background(), zap.NewNop(), "", "", "")
if err != nil {
t.Errorf("expected no error from mock.Convert(), but got: %v", err)
}
}
func TestPDFEngineProvider(t *testing.T) {
mock := PDFEngineProviderMock{
PDFEngineMock: func() (PDFEngine, error) {
return PDFEngineMock{}, nil
},
}
_, err := mock.PDFEngine()
if err != nil {
t.Errorf("expected no error from mock.PDFEngine(), but got: %v", err)
}
}
func TestLoggerProviderMock(t *testing.T) {
mock := LoggerProviderMock{
LoggerMock: func(mod Module) (*zap.Logger, error) {
return nil, nil
},
}
_, err := mock.Logger(ModuleMock{})
if err != nil {
t.Errorf("expected no error from mock.Logger(), but got: %v", err)
}
}

View File

@@ -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{}}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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)
)

View File

@@ -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

View File

@@ -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)
)

View File

@@ -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)
)

View File

@@ -7,13 +7,13 @@ import (
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v7/pkg/modules/api"
"github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/unoconv"
"github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/uno"
"github.com/labstack/echo/v4"
)
// convertRoute returns an api.Route which can convert LibreOffice documents
// to PDF.
func convertRoute(uno unoconv.API, engine gotenberg.PDFEngine) api.Route {
func convertRoute(unoAPI uno.API, engine gotenberg.PDFEngine) api.Route {
return api.Route{
Method: http.MethodPost,
Path: "/forms/libreoffice/convert",
@@ -27,15 +27,17 @@ func convertRoute(uno unoconv.API, engine gotenberg.PDFEngine) api.Route {
landscape bool
nativePageRanges string
nativePDFA1aFormat bool
nativePDFformat string
PDFformat string
merge bool
)
err := ctx.FormData().
MandatoryPaths(uno.Extensions(), &inputPaths).
MandatoryPaths(unoAPI.Extensions(), &inputPaths).
Bool("landscape", &landscape, false).
String("nativePageRanges", &nativePageRanges, "").
Bool("nativePdfA1aFormat", &nativePDFA1aFormat, false).
String("nativePdfFormat", &nativePDFformat, "").
String("pdfFormat", &PDFformat, "").
Bool("merge", &merge, false).
Validate()
@@ -44,13 +46,35 @@ func convertRoute(uno unoconv.API, engine gotenberg.PDFEngine) api.Route {
return fmt.Errorf("validate form data: %w", err)
}
if nativePDFA1aFormat {
ctx.Log().Warn("'nativePdfA1aFormat' is deprecated; prefer 'nativePdfFormat' or 'pdfFormat' form fields instead")
}
if nativePDFA1aFormat && nativePDFformat != "" {
return api.WrapError(
errors.New("got both 'nativePdfFormat' and 'nativePdfA1aFormat' form fields"),
api.NewSentinelHTTPError(http.StatusBadRequest, "Both 'nativePdfFormat' and 'nativePdfA1aFormat' form fields are provided"),
)
}
if nativePDFA1aFormat && PDFformat != "" {
return api.WrapError(
errors.New("got both 'pdfFormat' and 'nativePdfA1aFormat' form values"),
api.NewSentinelHTTPError(http.StatusBadRequest, "Both 'pdfFormat' and 'nativePdfA1aFormat' form values are provided"),
errors.New("got both 'pdfFormat' and 'nativePdfA1aFormat' form fields"),
api.NewSentinelHTTPError(http.StatusBadRequest, "Both 'pdfFormat' and 'nativePdfA1aFormat' form fields are provided"),
)
}
if nativePDFformat != "" && PDFformat != "" {
return api.WrapError(
errors.New("got both 'pdfFormat' and 'nativePdfFormat' form fields"),
api.NewSentinelHTTPError(http.StatusBadRequest, "Both 'pdfFormat' and 'nativePdfFormat' form fields are provided"),
)
}
if nativePDFA1aFormat {
nativePDFformat = gotenberg.FormatPDFA1a
}
// Alright, let's convert each document to PDF.
outputPaths := make([]string, len(inputPaths))
@@ -58,16 +82,16 @@ func convertRoute(uno unoconv.API, engine gotenberg.PDFEngine) api.Route {
for i, inputPath := range inputPaths {
outputPaths[i] = ctx.GeneratePath(".pdf")
options := unoconv.Options{
options := uno.Options{
Landscape: landscape,
PageRanges: nativePageRanges,
PDFArchive: nativePDFA1aFormat,
PDFformat: nativePDFformat,
}
err = uno.PDF(ctx, ctx.Log(), inputPath, outputPaths[i], options)
err = unoAPI.PDF(ctx, ctx.Log(), inputPath, outputPaths[i], options)
if err != nil {
if errors.Is(err, unoconv.ErrMalformedPageRanges) {
if errors.Is(err, uno.ErrMalformedPageRanges) {
return api.WrapError(
fmt.Errorf("convert to PDF: %w", err),
api.NewSentinelHTTPError(http.StatusBadRequest, fmt.Sprintf("Malformed page ranges '%s' (nativePageRanges)", options.PageRanges)),
@@ -92,10 +116,8 @@ func convertRoute(uno unoconv.API, engine gotenberg.PDFEngine) api.Route {
// Now, let's check if the client want to convert this result
// PDF to a specific PDF format.
// Note: nativePdfA1aFormat has not been specified if we reach
// this part of the code. Indeed, the handler returns early on
// an error if both nativePdfA1aFormat and pdfFormat are
// present.
// Note: nativePdfA1aFormat/nativePdfFormat have not been
// specified if PDFformat is not empty.
if PDFformat != "" {
convertInputPath := outputPath
@@ -135,9 +157,8 @@ func convertRoute(uno unoconv.API, engine gotenberg.PDFEngine) api.Route {
// Ok, we don't have to merge the PDFs. Let's check if the client
// want to convert each PDF to a specific PDF format.
// Note: nativePdfA1aFormat has not been specified if we reach this
// part of the code. Indeed, the handler returns early on an error
// if both nativePdfA1aFormat and pdfFormat are present.
// Note: nativePdfA1aFormat/nativePdfFormat have not been
// specified if PDFformat is not empty.
if PDFformat != "" {
convertOutputPaths := make([]string, len(outputPaths))

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
// Package uno provides a module which interacts with the UNO
// (Universal Network Objects) API.
package uno

View File

@@ -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 {

View File

@@ -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)
)

View File

@@ -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")
}
}

View File

@@ -0,0 +1,36 @@
package uno
import (
"context"
"go.uber.org/zap"
)
// APIMock is a mock for the API interface.
type APIMock struct {
PDFMock func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error
ExtensionsMock func() []string
}
func (api APIMock) PDF(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error {
return api.PDFMock(ctx, logger, inputPath, outputPath, options)
}
func (api APIMock) Extensions() []string {
return api.ExtensionsMock()
}
// ProviderMock is a mock for the Provider interface.
type ProviderMock struct {
UNOMock func() (API, error)
}
func (provider ProviderMock) UNO() (API, error) {
return provider.UNOMock()
}
// Interface guards.
var (
_ API = (*APIMock)(nil)
_ Provider = (*ProviderMock)(nil)
)

View File

@@ -0,0 +1,42 @@
package uno
import (
"context"
"testing"
"go.uber.org/zap"
)
func TestAPIMock(t *testing.T) {
mock := APIMock{
PDFMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error {
return nil
},
ExtensionsMock: func() []string {
return nil
},
}
err := mock.PDF(context.Background(), zap.NewNop(), "", "", Options{})
if err != nil {
t.Errorf("expected no error from mock.PDF(), but got: %v", err)
}
ext := mock.Extensions()
if ext != nil {
t.Errorf("expected no extensions from mock.Extensions(), but got: %+v", ext)
}
}
func TestProviderMock(t *testing.T) {
mock := ProviderMock{
UNOMock: func() (API, error) {
return APIMock{}, nil
},
}
_, err := mock.UNO()
if err != nil {
t.Errorf("expected no error from mock.UNO(), but got: %v", err)
}
}

View File

@@ -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)
)

View File

@@ -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)
)

View File

@@ -1,2 +0,0 @@
// Package unoconv provides a module which abstracts the CLI tool unoconv.
package unoconv

View File

@@ -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)
)

View File

@@ -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)
)

View File

@@ -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)
}
})
}
}

View File

@@ -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
}

View File

@@ -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)
)

View File

@@ -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()))
}
})
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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"

View File

@@ -42,4 +42,4 @@ RUN apt-get update -qq &&\
# Pristine working directory.
WORKDIR /tests
ENTRYPOINT [ "docker-entrypoint.sh" ]
ENTRYPOINT [ "/usr/bin/tini", "--", "docker-entrypoint.sh" ]