mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-08-15 20:02:15 +01:00
chore(libreoffice): switch to supervisor (#708)
This commit is contained in:
388
pkg/modules/libreoffice/api/api.go
Normal file
388
pkg/modules/libreoffice/api/api.go
Normal file
@@ -0,0 +1,388 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/alexliesenfeld/health"
|
||||
flag "github.com/spf13/pflag"
|
||||
"go.uber.org/multierr"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/modules/api"
|
||||
)
|
||||
|
||||
func init() {
|
||||
gotenberg.MustRegisterModule(new(Api))
|
||||
}
|
||||
|
||||
var (
|
||||
// ErrInvalidPdfFormat happens if the PDF format option cannot be handled
|
||||
// by LibreOffice.
|
||||
ErrInvalidPdfFormat = errors.New("invalid PDF format")
|
||||
|
||||
// ErrMalformedPageRanges happens if the page ranges option cannot be
|
||||
// interpreted by LibreOffice.
|
||||
ErrMalformedPageRanges = errors.New("page ranges are malformed")
|
||||
)
|
||||
|
||||
// Api is a module which provides an [Uno] to interact with LibreOffice.
|
||||
type Api struct {
|
||||
autoStart bool
|
||||
args libreOfficeArguments
|
||||
|
||||
logger *zap.Logger
|
||||
libreOffice libreOffice
|
||||
supervisor gotenberg.ProcessSupervisor
|
||||
}
|
||||
|
||||
// Options gathers available options when converting a document to PDF.
|
||||
type Options struct {
|
||||
// Landscape allows to change the orientation of the resulting PDF.
|
||||
// Optional.
|
||||
Landscape bool
|
||||
|
||||
// PageRanges allows to select the pages to convert.
|
||||
// TODO: should prefer a method form PDFEngine.
|
||||
// Optional.
|
||||
PageRanges string
|
||||
|
||||
// PdfFormat allows to convert the resulting PDF to PDF/A-1a, PDF/A-2b, or
|
||||
// PDF/A-3b.
|
||||
// Optional.
|
||||
PdfFormat string
|
||||
}
|
||||
|
||||
// Uno is an abstraction on top of Api.
|
||||
type Uno interface {
|
||||
Pdf(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error
|
||||
Extensions() []string
|
||||
}
|
||||
|
||||
// Provider is a module interface which exposes a method for creating a
|
||||
// [Uno] for other modules.
|
||||
//
|
||||
// func (m *YourModule) Provision(ctx *gotenberg.Context) error {
|
||||
// provider, _ := ctx.Module(new(libreofficeapi.Provider))
|
||||
// libreOffice, _ := provider.(api.Provider).LibreOffice()
|
||||
// }
|
||||
type Provider interface {
|
||||
LibreOffice() (Uno, error)
|
||||
}
|
||||
|
||||
// Descriptor returns a [Api]'s module descriptor.
|
||||
func (a *Api) Descriptor() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{
|
||||
ID: "libreoffice-api",
|
||||
FlagSet: func() *flag.FlagSet {
|
||||
fs := flag.NewFlagSet("api", flag.ExitOnError)
|
||||
|
||||
// Deprecated flags.
|
||||
fs.Duration("uno-listener-start-timeout", time.Duration(10)*time.Second, "Time limit for restarting the LibreOffice")
|
||||
fs.Int("uno-listener-restart-threshold", 10, "Conversions limit after which the LibreOffice listener is restarted - 0 means no restart")
|
||||
fs.Bool("unoconv-disable-listener", false, "Do not start a long-running listener - save resources in detriment of unitary performance")
|
||||
|
||||
var err error
|
||||
err = multierr.Append(err, fs.MarkDeprecated("uno-listener-start-timeout", "use the libreOffice-start-timeout property instead"))
|
||||
err = multierr.Append(err, fs.MarkDeprecated("uno-listener-restart-threshold", "use the libreOffice-restart-after property instead"))
|
||||
err = multierr.Append(err, fs.MarkDeprecated("unoconv-disable-listener", "use the libreOffice-auto-start property instead"))
|
||||
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("create deprecated flags for the LibreOffice module: %v", err))
|
||||
}
|
||||
|
||||
fs.Int64("libreoffice-restart-after", 10, "Number of conversions after which LibreOffice will automatically restart. Set to 0 to disable this feature")
|
||||
fs.Bool("libreoffice-auto-start", false, "Automatically launch LibreOffice upon initialization if set to true; otherwise, LibreOffice will start at the time of the first conversion")
|
||||
fs.Duration("libreoffice-start-timeout", time.Duration(10)*time.Second, "Maximum duration to wait for LibreOffice to start or restart")
|
||||
|
||||
return fs
|
||||
}(),
|
||||
New: func() gotenberg.Module { return new(Api) },
|
||||
}
|
||||
}
|
||||
|
||||
// Provision sets the module properties.
|
||||
func (a *Api) Provision(ctx *gotenberg.Context) error {
|
||||
flags := ctx.ParsedFlags()
|
||||
a.autoStart = flags.MustBool("libreoffice-auto-start")
|
||||
|
||||
libreOfficeBinPath, ok := os.LookupEnv("LIBREOFFICE_BIN_PATH")
|
||||
if !ok {
|
||||
return errors.New("LIBREOFFICE_BIN_PATH environment variable is not set")
|
||||
}
|
||||
|
||||
unoBinPath, ok := os.LookupEnv("UNOCONV_BIN_PATH")
|
||||
if !ok {
|
||||
return errors.New("UNOCONV_BIN_PATH environment variable is not set")
|
||||
}
|
||||
|
||||
a.args = libreOfficeArguments{
|
||||
binPath: libreOfficeBinPath,
|
||||
unoBinPath: unoBinPath,
|
||||
startTimeout: flags.MustDeprecatedDuration("uno-listener-start-timeout", "libreoffice-start-timeout"),
|
||||
}
|
||||
|
||||
// Logger.
|
||||
loggerProvider, err := ctx.Module(new(gotenberg.LoggerProvider))
|
||||
if err != nil {
|
||||
return fmt.Errorf("get logger provider: %w", err)
|
||||
}
|
||||
logger, err := loggerProvider.(gotenberg.LoggerProvider).Logger(a)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get logger: %w", err)
|
||||
}
|
||||
a.logger = logger.Named("libreoffice")
|
||||
|
||||
// Process.
|
||||
a.libreOffice = newLibreOfficeProcess(a.args)
|
||||
a.supervisor = gotenberg.NewProcessSupervisor(a.logger, a.libreOffice, flags.MustDeprecatedInt64("uno-listener-restart-threshold", "libreoffice-restart-after"))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate validates the module properties.
|
||||
func (a *Api) Validate() error {
|
||||
var err error
|
||||
|
||||
_, statErr := os.Stat(a.args.binPath)
|
||||
if os.IsNotExist(statErr) {
|
||||
err = multierr.Append(err, fmt.Errorf("LibreOffice binary path does not exist: %w", statErr))
|
||||
}
|
||||
|
||||
_, statErr = os.Stat(a.args.unoBinPath)
|
||||
if os.IsNotExist(statErr) {
|
||||
err = multierr.Append(err, fmt.Errorf("uno binary path does not exist: %w", statErr))
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// Start does nothing if auto-start is not enabled. Otherwise, it starts a
|
||||
// LibreOffice instance.
|
||||
func (a *Api) Start() error {
|
||||
if !a.autoStart {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := a.supervisor.Launch()
|
||||
if err != nil {
|
||||
return fmt.Errorf("launch supervisor: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// StartupMessage returns a custom startup message.
|
||||
func (a *Api) StartupMessage() string {
|
||||
if !a.autoStart {
|
||||
return "LibreOffice ready to start"
|
||||
}
|
||||
|
||||
return "LibreOffice automatically started"
|
||||
}
|
||||
|
||||
// Stop stops the current browser instance.
|
||||
func (a *Api) Stop(ctx context.Context) error {
|
||||
// Block until the context is done so that other module may gracefully stop
|
||||
// before we do a shutdown.
|
||||
a.logger.Debug("wait for the end of grace duration")
|
||||
|
||||
<-ctx.Done()
|
||||
|
||||
err := a.supervisor.Shutdown()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("stop LibreOffice: %w", err)
|
||||
}
|
||||
|
||||
// Metrics returns the metrics.
|
||||
func (a *Api) Metrics() ([]gotenberg.Metric, error) {
|
||||
return []gotenberg.Metric{
|
||||
// TODO: remove deprecated.
|
||||
{
|
||||
Name: "unoconv_active_instances_count",
|
||||
Description: "Current number of active unoconv instances - deprecated.",
|
||||
Read: func() float64 {
|
||||
return 1
|
||||
},
|
||||
},
|
||||
// TODO: remove deprecated.
|
||||
{
|
||||
Name: "libreoffice_listener_active_instances_count",
|
||||
Description: "Current number of active LibreOffice listener instances - deprecated.",
|
||||
Read: func() float64 {
|
||||
return 1
|
||||
},
|
||||
},
|
||||
// TODO: remove deprecated.
|
||||
{
|
||||
Name: "unoconv_listener_active_instances_count",
|
||||
Description: "Current number of active unoconv listener instances- deprecated.",
|
||||
Read: func() float64 {
|
||||
return 1
|
||||
},
|
||||
},
|
||||
// TODO: remove deprecated.
|
||||
{
|
||||
Name: "libreoffice_listener_queue_length",
|
||||
Description: "Current number of processes in the LibreOffice listener queue - deprecated, prefer libreoffice_requests_queue_size.",
|
||||
Read: func() float64 {
|
||||
return float64(a.supervisor.ReqQueueSize())
|
||||
},
|
||||
},
|
||||
// TODO: remove deprecated.
|
||||
{
|
||||
Name: "unoconv_listener_queue_length",
|
||||
Description: "Current number of processes in the queue - deprecated, prefer libreoffice_requests_queue_size.",
|
||||
Read: func() float64 {
|
||||
return float64(a.supervisor.ReqQueueSize())
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "libreoffice_requests_queue_size",
|
||||
Description: "Current number of LibreOffice conversion requests waiting to be treated.",
|
||||
Read: func() float64 {
|
||||
return float64(a.supervisor.ReqQueueSize())
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "libreoffice_restarts_count",
|
||||
Description: "Current number of LibreOffice restarts.",
|
||||
Read: func() float64 {
|
||||
return float64(a.supervisor.RestartsCount())
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Checks adds a health check that verifies if LibreOffice is healthy.
|
||||
func (a *Api) Checks() ([]health.CheckerOption, error) {
|
||||
return []health.CheckerOption{
|
||||
health.WithCheck(health.Check{
|
||||
Name: "api",
|
||||
Check: func(_ context.Context) error {
|
||||
if a.supervisor.Healthy() {
|
||||
return nil
|
||||
}
|
||||
|
||||
return errors.New("LibreOffice unhealthy")
|
||||
},
|
||||
}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// LibreOffice returns a [Uno] for interacting with LibreOffice.
|
||||
func (a *Api) LibreOffice() (Uno, error) {
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// Pdf converts a document to PDF.
|
||||
func (a *Api) Pdf(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error {
|
||||
return a.supervisor.Run(ctx, logger, func() error {
|
||||
return a.libreOffice.pdf(ctx, logger, inputPath, outputPath, options)
|
||||
})
|
||||
}
|
||||
|
||||
// Extensions returns the file extensions available for conversions.
|
||||
// FIXME: don't care, take all on the route level?
|
||||
func (a *Api) Extensions() []string {
|
||||
return []string{
|
||||
".bib",
|
||||
".doc",
|
||||
".xml",
|
||||
".docx",
|
||||
".fodt",
|
||||
".html",
|
||||
".ltx",
|
||||
".txt",
|
||||
".odt",
|
||||
".ott",
|
||||
".pdb",
|
||||
".pdf",
|
||||
".psw",
|
||||
".rtf",
|
||||
".sdw",
|
||||
".stw",
|
||||
".sxw",
|
||||
".uot",
|
||||
".vor",
|
||||
".wps",
|
||||
".epub",
|
||||
".png",
|
||||
".bmp",
|
||||
".emf",
|
||||
".eps",
|
||||
".fodg",
|
||||
".gif",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".met",
|
||||
".odd",
|
||||
".otg",
|
||||
".pbm",
|
||||
".pct",
|
||||
".pgm",
|
||||
".ppm",
|
||||
".ras",
|
||||
".std",
|
||||
".svg",
|
||||
".svm",
|
||||
".swf",
|
||||
".sxd",
|
||||
".sxw",
|
||||
".tif",
|
||||
".tiff",
|
||||
".xhtml",
|
||||
".xpm",
|
||||
".odp",
|
||||
".fodp",
|
||||
".potm",
|
||||
".pot",
|
||||
".pptx",
|
||||
".pps",
|
||||
".ppt",
|
||||
".pwp",
|
||||
".sda",
|
||||
".sdd",
|
||||
".sti",
|
||||
".sxi",
|
||||
".uop",
|
||||
".wmf",
|
||||
".csv",
|
||||
".dbf",
|
||||
".dif",
|
||||
".fods",
|
||||
".ods",
|
||||
".ots",
|
||||
".pxl",
|
||||
".sdc",
|
||||
".slk",
|
||||
".stc",
|
||||
".sxc",
|
||||
".uos",
|
||||
".xls",
|
||||
".xlt",
|
||||
".xlsx",
|
||||
".odg",
|
||||
".dotx",
|
||||
".xltx",
|
||||
}
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ gotenberg.Module = (*Api)(nil)
|
||||
_ gotenberg.Provisioner = (*Api)(nil)
|
||||
_ gotenberg.Validator = (*Api)(nil)
|
||||
_ gotenberg.App = (*Api)(nil)
|
||||
_ gotenberg.MetricsProvider = (*Api)(nil)
|
||||
_ api.HealthChecker = (*Api)(nil)
|
||||
_ Uno = (*Api)(nil)
|
||||
_ Provider = (*Api)(nil)
|
||||
)
|
||||
428
pkg/modules/libreoffice/api/api_test.go
Normal file
428
pkg/modules/libreoffice/api/api_test.go
Normal file
@@ -0,0 +1,428 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alexliesenfeld/health"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
||||
)
|
||||
|
||||
func TestApi_Descriptor(t *testing.T) {
|
||||
descriptor := new(Api).Descriptor()
|
||||
|
||||
actual := reflect.TypeOf(descriptor.New())
|
||||
expect := reflect.TypeOf(new(Api))
|
||||
|
||||
if actual != expect {
|
||||
t.Errorf("expected '%s' but got '%s'", expect, actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApi_Provision(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
ctx *gotenberg.Context
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
scenario: "no logger provider",
|
||||
ctx: func() *gotenberg.Context {
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: new(Api).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{},
|
||||
)
|
||||
}(),
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "no logger from logger provider",
|
||||
ctx: func() *gotenberg.Context {
|
||||
mod := &struct {
|
||||
gotenberg.ModuleMock
|
||||
gotenberg.LoggerProviderMock
|
||||
}{}
|
||||
mod.DescriptorMock = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }}
|
||||
}
|
||||
mod.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) {
|
||||
return nil, errors.New("foo")
|
||||
}
|
||||
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: new(Api).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{
|
||||
mod.Descriptor(),
|
||||
},
|
||||
)
|
||||
}(),
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "provision success",
|
||||
ctx: func() *gotenberg.Context {
|
||||
mod := &struct {
|
||||
gotenberg.ModuleMock
|
||||
gotenberg.LoggerProviderMock
|
||||
}{}
|
||||
mod.DescriptorMock = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }}
|
||||
}
|
||||
mod.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) {
|
||||
return zap.NewNop(), nil
|
||||
}
|
||||
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: new(Api).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{
|
||||
mod.Descriptor(),
|
||||
},
|
||||
)
|
||||
}(),
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
a := new(Api)
|
||||
err := a.Provision(tc.ctx)
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApi_Validate(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
binPath string
|
||||
unoBinPath string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
scenario: "empty LibreOffice bin path",
|
||||
binPath: "",
|
||||
unoBinPath: os.Getenv("UNOCONV_BIN_PATH"),
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "LibreOffice bin path does not exist",
|
||||
binPath: "/foo",
|
||||
unoBinPath: os.Getenv("UNOCONV_BIN_PATH"),
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "empty uno bin path",
|
||||
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
|
||||
unoBinPath: "",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "uno bin path does not exist",
|
||||
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
|
||||
unoBinPath: "/foo",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "validate success",
|
||||
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
|
||||
unoBinPath: os.Getenv("UNOCONV_BIN_PATH"),
|
||||
expectError: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
a := new(Api)
|
||||
a.args = libreOfficeArguments{
|
||||
binPath: tc.binPath,
|
||||
unoBinPath: tc.unoBinPath,
|
||||
}
|
||||
err := a.Validate()
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApi_Start(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
autoStart bool
|
||||
supervisor *gotenberg.ProcessSupervisorMock
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
scenario: "no auto-start",
|
||||
autoStart: false,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "auto-start success",
|
||||
autoStart: true,
|
||||
supervisor: &gotenberg.ProcessSupervisorMock{LaunchMock: func() error {
|
||||
return nil
|
||||
}},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "auto-start failed",
|
||||
autoStart: true,
|
||||
supervisor: &gotenberg.ProcessSupervisorMock{LaunchMock: func() error {
|
||||
return errors.New("foo")
|
||||
}},
|
||||
expectError: true,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
a := new(Api)
|
||||
a.autoStart = tc.autoStart
|
||||
a.supervisor = tc.supervisor
|
||||
|
||||
err := a.Start()
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApi_StartupMessage(t *testing.T) {
|
||||
a := new(Api)
|
||||
|
||||
a.autoStart = true
|
||||
autoStartMsg := a.StartupMessage()
|
||||
|
||||
a.autoStart = false
|
||||
noAutoStartMsg := a.StartupMessage()
|
||||
|
||||
if autoStartMsg == noAutoStartMsg {
|
||||
t.Errorf("expected differrent startup messages based on auto start, but got '%s'", autoStartMsg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApi_Stop(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
supervisor *gotenberg.ProcessSupervisorMock
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
scenario: "stop success",
|
||||
supervisor: &gotenberg.ProcessSupervisorMock{ShutdownMock: func() error {
|
||||
return nil
|
||||
}},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "stop failed",
|
||||
supervisor: &gotenberg.ProcessSupervisorMock{ShutdownMock: func() error {
|
||||
return errors.New("foo")
|
||||
}},
|
||||
expectError: true,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
a := new(Api)
|
||||
a.logger = zap.NewNop()
|
||||
a.supervisor = tc.supervisor
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 0*time.Second)
|
||||
cancel()
|
||||
|
||||
err := a.Stop(ctx)
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApi_Metrics(t *testing.T) {
|
||||
a := new(Api)
|
||||
a.supervisor = &gotenberg.ProcessSupervisorMock{
|
||||
ReqQueueSizeMock: func() int64 {
|
||||
return 10
|
||||
},
|
||||
RestartsCountMock: func() int64 {
|
||||
return 0
|
||||
},
|
||||
}
|
||||
|
||||
metrics, err := a.Metrics()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if len(metrics) != 7 {
|
||||
t.Fatalf("expected %d metrics, but got %d", 7, len(metrics))
|
||||
}
|
||||
|
||||
actual := metrics[0].Read()
|
||||
if actual != float64(1) {
|
||||
t.Errorf("expected %f for unoconv_active_instances_count, but got %f", float64(1), actual)
|
||||
}
|
||||
|
||||
actual = metrics[1].Read()
|
||||
if actual != float64(1) {
|
||||
t.Errorf("expected %f for libreoffice_listener_active_instances_count, but got %f", float64(1), actual)
|
||||
}
|
||||
|
||||
actual = metrics[2].Read()
|
||||
if actual != float64(1) {
|
||||
t.Errorf("expected %f for unoconv_listener_active_instances_count, but got %f", float64(1), actual)
|
||||
}
|
||||
|
||||
actual = metrics[3].Read()
|
||||
if actual != float64(10) {
|
||||
t.Errorf("expected %f for libreoffice_listener_queue_length, but got %f", float64(10), actual)
|
||||
}
|
||||
|
||||
actual = metrics[4].Read()
|
||||
if actual != float64(10) {
|
||||
t.Errorf("expected %f for unoconv_listener_queue_length, but got %f", float64(10), actual)
|
||||
}
|
||||
|
||||
actual = metrics[5].Read()
|
||||
if actual != float64(10) {
|
||||
t.Errorf("expected %f for libreoffice_requests_queue_size, but got %f", float64(10), actual)
|
||||
}
|
||||
|
||||
actual = metrics[6].Read()
|
||||
if actual != float64(0) {
|
||||
t.Errorf("expected %f for libreoffice_restarts_count, but got %f", float64(0), actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApi_Checks(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
supervisor gotenberg.ProcessSupervisor
|
||||
expectAvailabilityStatus health.AvailabilityStatus
|
||||
}{
|
||||
{
|
||||
scenario: "healthy module",
|
||||
supervisor: &gotenberg.ProcessSupervisorMock{HealthyMock: func() bool {
|
||||
return true
|
||||
}},
|
||||
expectAvailabilityStatus: health.StatusUp,
|
||||
},
|
||||
{
|
||||
scenario: "unhealthy module",
|
||||
supervisor: &gotenberg.ProcessSupervisorMock{HealthyMock: func() bool {
|
||||
return false
|
||||
}},
|
||||
expectAvailabilityStatus: health.StatusDown,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
a := new(Api)
|
||||
a.supervisor = tc.supervisor
|
||||
|
||||
checks, err := a.Checks()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
checker := health.NewChecker(checks...)
|
||||
result := checker.Check(context.Background())
|
||||
|
||||
if result.Status != tc.expectAvailabilityStatus {
|
||||
t.Errorf("expected '%s' as availability status, but got '%s'", tc.expectAvailabilityStatus, result.Status)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApi_LibreOffice(t *testing.T) {
|
||||
a := new(Api)
|
||||
|
||||
_, err := a.LibreOffice()
|
||||
if err != nil {
|
||||
t.Errorf("expected no error but got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApi_Pdf(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
supervisor gotenberg.ProcessSupervisor
|
||||
libreOffice libreOffice
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
scenario: "PDF task success",
|
||||
libreOffice: &libreOfficeMock{pdfMock: func(ctx context.Context, logger *zap.Logger, input, outputPath string, options Options) error {
|
||||
return nil
|
||||
}},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "PDF task error",
|
||||
libreOffice: &libreOfficeMock{pdfMock: func(ctx context.Context, logger *zap.Logger, input, outputPath string, options Options) error {
|
||||
return errors.New("PDF task error")
|
||||
}},
|
||||
expectError: true,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
a := new(Api)
|
||||
a.supervisor = &gotenberg.ProcessSupervisorMock{RunMock: func(ctx context.Context, logger *zap.Logger, task func() error) error {
|
||||
return task()
|
||||
}}
|
||||
a.libreOffice = tc.libreOffice
|
||||
|
||||
err := a.Pdf(context.Background(), zap.NewNop(), "", "", Options{})
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApi_Extensions(t *testing.T) {
|
||||
a := new(Api)
|
||||
extensions := a.Extensions()
|
||||
|
||||
actual := len(extensions)
|
||||
expect := 79
|
||||
|
||||
if actual != expect {
|
||||
t.Errorf("expected %d extensions, but got %d", expect, actual)
|
||||
}
|
||||
}
|
||||
3
pkg/modules/libreoffice/api/doc.go
Normal file
3
pkg/modules/libreoffice/api/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package api provides a module which manages a LibreOffice instance and
|
||||
// interacts with it via the UNO (Universal Network Objects) API.
|
||||
package api
|
||||
31
pkg/modules/libreoffice/api/freeport.go
Normal file
31
pkg/modules/libreoffice/api/freeport.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func freePort(logger *zap.Logger) (int, error) {
|
||||
netListener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("listen on the local network address: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
err := netListener.Close()
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("close network listener: %s", err.Error()))
|
||||
}
|
||||
}()
|
||||
|
||||
addr := netListener.Addr().String()
|
||||
|
||||
_, portStr, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("get free port from host: %w", err)
|
||||
}
|
||||
|
||||
return strconv.Atoi(portStr)
|
||||
}
|
||||
320
pkg/modules/libreoffice/api/libreoffice.go
Normal file
320
pkg/modules/libreoffice/api/libreoffice.go
Normal file
@@ -0,0 +1,320 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
||||
)
|
||||
|
||||
type libreOffice interface {
|
||||
gotenberg.Process
|
||||
pdf(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error
|
||||
}
|
||||
|
||||
type libreOfficeArguments struct {
|
||||
binPath string
|
||||
unoBinPath string
|
||||
startTimeout time.Duration
|
||||
}
|
||||
|
||||
type libreOfficeProcess struct {
|
||||
socketPort int
|
||||
userProfileDirPath string
|
||||
cmd gotenberg.Cmd
|
||||
cfgMu sync.RWMutex
|
||||
isStarted atomic.Bool
|
||||
|
||||
arguments libreOfficeArguments
|
||||
fs *gotenberg.FileSystem
|
||||
}
|
||||
|
||||
func newLibreOfficeProcess(arguments libreOfficeArguments) libreOffice {
|
||||
p := &libreOfficeProcess{
|
||||
arguments: arguments,
|
||||
fs: gotenberg.NewFileSystem(),
|
||||
}
|
||||
p.isStarted.Store(false)
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *libreOfficeProcess) Start(logger *zap.Logger) error {
|
||||
if p.isStarted.Load() {
|
||||
return errors.New("LibreOffice is already started")
|
||||
}
|
||||
|
||||
port, err := freePort(logger)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get free port: %w", err)
|
||||
}
|
||||
|
||||
userProfileDirPath := p.fs.NewDirPath()
|
||||
args := []string{
|
||||
"--headless",
|
||||
"--invisible",
|
||||
"--nocrashreport",
|
||||
"--nodefault",
|
||||
"--nologo",
|
||||
"--nofirststartwizard",
|
||||
"--norestore",
|
||||
fmt.Sprintf("-env:UserInstallation=file://%s", userProfileDirPath),
|
||||
fmt.Sprintf("--accept=socket,host=127.0.0.1,port=%d,tcpNoDelay=1;urp;StarOffice.ComponentContext", port),
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), p.arguments.startTimeout)
|
||||
defer cancel()
|
||||
|
||||
cmd, err := gotenberg.CommandContext(ctx, logger, p.arguments.binPath, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create LibreOffice command: %w", err)
|
||||
}
|
||||
|
||||
// For whatever reason, LibreOffice requires a first start before being
|
||||
// able to run as a daemon.
|
||||
exitCode, err := cmd.Exec()
|
||||
if err != nil && exitCode != 81 {
|
||||
return fmt.Errorf("execute LibreOffice: %w", err)
|
||||
}
|
||||
|
||||
logger.Debug("got exit code 81, e.g., LibreOffice first start")
|
||||
|
||||
// Second start (daemon).
|
||||
cmd = gotenberg.Command(logger, p.arguments.binPath, args...)
|
||||
|
||||
err = cmd.Start()
|
||||
if err != nil {
|
||||
return fmt.Errorf("start LibreOffice: %w", err)
|
||||
}
|
||||
|
||||
waitChan := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
// By waiting the process, we avoid the creation of a zombie process
|
||||
// and make sure we catch an early exit if any.
|
||||
waitChan <- cmd.Wait()
|
||||
}()
|
||||
|
||||
connChan := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
// As the LibreOffice socket may take some time to be available, we
|
||||
// have to ensure that it is indeed accepting connections.
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
connChan <- ctx.Err()
|
||||
break
|
||||
}
|
||||
|
||||
conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), time.Duration(1)*time.Second)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
connChan <- nil
|
||||
err = conn.Close()
|
||||
if err != nil {
|
||||
logger.Debug(fmt.Sprintf("close connection after health checking the LibreOffice: %v", err))
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}()
|
||||
|
||||
var success bool
|
||||
|
||||
defer func() {
|
||||
if success {
|
||||
p.cfgMu.Lock()
|
||||
defer p.cfgMu.Unlock()
|
||||
|
||||
p.socketPort = port
|
||||
p.userProfileDirPath = userProfileDirPath
|
||||
p.cmd = cmd
|
||||
p.isStarted.Store(true)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Let's make sure the process is killed.
|
||||
err = cmd.Kill()
|
||||
if err != nil {
|
||||
logger.Debug(fmt.Sprintf("kill LibreOffice process: %v", err))
|
||||
}
|
||||
|
||||
// And the user profile directory is deleted.
|
||||
err = os.RemoveAll(userProfileDirPath)
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("remove LibreOffice's user profile directory: %v", err))
|
||||
}
|
||||
|
||||
logger.Debug(fmt.Sprintf("'%s' LibreOffice's user profile directory removed", userProfileDirPath))
|
||||
}()
|
||||
|
||||
logger.Debug("waiting for the LibreOffice socket to be available...")
|
||||
|
||||
for {
|
||||
select {
|
||||
case err = <-connChan:
|
||||
if err != nil {
|
||||
return fmt.Errorf("LibreOffice socket not available: %w", err)
|
||||
}
|
||||
|
||||
logger.Debug("LibreOffice socket available")
|
||||
success = true
|
||||
|
||||
return nil
|
||||
case err = <-waitChan:
|
||||
return fmt.Errorf("LibreOffice process exited: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *libreOfficeProcess) Stop(logger *zap.Logger) error {
|
||||
if !p.isStarted.Load() {
|
||||
// No big deal? Like calling cancel twice.
|
||||
return nil
|
||||
}
|
||||
|
||||
// Always remove the user profile directory created by LibreOffice.
|
||||
copyUserProfileDirPath := p.userProfileDirPath
|
||||
defer func(userProfileDirPath string) {
|
||||
go func() {
|
||||
err := os.RemoveAll(userProfileDirPath)
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("remove LibreOffice's user profile directory: %v", err))
|
||||
}
|
||||
|
||||
logger.Debug(fmt.Sprintf("'%s' LibreOffice's user profile directory removed", userProfileDirPath))
|
||||
|
||||
// Also remove LibreOffice specific files in the temporary directory.
|
||||
err = gotenberg.GarbageCollect(logger, os.TempDir(), []string{"OSL_PIPE", ".tmp"})
|
||||
if err != nil {
|
||||
logger.Error(err.Error())
|
||||
}
|
||||
}()
|
||||
}(copyUserProfileDirPath)
|
||||
|
||||
p.cfgMu.Lock()
|
||||
defer p.cfgMu.Unlock()
|
||||
|
||||
err := p.cmd.Kill()
|
||||
if err != nil {
|
||||
return fmt.Errorf("kill LibreOffice process: %w", err)
|
||||
}
|
||||
|
||||
p.socketPort = 0
|
||||
p.userProfileDirPath = ""
|
||||
p.cmd = gotenberg.Cmd{} // FIXME: pointer.
|
||||
p.isStarted.Store(false)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *libreOfficeProcess) Healthy(logger *zap.Logger) bool {
|
||||
// Good to know: the supervisor does not call this method if no first start
|
||||
// or if the process is restarting.
|
||||
|
||||
if !p.isStarted.Load() {
|
||||
// Non-started browser but not restarting?
|
||||
return false
|
||||
}
|
||||
|
||||
p.cfgMu.RLock()
|
||||
defer p.cfgMu.RUnlock()
|
||||
|
||||
conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", p.socketPort), time.Duration(10)*time.Second)
|
||||
if err == nil {
|
||||
err = conn.Close()
|
||||
if err != nil {
|
||||
logger.Debug(fmt.Sprintf("close connection after health checking LibreOffice: %v", err))
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (p *libreOfficeProcess) pdf(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error {
|
||||
if !p.isStarted.Load() {
|
||||
return errors.New("LibreOffice not started, cannot handle PDF conversion")
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"--no-launch",
|
||||
"--format",
|
||||
"pdf",
|
||||
}
|
||||
|
||||
args = append(args, "--port", fmt.Sprintf("%d", p.socketPort))
|
||||
|
||||
checkedEntry := logger.Check(zap.DebugLevel, "check for debug level before setting high verbosity")
|
||||
if checkedEntry != nil {
|
||||
args = append(args, "-vvv")
|
||||
}
|
||||
|
||||
if options.Landscape {
|
||||
args = append(args, "--printer", "PaperOrientation=landscape")
|
||||
}
|
||||
|
||||
if options.PageRanges != "" {
|
||||
args = append(args, "--export", fmt.Sprintf("PageRange=%s", options.PageRanges))
|
||||
}
|
||||
|
||||
switch options.PdfFormat {
|
||||
case "":
|
||||
case gotenberg.FormatPDFA1a:
|
||||
args = append(args, "--export", "SelectPdfVersion=1")
|
||||
case gotenberg.FormatPDFA2b:
|
||||
args = append(args, "--export", "SelectPdfVersion=2")
|
||||
case gotenberg.FormatPDFA3b:
|
||||
args = append(args, "--export", "SelectPdfVersion=3")
|
||||
default:
|
||||
return ErrInvalidPdfFormat
|
||||
}
|
||||
|
||||
args = append(args, "--output", outputPath, inputPath)
|
||||
|
||||
cmd, err := gotenberg.CommandContext(ctx, logger, p.arguments.unoBinPath, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create uno command: %w", err)
|
||||
}
|
||||
|
||||
logger.Debug(fmt.Sprintf("print to PDF with: %+v", options))
|
||||
|
||||
exitCode, err := cmd.Exec()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// LibreOffice's errors are not explicit.
|
||||
// That's why we have to make an educated guess according to the exit code
|
||||
// and given inputs.
|
||||
if exitCode == 5 && options.PageRanges != "" {
|
||||
return ErrMalformedPageRanges
|
||||
}
|
||||
|
||||
// Possible errors:
|
||||
// 1. LibreOffice failed for some reason.
|
||||
// 2. Context done.
|
||||
//
|
||||
// On the second scenario, LibreOffice might not have time to remove some
|
||||
// of its temporary files, as it has been killed without warning. The
|
||||
// garbage collector will delete them for us (if the module is loaded).
|
||||
return fmt.Errorf("convert to PDF: %w", err)
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ gotenberg.Process = (*libreOfficeProcess)(nil)
|
||||
_ libreOffice = (*libreOfficeProcess)(nil)
|
||||
)
|
||||
537
pkg/modules/libreoffice/api/libreoffice_test.go
Normal file
537
pkg/modules/libreoffice/api/libreoffice_test.go
Normal file
@@ -0,0 +1,537 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
||||
)
|
||||
|
||||
func TestLibreOfficeProcess_Start(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
libreOffice libreOffice
|
||||
expectError bool
|
||||
cleanup bool
|
||||
}{
|
||||
{
|
||||
scenario: "successful start",
|
||||
libreOffice: newLibreOfficeProcess(
|
||||
libreOfficeArguments{
|
||||
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
unoBinPath: os.Getenv("UNOCONV_BIN_PATH"),
|
||||
startTimeout: 5 * time.Second,
|
||||
},
|
||||
),
|
||||
expectError: false,
|
||||
cleanup: true,
|
||||
},
|
||||
{
|
||||
scenario: "LibreOffice already started",
|
||||
libreOffice: func() libreOffice {
|
||||
p := new(libreOfficeProcess)
|
||||
p.isStarted.Store(true)
|
||||
return p
|
||||
}(),
|
||||
expectError: true,
|
||||
cleanup: false,
|
||||
},
|
||||
{
|
||||
scenario: "non-exit code 81 on first start",
|
||||
libreOffice: newLibreOfficeProcess(
|
||||
libreOfficeArguments{
|
||||
binPath: "foo",
|
||||
unoBinPath: os.Getenv("UNOCONV_BIN_PATH"),
|
||||
startTimeout: 5 * time.Second,
|
||||
},
|
||||
),
|
||||
expectError: true,
|
||||
cleanup: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
logger := zap.NewNop()
|
||||
err := tc.libreOffice.Start(logger)
|
||||
|
||||
if tc.cleanup {
|
||||
defer func(p libreOffice, logger *zap.Logger) {
|
||||
err = p.Stop(logger)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error while cleaning up, but got: %v", err)
|
||||
}
|
||||
}(tc.libreOffice, logger)
|
||||
}
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibreOfficeProcess_Stop(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
libreOffice libreOffice
|
||||
setup func(libreOffice libreOffice, logger *zap.Logger) error
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
scenario: "successful stop",
|
||||
libreOffice: newLibreOfficeProcess(
|
||||
libreOfficeArguments{
|
||||
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
unoBinPath: os.Getenv("UNOCONV_BIN_PATH"),
|
||||
startTimeout: 5 * time.Second,
|
||||
},
|
||||
),
|
||||
setup: func(p libreOffice, logger *zap.Logger) error {
|
||||
return p.Start(logger)
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "LibreOffice already stopped",
|
||||
libreOffice: func() libreOffice {
|
||||
p := new(libreOfficeProcess)
|
||||
p.isStarted.Store(false)
|
||||
return p
|
||||
}(),
|
||||
expectError: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
logger := zap.NewNop()
|
||||
|
||||
if tc.setup != nil {
|
||||
err := tc.setup(tc.libreOffice, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("setup error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
err := tc.libreOffice.Stop(logger)
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibreOfficeProcess_Healthy(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
libreOffice libreOffice
|
||||
setup func(libreOffice libreOffice, logger *zap.Logger) error
|
||||
expectHealthy bool
|
||||
cleanup bool
|
||||
}{
|
||||
{
|
||||
scenario: "healthy LibreOffice",
|
||||
libreOffice: newLibreOfficeProcess(
|
||||
libreOfficeArguments{
|
||||
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
unoBinPath: os.Getenv("UNOCONV_BIN_PATH"),
|
||||
startTimeout: 5 * time.Second,
|
||||
},
|
||||
),
|
||||
setup: func(p libreOffice, logger *zap.Logger) error {
|
||||
return p.Start(logger)
|
||||
},
|
||||
expectHealthy: true,
|
||||
cleanup: true,
|
||||
},
|
||||
{
|
||||
scenario: "LibreOffice not started",
|
||||
libreOffice: func() libreOffice {
|
||||
p := new(libreOfficeProcess)
|
||||
p.isStarted.Store(false)
|
||||
return p
|
||||
}(),
|
||||
expectHealthy: false,
|
||||
cleanup: false,
|
||||
},
|
||||
{
|
||||
scenario: "unhealthy LibreOffice",
|
||||
libreOffice: func() libreOffice {
|
||||
p := new(libreOfficeProcess)
|
||||
p.isStarted.Store(true)
|
||||
p.socketPort = 12345
|
||||
return p
|
||||
}(),
|
||||
expectHealthy: false,
|
||||
cleanup: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
logger := zap.NewNop()
|
||||
|
||||
if tc.setup != nil {
|
||||
err := tc.setup(tc.libreOffice, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("setup error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if tc.cleanup {
|
||||
defer func(p libreOffice, logger *zap.Logger) {
|
||||
err := p.Stop(logger)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error while cleaning up, but got: %v", err)
|
||||
}
|
||||
}(tc.libreOffice, logger)
|
||||
}
|
||||
|
||||
healthy := tc.libreOffice.Healthy(logger)
|
||||
|
||||
if !tc.expectHealthy && healthy {
|
||||
t.Fatal("expected unhealthy LibreOffice but got an healthy one")
|
||||
}
|
||||
|
||||
if tc.expectHealthy && !healthy {
|
||||
t.Fatal("expected a healthy LibreOffice but got an unhealthy one")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibreOfficeProcess_pdf(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
libreOffice libreOffice
|
||||
fs *gotenberg.FileSystem
|
||||
options Options
|
||||
cancelledCtx bool
|
||||
start bool
|
||||
expectError bool
|
||||
expectedError error
|
||||
}{
|
||||
{
|
||||
scenario: "LibreOffice not started",
|
||||
libreOffice: func() libreOffice {
|
||||
p := new(libreOfficeProcess)
|
||||
p.isStarted.Store(false)
|
||||
return p
|
||||
}(),
|
||||
fs: gotenberg.NewFileSystem(),
|
||||
cancelledCtx: false,
|
||||
start: false,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "ErrInvalidPdfFormat",
|
||||
libreOffice: func() libreOffice {
|
||||
p := new(libreOfficeProcess)
|
||||
p.socketPort = 12345
|
||||
p.isStarted.Store(true)
|
||||
return p
|
||||
}(),
|
||||
fs: gotenberg.NewFileSystem(),
|
||||
options: Options{PdfFormat: "foo"},
|
||||
cancelledCtx: false,
|
||||
start: false,
|
||||
expectError: true,
|
||||
expectedError: ErrInvalidPdfFormat,
|
||||
},
|
||||
{
|
||||
scenario: "ErrMalformedPageRanges",
|
||||
libreOffice: newLibreOfficeProcess(
|
||||
libreOfficeArguments{
|
||||
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
unoBinPath: os.Getenv("UNOCONV_BIN_PATH"),
|
||||
startTimeout: 5 * time.Second,
|
||||
},
|
||||
),
|
||||
options: Options{PageRanges: "foo"},
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
fs := gotenberg.NewFileSystem()
|
||||
|
||||
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
|
||||
}
|
||||
|
||||
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("ErrMalformedPageRanges"), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
return fs
|
||||
}(),
|
||||
cancelledCtx: false,
|
||||
start: true,
|
||||
expectError: true,
|
||||
expectedError: ErrMalformedPageRanges,
|
||||
},
|
||||
{
|
||||
scenario: "context done",
|
||||
libreOffice: newLibreOfficeProcess(
|
||||
libreOfficeArguments{
|
||||
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
unoBinPath: os.Getenv("UNOCONV_BIN_PATH"),
|
||||
startTimeout: 5 * time.Second,
|
||||
},
|
||||
),
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
fs := gotenberg.NewFileSystem()
|
||||
|
||||
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
|
||||
}
|
||||
|
||||
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Context done"), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
return fs
|
||||
}(),
|
||||
cancelledCtx: true,
|
||||
start: true,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "success (default options)",
|
||||
libreOffice: newLibreOfficeProcess(
|
||||
libreOfficeArguments{
|
||||
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
unoBinPath: os.Getenv("UNOCONV_BIN_PATH"),
|
||||
startTimeout: 5 * time.Second,
|
||||
},
|
||||
),
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
fs := gotenberg.NewFileSystem()
|
||||
|
||||
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
|
||||
}
|
||||
|
||||
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Success"), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
return fs
|
||||
}(),
|
||||
cancelledCtx: false,
|
||||
start: true,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "success (landscape)",
|
||||
libreOffice: newLibreOfficeProcess(
|
||||
libreOfficeArguments{
|
||||
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
unoBinPath: os.Getenv("UNOCONV_BIN_PATH"),
|
||||
startTimeout: 5 * time.Second,
|
||||
},
|
||||
),
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
fs := gotenberg.NewFileSystem()
|
||||
|
||||
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
|
||||
}
|
||||
|
||||
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Landscape"), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
return fs
|
||||
}(),
|
||||
options: Options{Landscape: true},
|
||||
cancelledCtx: false,
|
||||
start: true,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "success (page ranges)",
|
||||
libreOffice: newLibreOfficeProcess(
|
||||
libreOfficeArguments{
|
||||
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
unoBinPath: os.Getenv("UNOCONV_BIN_PATH"),
|
||||
startTimeout: 5 * time.Second,
|
||||
},
|
||||
),
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
fs := gotenberg.NewFileSystem()
|
||||
|
||||
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
|
||||
}
|
||||
|
||||
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Landscape"), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
return fs
|
||||
}(),
|
||||
options: Options{PageRanges: "1-1"},
|
||||
cancelledCtx: false,
|
||||
start: true,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "success (PDF/A-1a)",
|
||||
libreOffice: newLibreOfficeProcess(
|
||||
libreOfficeArguments{
|
||||
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
unoBinPath: os.Getenv("UNOCONV_BIN_PATH"),
|
||||
startTimeout: 5 * time.Second,
|
||||
},
|
||||
),
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
fs := gotenberg.NewFileSystem()
|
||||
|
||||
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
|
||||
}
|
||||
|
||||
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Landscape"), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
return fs
|
||||
}(),
|
||||
options: Options{PdfFormat: gotenberg.FormatPDFA1a},
|
||||
cancelledCtx: false,
|
||||
start: true,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "success (PDF/A-2b)",
|
||||
libreOffice: newLibreOfficeProcess(
|
||||
libreOfficeArguments{
|
||||
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
unoBinPath: os.Getenv("UNOCONV_BIN_PATH"),
|
||||
startTimeout: 5 * time.Second,
|
||||
},
|
||||
),
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
fs := gotenberg.NewFileSystem()
|
||||
|
||||
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
|
||||
}
|
||||
|
||||
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Landscape"), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
return fs
|
||||
}(),
|
||||
options: Options{PdfFormat: gotenberg.FormatPDFA2b},
|
||||
cancelledCtx: false,
|
||||
start: true,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "success (PDF/A-3b)",
|
||||
libreOffice: newLibreOfficeProcess(
|
||||
libreOfficeArguments{
|
||||
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
unoBinPath: os.Getenv("UNOCONV_BIN_PATH"),
|
||||
startTimeout: 5 * time.Second,
|
||||
},
|
||||
),
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
fs := gotenberg.NewFileSystem()
|
||||
|
||||
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
|
||||
}
|
||||
|
||||
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Landscape"), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
return fs
|
||||
}(),
|
||||
options: Options{PdfFormat: gotenberg.FormatPDFA3b},
|
||||
cancelledCtx: false,
|
||||
start: true,
|
||||
expectError: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
// Force the debug level.
|
||||
logger := zap.NewExample()
|
||||
|
||||
defer func() {
|
||||
err := os.RemoveAll(tc.fs.WorkingDirPath())
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error while cleaning up, but got: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
if tc.start {
|
||||
err := tc.libreOffice.Start(logger)
|
||||
if err != nil {
|
||||
t.Fatalf("setup error: %v", err)
|
||||
}
|
||||
|
||||
defer func(p libreOffice, logger *zap.Logger) {
|
||||
err = p.Stop(logger)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error while cleaning up, but got: %v", err)
|
||||
}
|
||||
}(tc.libreOffice, logger)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(5)*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if tc.cancelledCtx {
|
||||
cancel()
|
||||
}
|
||||
|
||||
err := tc.libreOffice.pdf(
|
||||
ctx,
|
||||
logger,
|
||||
fmt.Sprintf("file://%s/document.txt", tc.fs.WorkingDirPath()),
|
||||
fmt.Sprintf("%s/%s.pdf", tc.fs.WorkingDirPath(), uuid.NewString()),
|
||||
tc.options,
|
||||
)
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
|
||||
if tc.expectedError != nil && !errors.Is(err, tc.expectedError) {
|
||||
t.Fatalf("expected error %v but got: %v", tc.expectedError, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
49
pkg/modules/libreoffice/api/mocks.go
Normal file
49
pkg/modules/libreoffice/api/mocks.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
||||
)
|
||||
|
||||
// ApiMock is a mock for the [Uno] interface.
|
||||
type ApiMock struct {
|
||||
PdfMock func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error
|
||||
ExtensionsMock func() []string
|
||||
}
|
||||
|
||||
func (api *ApiMock) Pdf(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error {
|
||||
return api.PdfMock(ctx, logger, inputPath, outputPath, options)
|
||||
}
|
||||
|
||||
func (api *ApiMock) Extensions() []string {
|
||||
return api.ExtensionsMock()
|
||||
}
|
||||
|
||||
// ProviderMock is a mock for the [Provider] interface.
|
||||
type ProviderMock struct {
|
||||
LibreOfficeMock func() (Uno, error)
|
||||
}
|
||||
|
||||
func (provider *ProviderMock) LibreOffice() (Uno, error) {
|
||||
return provider.LibreOfficeMock()
|
||||
}
|
||||
|
||||
// libreOfficeMock is a mock for the [libreOffice] interface.
|
||||
type libreOfficeMock struct {
|
||||
gotenberg.ProcessMock
|
||||
pdfMock func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error
|
||||
}
|
||||
|
||||
func (b *libreOfficeMock) pdf(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error {
|
||||
return b.pdfMock(ctx, logger, inputPath, outputPath, options)
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ Uno = (*ApiMock)(nil)
|
||||
_ Provider = (*ProviderMock)(nil)
|
||||
_ libreOffice = (*libreOfficeMock)(nil)
|
||||
)
|
||||
55
pkg/modules/libreoffice/api/mocks_test.go
Normal file
55
pkg/modules/libreoffice/api/mocks_test.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestApiMock(t *testing.T) {
|
||||
mock := &ApiMock{
|
||||
PdfMock: func(ctx context.Context, logger *zap.Logger, input, outputPath string, options Options) error {
|
||||
return nil
|
||||
},
|
||||
ExtensionsMock: func() []string {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
err := mock.Pdf(context.Background(), zap.NewNop(), "", "", Options{})
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from ApiMock.Pdf, but got: %v", err)
|
||||
}
|
||||
|
||||
ext := mock.Extensions()
|
||||
if ext != nil {
|
||||
t.Errorf("expected nil result from ApiMock.Extensions, but got: %v", ext)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderMock(t *testing.T) {
|
||||
mock := &ProviderMock{
|
||||
LibreOfficeMock: func() (Uno, error) {
|
||||
return nil, nil
|
||||
},
|
||||
}
|
||||
|
||||
_, err := mock.LibreOffice()
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from ProviderMock.LibreOffice, but got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibreOfficeMock(t *testing.T) {
|
||||
mock := &libreOfficeMock{
|
||||
pdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
err := mock.pdf(context.Background(), zap.NewNop(), "", "", Options{})
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from libreOfficeMock.pdf, but got: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user