huge refactoring

This commit is contained in:
Julien Neuhart
2019-07-23 13:57:18 +02:00
parent f6b357691c
commit 7bfbda4490
105 changed files with 4051 additions and 2667 deletions

206
internal/pkg/conf/conf.go Normal file
View File

@@ -0,0 +1,206 @@
package conf
import (
"github.com/thecodingmachine/gotenberg/internal/pkg/xassert"
"github.com/thecodingmachine/gotenberg/internal/pkg/xerror"
"github.com/thecodingmachine/gotenberg/internal/pkg/xlog"
)
const (
maximumWaitTimeoutEnvVar string = "MAXIMUM_WAIT_TIMEOUT"
maximumWaitDelayEnvVar string = "MAXIMUM_WAIT_DELAY"
maximumWebhookURLTimeoutEnvVar string = "MAXIMUM_WEBHOOK_URL_TIMEOUT"
defaultWaitTimeoutEnvVar string = "DEFAULT_WAIT_TIMEOUT"
defaultWebhookURLTimeoutEnvVar string = "DEFAULT_WEBHOOK_URL_TIMEOUT"
defaultListenPortEnvVar string = "DEFAULT_LISTEN_PORT"
disableGoogleChromeEnvVar string = "DISABLE_GOOGLE_CHROME"
disableUnoconvEnvVar string = "DISABLE_UNOCONV"
logLevelEnvVar string = "LOG_LEVEL"
)
// Config contains the application
// configuration.
type Config struct {
maximumWaitTimeout float64
maximumWaitDelay float64
maximumWebhookURLTimeout float64
defaultWaitTimeout float64
defaultWebhookURLTimeout float64
defaultListenPort int64
disableGoogleChrome bool
disableUnoconv bool
logLevel xlog.Level
}
func defaultConfig() Config {
return Config{
maximumWaitTimeout: 30.0,
maximumWaitDelay: 10.0,
maximumWebhookURLTimeout: 30.0,
defaultWaitTimeout: 10.0,
defaultWebhookURLTimeout: 10.0,
defaultListenPort: 3000,
disableGoogleChrome: false,
disableUnoconv: false,
logLevel: xlog.InfoLevel,
}
}
/*
FromEnv returns a Conf according
to environment variables.
*/
func FromEnv() (Config, error) {
const op string = "conf.FromEnv"
resolver := func() (Config, error) {
c := defaultConfig()
maximumWaitTimeout, err := xassert.Float64FromEnv(
maximumWaitTimeoutEnvVar,
c.maximumWaitTimeout,
xassert.Float64NotInferiorTo(0.0),
)
c.maximumWaitTimeout = maximumWaitTimeout
if err != nil {
return c, err
}
maximumWaitDelay, err := xassert.Float64FromEnv(
maximumWaitDelayEnvVar,
c.maximumWaitDelay,
xassert.Float64NotInferiorTo(0.0),
)
c.maximumWaitDelay = maximumWaitDelay
if err != nil {
return c, err
}
maximumWebhookURLTimeout, err := xassert.Float64FromEnv(
maximumWebhookURLTimeoutEnvVar,
c.maximumWebhookURLTimeout,
xassert.Float64NotInferiorTo(0.0),
)
c.maximumWebhookURLTimeout = maximumWebhookURLTimeout
if err != nil {
return c, err
}
defaultWaitTimeout, err := xassert.Float64FromEnv(
defaultWaitTimeoutEnvVar,
c.defaultWaitTimeout,
xassert.Float64NotInferiorTo(0.0),
xassert.Float64NotSuperiorTo(c.maximumWaitTimeout),
)
c.defaultWaitTimeout = defaultWaitTimeout
if err != nil {
return c, err
}
defaultWebhookURLTimeout, err := xassert.Float64FromEnv(
defaultWebhookURLTimeoutEnvVar,
c.defaultWebhookURLTimeout,
xassert.Float64NotInferiorTo(0.0),
xassert.Float64NotSuperiorTo(c.defaultWebhookURLTimeout),
)
c.defaultWebhookURLTimeout = defaultWebhookURLTimeout
if err != nil {
return c, err
}
defaultListenPort, err := xassert.Int64FromEnv(
defaultListenPortEnvVar,
c.defaultListenPort,
xassert.Int64NotInferiorTo(0),
xassert.Int64NotSuperiorTo(65535),
)
c.defaultListenPort = defaultListenPort
if err != nil {
return c, err
}
disableGoogleChrome, err := xassert.BoolFromEnv(
disableGoogleChromeEnvVar,
c.disableGoogleChrome,
)
c.disableGoogleChrome = disableGoogleChrome
if err != nil {
return c, err
}
disableUnoconv, err := xassert.BoolFromEnv(
disableUnoconvEnvVar,
c.disableUnoconv,
)
c.disableUnoconv = disableUnoconv
if err != nil {
return c, err
}
logLevel, err := xassert.StringFromEnv(
logLevelEnvVar,
string(c.logLevel),
xassert.StringOneOf(xlog.Levels()),
)
c.logLevel = xlog.MustParseLevel(logLevel)
if err != nil {
return c, err
}
return c, nil
}
result, err := resolver()
if err != nil {
return result, xerror.New(op, err)
}
return result, nil
}
// MaximumWaitTimeout returns the maximum
// wait timeout from the configuration.
func (c Config) MaximumWaitTimeout() float64 {
return c.maximumWaitTimeout
}
// MaximumWaitDelay returns the maximum
// wait timeout from the configuration.
func (c Config) MaximumWaitDelay() float64 {
return c.maximumWaitDelay
}
// MaximumWebhookURLTimeout returns the maximum
// webhook URL wait timeout from the configuration.
func (c Config) MaximumWebhookURLTimeout() float64 {
return c.maximumWebhookURLTimeout
}
// DefaultWaitTimeout returns the default
// wait timeout from the configuration.
func (c Config) DefaultWaitTimeout() float64 {
return c.defaultWaitTimeout
}
// DefaultWebhookURLTimeout returns the default
// webhook URL wait timeout from the configuration.
func (c Config) DefaultWebhookURLTimeout() float64 {
return c.defaultWebhookURLTimeout
}
// DefaultListenPort returns the default
// listen port from the configuration.
func (c Config) DefaultListenPort() int64 {
return c.defaultListenPort
}
/*
DisableGoogleChrome returns true if
Google Chrome is disabled in the
configuration.
*/
func (c Config) DisableGoogleChrome() bool {
return c.disableGoogleChrome
}
/*
DisableUnoconv returns true if
Unoconv is disabled in the
configuration.
*/
func (c Config) DisableUnoconv() bool {
return c.disableUnoconv
}
// LogLevel returns the xlog.Level from
// the configuration.
func (c Config) LogLevel() xlog.Level {
return c.logLevel
}

View File

@@ -0,0 +1,334 @@
package conf
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/thecodingmachine/gotenberg/internal/pkg/xlog"
"github.com/thecodingmachine/gotenberg/test/internalpkg/xerrortest"
)
func TestEmptyFromEnv(t *testing.T) {
var (
expected Config
result Config
err error
)
// no environment variables set,
// values should be equal to default config.
expected = defaultConfig()
result, err = FromEnv()
assert.Nil(t, err)
assert.Equal(t, expected, result)
}
func TestMaximumWaitTimeoutFromEnv(t *testing.T) {
var (
expected Config
result Config
err error
)
// MAXIMUM_WAIT_TIMEOUT correctly set.
os.Setenv(maximumWaitTimeoutEnvVar, "10.0")
expected = defaultConfig()
expected.maximumWaitTimeout = 10.0
result, err = FromEnv()
assert.Nil(t, err)
assert.Equal(t, expected, result)
os.Unsetenv(maximumWaitTimeoutEnvVar)
// MAXIMUM_WAIT_TIMEOUT wrongly set.
os.Setenv(maximumWaitTimeoutEnvVar, "foo")
expected = defaultConfig()
result, err = FromEnv()
xerrortest.AssertError(t, err)
assert.Equal(t, expected, result)
os.Unsetenv(maximumWaitTimeoutEnvVar)
// MAXIMUM_WAIT_TIMEOUT < 0.
os.Setenv(maximumWaitTimeoutEnvVar, "-1.0")
expected = defaultConfig()
result, err = FromEnv()
xerrortest.AssertError(t, err)
assert.Equal(t, expected, result)
os.Unsetenv(maximumWaitTimeoutEnvVar)
}
func TestMaximumWaitDelayFromEnv(t *testing.T) {
var (
expected Config
result Config
err error
)
// MAXIMUM_WAIT_DELAY correctly set.
os.Setenv(maximumWaitDelayEnvVar, "10.0")
expected = defaultConfig()
expected.maximumWaitDelay = 10.0
result, err = FromEnv()
assert.Nil(t, err)
assert.Equal(t, expected, result)
os.Unsetenv(maximumWaitDelayEnvVar)
// MAXIMUM_WAIT_DELAY wrongly set.
os.Setenv(maximumWaitDelayEnvVar, "foo")
expected = defaultConfig()
result, err = FromEnv()
xerrortest.AssertError(t, err)
assert.Equal(t, expected, result)
os.Unsetenv(maximumWaitDelayEnvVar)
// MAXIMUM_WAIT_DELAY < 0.
os.Setenv(maximumWaitDelayEnvVar, "-1.0")
expected = defaultConfig()
result, err = FromEnv()
xerrortest.AssertError(t, err)
assert.Equal(t, expected, result)
os.Unsetenv(maximumWaitDelayEnvVar)
}
func TestMaximumWebhookURLTimeoutFromEnv(t *testing.T) {
var (
expected Config
result Config
err error
)
// MAXIMUM_WEBHOOK_URL_TIMEOUT correctly set.
os.Setenv(maximumWebhookURLTimeoutEnvVar, "10.0")
expected = defaultConfig()
expected.maximumWebhookURLTimeout = 10.0
result, err = FromEnv()
assert.Nil(t, err)
assert.Equal(t, expected, result)
os.Unsetenv(maximumWebhookURLTimeoutEnvVar)
// MAXIMUM_WEBHOOK_URL_TIMEOUT wrongly set.
os.Setenv(maximumWebhookURLTimeoutEnvVar, "foo")
expected = defaultConfig()
result, err = FromEnv()
xerrortest.AssertError(t, err)
assert.Equal(t, expected, result)
os.Unsetenv(maximumWebhookURLTimeoutEnvVar)
// MAXIMUM_WEBHOOK_URL_TIMEOUT < 0.
os.Setenv(maximumWebhookURLTimeoutEnvVar, "-1.0")
expected = defaultConfig()
result, err = FromEnv()
xerrortest.AssertError(t, err)
assert.Equal(t, expected, result)
os.Unsetenv(maximumWebhookURLTimeoutEnvVar)
}
func TestDefaultWaitTimeoutFromEnv(t *testing.T) {
var (
expected Config
result Config
err error
)
// DEFAULT_WAIT_TIMEOUT correctly set.
os.Setenv(defaultWaitTimeoutEnvVar, "10.0")
expected = defaultConfig()
expected.defaultWaitTimeout = 10.0
result, err = FromEnv()
assert.Nil(t, err)
assert.Equal(t, expected, result)
os.Unsetenv(defaultWaitTimeoutEnvVar)
// DEFAULT_WAIT_TIMEOUT wrongly set.
os.Setenv(defaultWaitTimeoutEnvVar, "foo")
expected = defaultConfig()
result, err = FromEnv()
xerrortest.AssertError(t, err)
assert.Equal(t, expected, result)
os.Unsetenv(defaultWaitTimeoutEnvVar)
// DEFAULT_WAIT_TIMEOUT < 0.
os.Setenv(defaultWaitTimeoutEnvVar, "-1.0")
expected = defaultConfig()
result, err = FromEnv()
xerrortest.AssertError(t, err)
assert.Equal(t, expected, result)
os.Unsetenv(defaultWaitTimeoutEnvVar)
// DEFAULT_WAIT_TIMEOUT > MAXIMUM_WAIT_TIMEOUT.
os.Setenv(defaultWaitTimeoutEnvVar, "40.0")
expected = defaultConfig()
result, err = FromEnv()
xerrortest.AssertError(t, err)
assert.Equal(t, expected, result)
os.Unsetenv(defaultWaitTimeoutEnvVar)
}
func TestDefaultWebhookURLTimeoutFromEnv(t *testing.T) {
var (
expected Config
result Config
err error
)
// DEFAULT_WEBHOOK_URL_TIMEOUT correctly set.
os.Setenv(defaultWebhookURLTimeoutEnvVar, "10.0")
expected = defaultConfig()
expected.defaultWebhookURLTimeout = 10.0
result, err = FromEnv()
assert.Nil(t, err)
assert.Equal(t, expected, result)
os.Unsetenv(defaultWebhookURLTimeoutEnvVar)
// DEFAULT_WEBHOOK_URL_TIMEOUT wrongly set.
os.Setenv(defaultWebhookURLTimeoutEnvVar, "foo")
expected = defaultConfig()
result, err = FromEnv()
xerrortest.AssertError(t, err)
assert.Equal(t, expected, result)
os.Unsetenv(defaultWebhookURLTimeoutEnvVar)
// DEFAULT_WEBHOOK_URL_TIMEOUT < 0.
os.Setenv(defaultWebhookURLTimeoutEnvVar, "-1.0")
expected = defaultConfig()
result, err = FromEnv()
xerrortest.AssertError(t, err)
assert.Equal(t, expected, result)
os.Unsetenv(defaultWebhookURLTimeoutEnvVar)
// DEFAULT_WEBHOOK_URL_TIMEOUT > MAXIMUM_WEBHOOK_URL_TIMEOUT.
os.Setenv(defaultWebhookURLTimeoutEnvVar, "40.0")
expected = defaultConfig()
result, err = FromEnv()
xerrortest.AssertError(t, err)
assert.Equal(t, expected, result)
os.Unsetenv(defaultWebhookURLTimeoutEnvVar)
}
func TestDefaultListenPortFromEnv(t *testing.T) {
var (
expected Config
result Config
err error
)
// DEFAULT_LISTEN_PORT correctly set.
os.Setenv(defaultListenPortEnvVar, "80")
expected = defaultConfig()
expected.defaultListenPort = 80
result, err = FromEnv()
assert.Nil(t, err)
assert.Equal(t, expected, result)
os.Unsetenv(defaultListenPortEnvVar)
// DEFAULT_LISTEN_PORT wrongly set.
os.Setenv(defaultListenPortEnvVar, "foo")
expected = defaultConfig()
result, err = FromEnv()
xerrortest.AssertError(t, err)
assert.Equal(t, expected, result)
os.Unsetenv(defaultListenPortEnvVar)
// DEFAULT_LISTEN_PORT < 0.
os.Setenv(defaultListenPortEnvVar, "-1.0")
expected = defaultConfig()
result, err = FromEnv()
xerrortest.AssertError(t, err)
assert.Equal(t, expected, result)
os.Unsetenv(defaultListenPortEnvVar)
// DEFAULT_LISTEN_PORT > 65535.
os.Setenv(defaultListenPortEnvVar, "65536")
expected = defaultConfig()
result, err = FromEnv()
xerrortest.AssertError(t, err)
assert.Equal(t, expected, result)
os.Unsetenv(defaultListenPortEnvVar)
}
func TestDisableGoogleChromeFromEnv(t *testing.T) {
var (
expected Config
result Config
err error
)
// DISABLE_GOOGLE_CHROME correctly set.
os.Setenv(disableGoogleChromeEnvVar, "1")
expected = defaultConfig()
expected.disableGoogleChrome = true
result, err = FromEnv()
assert.Nil(t, err)
assert.Equal(t, expected, result)
os.Unsetenv(disableGoogleChromeEnvVar)
os.Setenv(disableGoogleChromeEnvVar, "0")
expected = defaultConfig()
expected.disableGoogleChrome = false
result, err = FromEnv()
assert.Nil(t, err)
assert.Equal(t, expected, result)
os.Unsetenv(disableGoogleChromeEnvVar)
// DISABLE_GOOGLE_CHROME wrongly set.
os.Setenv(disableGoogleChromeEnvVar, "foo")
expected = defaultConfig()
result, err = FromEnv()
xerrortest.AssertError(t, err)
assert.Equal(t, expected, result)
os.Unsetenv(disableGoogleChromeEnvVar)
}
func TestDisableUnoconvFromEnv(t *testing.T) {
var (
expected Config
result Config
err error
)
// DISABLE_UNOCONV correctly set.
os.Setenv(disableUnoconvEnvVar, "1")
expected = defaultConfig()
expected.disableUnoconv = true
result, err = FromEnv()
assert.Nil(t, err)
assert.Equal(t, expected, result)
os.Unsetenv(disableUnoconvEnvVar)
os.Setenv(disableUnoconvEnvVar, "0")
expected = defaultConfig()
expected.disableUnoconv = false
result, err = FromEnv()
assert.Nil(t, err)
assert.Equal(t, expected, result)
os.Unsetenv(disableUnoconvEnvVar)
// DISABLE_UNOCONV wrongly set.
os.Setenv(disableUnoconvEnvVar, "foo")
expected = defaultConfig()
result, err = FromEnv()
xerrortest.AssertError(t, err)
assert.Equal(t, expected, result)
os.Unsetenv(disableUnoconvEnvVar)
}
func TestLogLevelFromEnv(t *testing.T) {
var (
expected Config
result Config
err error
)
// LOG_LEVEL correctly set.
os.Setenv(logLevelEnvVar, "DEBUG")
expected = defaultConfig()
expected.logLevel = xlog.DebugLevel
result, err = FromEnv()
assert.Nil(t, err)
assert.Equal(t, expected, result)
os.Unsetenv(logLevelEnvVar)
os.Setenv(logLevelEnvVar, "INFO")
expected = defaultConfig()
result, err = FromEnv()
assert.Nil(t, err)
assert.Equal(t, expected, result)
os.Unsetenv(logLevelEnvVar)
os.Setenv(logLevelEnvVar, "ERROR")
expected = defaultConfig()
expected.logLevel = xlog.ErrorLevel
result, err = FromEnv()
assert.Nil(t, err)
assert.Equal(t, expected, result)
os.Unsetenv(logLevelEnvVar)
// LOG_LEVEL wrongly set.
os.Setenv(logLevelEnvVar, "foo")
expected = defaultConfig()
result, err = FromEnv()
xerrortest.AssertError(t, err)
assert.Equal(t, expected, result)
os.Unsetenv(logLevelEnvVar)
}
func TestGetters(t *testing.T) {
result := defaultConfig()
assert.Equal(t, result.maximumWaitTimeout, result.MaximumWaitTimeout())
assert.Equal(t, result.maximumWaitDelay, result.MaximumWaitDelay())
assert.Equal(t, result.maximumWebhookURLTimeout, result.MaximumWebhookURLTimeout())
assert.Equal(t, result.defaultWaitTimeout, result.DefaultWaitTimeout())
assert.Equal(t, result.defaultWebhookURLTimeout, result.DefaultWebhookURLTimeout())
assert.Equal(t, result.defaultListenPort, result.DefaultListenPort())
assert.Equal(t, result.disableGoogleChrome, result.DisableGoogleChrome())
assert.Equal(t, result.disableUnoconv, result.DisableUnoconv())
assert.Equal(t, result.logLevel, result.LogLevel())
}

3
internal/pkg/conf/doc.go Normal file
View File

@@ -0,0 +1,3 @@
// Package conf gathers all
// configuration data.
package conf

View File

@@ -1,178 +0,0 @@
package config
import (
"fmt"
"os"
"strconv"
"github.com/sirupsen/logrus"
"github.com/thecodingmachine/gotenberg/internal/pkg/standarderror"
)
const (
defaultWaitTimeoutEnvVar string = "DEFAULT_WAIT_TIMEOUT"
defaultListenPortEnvVar string = "DEFAULT_LISTEN_PORT"
disableGoogleChromeEnvVar string = "DISABLE_GOOGLE_CHROME"
disableUnoconvEnvVar string = "DISABLE_UNOCONV"
logLevelEnvVar string = "LOG_LEVEL"
)
// Config contains the application
// configuration.
type Config struct {
defaultWaitTimeout float64
defaultListenPort string
enableChromeEndpoints bool
enableUnoconvEndpoints bool
logLevel logrus.Level
}
func defaultConfig() *Config {
return &Config{
defaultWaitTimeout: 10,
defaultListenPort: "3000",
enableChromeEndpoints: true,
enableUnoconvEndpoints: true,
logLevel: logrus.InfoLevel,
}
}
// FromEnv fetches configuration
// from environment variables.
func FromEnv() (*Config, error) {
const op string = "config.FromEnv"
c := defaultConfig()
defaultWaitTimeout, err := defaultWaitTimeoutFromEnv(defaultWaitTimeoutEnvVar, c.DefaultWaitTimeout())
c.defaultWaitTimeout = defaultWaitTimeout
if err != nil {
return c, &standarderror.Error{Op: op, Err: err}
}
defaultListenPort, err := defaultListenPortFromEnv(defaultListenPortEnvVar, c.DefaultListenPort())
c.defaultListenPort = defaultListenPort
if err != nil {
return c, &standarderror.Error{Op: op, Err: err}
}
disableChromeEndpoints, err := boolFromEnv(disableGoogleChromeEnvVar, !c.EnableChromeEndpoints())
c.enableChromeEndpoints = !disableChromeEndpoints
if err != nil {
return c, &standarderror.Error{Op: op, Err: err}
}
disableUnoconvEndpoints, err := boolFromEnv(disableUnoconvEnvVar, !c.EnableUnoconvEndpoints())
c.enableUnoconvEndpoints = !disableUnoconvEndpoints
if err != nil {
return c, &standarderror.Error{Op: op, Err: err}
}
logLevel, err := logLevelFromEnv(logLevelEnvVar, c.LogLevel())
c.logLevel = logLevel
if err != nil {
return c, &standarderror.Error{Op: op, Err: err}
}
return c, nil
}
// DefaultWaitTimeout returns the default
// wait timeout from the configuration.
func (c *Config) DefaultWaitTimeout() float64 {
return c.defaultWaitTimeout
}
// DefaultListenPort returns the default
// listen port from the configuration.
func (c *Config) DefaultListenPort() string {
return c.defaultListenPort
}
// EnableChromeEndpoints returns true if
// Chrome endpoints are enabled in the
// configuration.
func (c *Config) EnableChromeEndpoints() bool {
return c.enableChromeEndpoints
}
// EnableUnoconvEndpoints returns true if
// Unoconv endpoints are enabled in the
// configuration.
func (c *Config) EnableUnoconvEndpoints() bool {
return c.enableUnoconvEndpoints
}
// LogLevel returns the logrus.Level from
// the configuration.
func (c *Config) LogLevel() logrus.Level {
return c.logLevel
}
func defaultWaitTimeoutFromEnv(envVar string, defaultValue float64) (float64, error) {
const op string = "config.defaultWaitTimeoutFromEnv"
if v, ok := os.LookupEnv(envVar); ok {
waitTimeout, err := strconv.ParseFloat(v, 64)
if err != nil {
return defaultValue, &standarderror.Error{
Code: standarderror.Invalid,
Message: fmt.Sprintf("'%s' is not a float, got '%s'", envVar, v),
Op: op,
}
}
return waitTimeout, nil
}
return defaultValue, nil
}
func defaultListenPortFromEnv(envVar string, defaultValue string) (string, error) {
const op string = "config.defaultListenPortFromEnv"
if v, ok := os.LookupEnv(envVar); ok {
portAsUint, err := strconv.ParseUint(v, 10, 64)
if err != nil {
return defaultValue, &standarderror.Error{
Code: standarderror.Invalid,
Message: fmt.Sprintf("'%s' is not a uint, got '%s'", envVar, v),
Op: op,
}
}
if portAsUint > 65535 {
return defaultValue, &standarderror.Error{
Code: standarderror.Invalid,
Message: fmt.Sprintf("'%s' is not a uint < 65535, got '%d'", envVar, portAsUint),
Op: op,
}
}
return v, nil
}
return defaultValue, nil
}
func boolFromEnv(envVar string, defaultValue bool) (bool, error) {
const op string = "config.boolFromEnv"
if v, ok := os.LookupEnv(envVar); ok {
if v != "1" && v != "0" {
return defaultValue, &standarderror.Error{
Code: standarderror.Invalid,
Message: fmt.Sprintf("'%s' is not '0' or '1', got %s", envVar, v),
Op: op,
}
}
return v == "1", nil
}
return defaultValue, nil
}
func logLevelFromEnv(envVar string, defaultValue logrus.Level) (logrus.Level, error) {
const op string = "config.logLevelFromEnv"
if v, ok := os.LookupEnv(envVar); ok {
switch v {
case "DEBUG":
return logrus.DebugLevel, nil
case "INFO":
return logrus.InfoLevel, nil
case "ERROR":
return logrus.ErrorLevel, nil
default:
return defaultValue, &standarderror.Error{
Code: standarderror.Invalid,
Message: fmt.Sprintf("'%s' is not 'DEBUG', 'INFO' or 'ERROR', got '%s'", envVar, v),
Op: op,
}
}
}
return defaultValue, nil
}

View File

@@ -1,123 +0,0 @@
package config
import (
"os"
"testing"
"github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/thecodingmachine/gotenberg/internal/pkg/standarderror"
"github.com/thecodingmachine/gotenberg/test"
)
func TestDefaultWaitTimeout(t *testing.T) {
// should be OK.
config, err := FromEnv()
assert.Nil(t, err)
assert.Equal(t, 10.0, config.DefaultWaitTimeout())
os.Setenv(defaultWaitTimeoutEnvVar, "1.5")
config, err = FromEnv()
assert.Nil(t, err)
assert.Equal(t, 1.5, config.DefaultWaitTimeout())
// should failed.
os.Setenv(defaultWaitTimeoutEnvVar, "foo")
_, err = FromEnv()
assert.NotNil(t, err)
standardized := test.RequireStandardError(t, err)
assert.Equal(t, standarderror.Invalid, standarderror.Code(standardized))
os.Unsetenv(defaultWaitTimeoutEnvVar)
}
func TestDefaultListenPort(t *testing.T) {
// should be OK.
config, err := FromEnv()
assert.Nil(t, err)
assert.Equal(t, "3000", config.DefaultListenPort())
os.Setenv(defaultListenPortEnvVar, "4000")
config, err = FromEnv()
assert.Nil(t, err)
assert.Equal(t, "4000", config.DefaultListenPort())
// should failed.
os.Setenv(defaultListenPortEnvVar, "foo")
_, err = FromEnv()
assert.NotNil(t, err)
standardized := test.RequireStandardError(t, err)
assert.Equal(t, standarderror.Invalid, standarderror.Code(standardized))
os.Setenv(defaultListenPortEnvVar, "100000000")
_, err = FromEnv()
assert.NotNil(t, err)
standardized = test.RequireStandardError(t, err)
assert.Equal(t, standarderror.Invalid, standarderror.Code(standardized))
os.Unsetenv(defaultListenPortEnvVar)
}
func TestEnableChromeEndpoints(t *testing.T) {
// should be OK.
config, err := FromEnv()
assert.Nil(t, err)
assert.Equal(t, true, config.EnableChromeEndpoints())
os.Setenv(disableGoogleChromeEnvVar, "1")
config, err = FromEnv()
assert.Nil(t, err)
assert.Equal(t, false, config.EnableChromeEndpoints())
os.Setenv(disableGoogleChromeEnvVar, "0")
config, err = FromEnv()
assert.Nil(t, err)
assert.Equal(t, true, config.EnableChromeEndpoints())
// should failed.
os.Setenv(disableGoogleChromeEnvVar, "true")
_, err = FromEnv()
assert.NotNil(t, err)
standardized := test.RequireStandardError(t, err)
assert.Equal(t, standarderror.Invalid, standarderror.Code(standardized))
os.Unsetenv(disableGoogleChromeEnvVar)
}
func TestEnableUnoconvEndpoints(t *testing.T) {
// should be OK.
config, err := FromEnv()
assert.Nil(t, err)
assert.Equal(t, true, config.EnableUnoconvEndpoints())
os.Setenv(disableUnoconvEnvVar, "1")
config, err = FromEnv()
assert.Nil(t, err)
assert.Equal(t, false, config.EnableUnoconvEndpoints())
os.Setenv(disableUnoconvEnvVar, "0")
config, err = FromEnv()
assert.Nil(t, err)
assert.Equal(t, true, config.EnableUnoconvEndpoints())
// should failed.
os.Setenv(disableUnoconvEnvVar, "true")
_, err = FromEnv()
assert.NotNil(t, err)
standardized := test.RequireStandardError(t, err)
assert.Equal(t, standarderror.Invalid, standarderror.Code(standardized))
os.Unsetenv(disableUnoconvEnvVar)
}
func TestLogLevel(t *testing.T) {
// should be OK.
config, err := FromEnv()
assert.Nil(t, err)
assert.Equal(t, logrus.InfoLevel, config.LogLevel())
os.Setenv(logLevelEnvVar, "DEBUG")
config, err = FromEnv()
assert.Nil(t, err)
assert.Equal(t, logrus.DebugLevel, config.LogLevel())
os.Setenv(logLevelEnvVar, "INFO")
config, err = FromEnv()
assert.Nil(t, err)
assert.Equal(t, logrus.InfoLevel, config.LogLevel())
os.Setenv(logLevelEnvVar, "ERROR")
config, err = FromEnv()
assert.Nil(t, err)
assert.Equal(t, logrus.ErrorLevel, config.LogLevel())
// should failed.
os.Setenv(logLevelEnvVar, "foo")
config, err = FromEnv()
assert.Equal(t, logrus.InfoLevel, config.LogLevel())
assert.NotNil(t, err)
standardized := test.RequireStandardError(t, err)
assert.Equal(t, standarderror.Invalid, standarderror.Code(standardized))
os.Unsetenv(logLevelEnvVar)
}

View File

@@ -1,3 +0,0 @@
// Package config gathers all
// configuration data.
package config

View File

@@ -1,3 +0,0 @@
// Package logger defines a standard
// logger for the application.
package logger

View File

@@ -1,63 +0,0 @@
package logger
import (
"os"
"github.com/mattn/go-isatty"
"github.com/sirupsen/logrus"
)
// Logger enforces specific log message formats.
type Logger struct {
entry *logrus.Entry
}
// New initializes the logger.
func New(level logrus.Level, trace string) *Logger {
l := logrus.New()
l.SetLevel(level)
if !isatty.IsTerminal(os.Stdout.Fd()) {
l.SetFormatter(&logrus.JSONFormatter{})
}
return &Logger{
entry: l.WithField("trace", trace),
}
}
// WithFields creates a new logger with
// given fields.
func (l *Logger) WithFields(fields map[string]interface{}) *Logger {
return &Logger{
entry: l.entry.WithFields(fields),
}
}
// DebugfOp logs a debug message for given
// logical operation.
func (l *Logger) DebugfOp(op string, format string, args ...interface{}) {
l.entry.WithField("op", op).Debugf(format, args...)
}
// InfofOp logs an info message for given
// logical operation.
func (l *Logger) InfofOp(op string, format string, args ...interface{}) {
l.entry.WithField("op", op).Infof(format, args...)
}
// ErrorOp logs an error for given
// logical operation.
func (l *Logger) ErrorOp(op string, err error) {
l.entry.WithField("op", op).Error(err.Error())
}
// ErrorfOp logs an error message for given
// logical operation.
func (l *Logger) ErrorfOp(op string, message string) {
l.entry.WithField("op", op).Error(message)
}
// FatalOp logs an error message for given
// logical operation.
func (l *Logger) FatalOp(op string, err error) {
l.entry.WithField("op", op).Error(err.Error())
}

View File

@@ -5,45 +5,72 @@ import (
"time"
"github.com/mafredri/cdp/devtool"
"github.com/thecodingmachine/gotenberg/internal/pkg/logger"
"github.com/thecodingmachine/gotenberg/internal/pkg/standarderror"
"github.com/thecodingmachine/gotenberg/internal/pkg/xerror"
"github.com/thecodingmachine/gotenberg/internal/pkg/xlog"
)
const chromeWarmupTime time.Duration = 10 * time.Second
type chrome struct {
manager *processManager
type chromeProcess struct {
logger xlog.Logger
}
// NewChrome returns a Google Chrome
// NewChromeProcess returns a Google Chrome
// headless process.
func NewChrome(logger *logger.Logger) Process {
return &chrome{
manager: &processManager{logger: logger},
func NewChromeProcess(logger xlog.Logger) Process {
return chromeProcess{
logger: logger,
}
}
func (p *chrome) Fullname() string {
func (p chromeProcess) Fullname() string {
return "Google Chrome headless"
}
func (p *chrome) Start() error {
const op string = "pm2.chrome.Start"
if err := p.manager.start(p); err != nil {
return &standarderror.Error{Op: op, Err: err}
func (p chromeProcess) Start() error {
const op string = "pm2.chromeProcess.Start"
if err := start(p.logger, p); err != nil {
return xerror.New(op, err)
}
return nil
}
func (p *chrome) Shutdown() error {
const op string = "pm2.chrome.Shutdown"
if err := p.manager.shutdown(p); err != nil {
return &standarderror.Error{Op: op, Err: err}
func (p chromeProcess) IsViable() bool {
const op string = "pm2.chromeProcess.IsViable"
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
p.logger.DebugfOp(
op,
"checking '%s' viability via endpoint '%s'",
p.Fullname(),
"http://localhost:9222/json/version",
)
v, err := devtool.New("http://localhost:9222").Version(ctx)
if err != nil {
p.logger.ErrorfOp(
op,
"'%s' is not viable as endpoint returned '%v'",
p.Fullname(),
err,
)
return false
}
p.logger.DebugfOp(
op,
"'%s' is viable as endpoint returned '%v'",
p.Fullname(),
v,
)
return true
}
func (p chromeProcess) Stop() error {
const op string = "pm2.chromeProcess.Stop"
if err := stop(p.logger, p); err != nil {
return xerror.New(op, err)
}
return nil
}
func (p *chrome) args() []string {
func (p chromeProcess) args() []string {
return []string{
"--no-sandbox",
"--headless",
@@ -62,47 +89,25 @@ func (p *chrome) args() []string {
}
}
func (p *chrome) name() string {
func (p chromeProcess) binary() string {
return "google-chrome-stable"
}
func (p *chrome) viable() bool {
const op string = "pm2.chrome.viable"
// check if Google Chrome is correctly running.
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
p.manager.logger.DebugfOp(
op,
"checking liveness via debug version endpoint http://localhost:9222/json/version",
func (p chromeProcess) warmup() {
const (
op string = "pm2.chromeProcess.warmup"
warmupTime time.Duration = 10 * time.Second
)
v, err := devtool.New("http://localhost:9222").Version(ctx)
if err != nil {
p.manager.logger.DebugfOp(
op,
"debug version endpoint returned error: %v",
err,
)
return false
}
p.manager.logger.DebugfOp(
p.logger.DebugfOp(
op,
"debug version endpoint returned version info: %+v",
*v,
"waiting '%v' for allowing '%s' to warmup",
warmupTime,
p.Fullname(),
)
return true
}
func (p *chrome) warmup() {
const op string = "pm2.chrome.warmup"
p.manager.logger.DebugfOp(
op,
"allowing %v to startup",
chromeWarmupTime,
)
time.Sleep(chromeWarmupTime)
time.Sleep(warmupTime)
}
// Compile-time checks to ensure type implements desired interfaces.
var (
_ = Process(new(chrome))
_ = Process(new(chromeProcess))
)

View File

@@ -1,20 +0,0 @@
package pm2
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/thecodingmachine/gotenberg/test"
)
func TestChromeStart(t *testing.T) {
p := NewChrome(test.CreateTestLogger())
err := p.Start()
require.Nil(t, err)
}
func TestChromeShutdown(t *testing.T) {
p := NewChrome(test.CreateTestLogger())
err := p.Shutdown()
require.Nil(t, err)
}

View File

@@ -1,6 +1,6 @@
/*
Package pm2 facilitates starting external
processes on which our API depends.
processes on which our application depends.
For instance, it may start Google Chrome headless and
unoconv listener with PM2.

View File

@@ -1,124 +1,102 @@
package pm2
import (
"bufio"
"fmt"
"io"
"os/exec"
"strings"
"github.com/thecodingmachine/gotenberg/internal/pkg/logger"
"github.com/thecodingmachine/gotenberg/internal/pkg/standarderror"
)
const (
stoppedState int32 = iota
runningState
errorState
"github.com/thecodingmachine/gotenberg/internal/pkg/xerror"
"github.com/thecodingmachine/gotenberg/internal/pkg/xexec"
"github.com/thecodingmachine/gotenberg/internal/pkg/xlog"
)
// Process is a type that can start or
// shutdown a process with PM2.
// stop a process with PM2.
type Process interface {
Fullname() string
Start() error
Shutdown() error
IsViable() bool
Stop() error
args() []string
name() string
viable() bool
binary() string
warmup()
}
type processManager struct {
heuristicState int32
logger *logger.Logger
}
type pm2Command string
func (m *processManager) start(p Process) error {
const op string = "pm2.start"
if err := m.pm2(p, "start"); err != nil {
return &standarderror.Error{Op: op, Err: err}
}
p.warmup()
if !p.viable() {
attempts := 0
for attempts < 5 && !p.viable() {
if err := m.pm2(p, "restart"); err != nil {
m.heuristicState = errorState
return &standarderror.Error{Op: op, Err: err}
}
p.warmup()
attempts++
const (
startCommand pm2Command = "start"
restartCommand pm2Command = "restart"
stopCommand pm2Command = "stop"
logsCommand pm2Command = "logs"
)
func start(logger xlog.Logger, process Process) error {
const (
op string = "pm2.start"
maximumAttempts int = 3
)
resolver := func() error {
// first, we try to start the process.
if err := run(logger, startCommand, process); err != nil {
return err
}
if !p.viable() {
m.heuristicState = errorState
return &standarderror.Error{
Op: op,
Message: fmt.Sprintf("failed to launch %s", p.Fullname()),
// we wait the process to be ready.
process.warmup()
// if the process failed to start correctly,
// we have to restart it.
if !process.IsViable() {
attempts := 0
for attempts < maximumAttempts && !process.IsViable() {
if err := run(logger, restartCommand, process); err != nil {
return err
}
process.warmup()
attempts++
}
if !process.IsViable() {
return fmt.Errorf("failed to start '%s'", process.Fullname())
}
}
}
m.heuristicState = runningState
return nil
}
func (m *processManager) shutdown(p Process) error {
const op string = "pm2.shutdown"
if m.heuristicState != runningState {
// the process is viable, let's log its
// output.
if err := run(logger, logsCommand, process); err != nil {
return err
}
return nil
}
if err := m.pm2(p, "stop"); err != nil {
m.heuristicState = errorState
return &standarderror.Error{Op: op, Err: err}
if err := resolver(); err != nil {
return xerror.New(op, err)
}
m.heuristicState = stoppedState
return nil
}
func (m *processManager) pm2(p Process, cmdName string) error {
const op string = "pm2.pm2"
cmdArgs := []string{
cmdName,
p.name(),
}
if cmdName == "start" {
cmdArgs = append(cmdArgs, "--interpreter=none", "--")
cmdArgs = append(cmdArgs, p.args()...)
}
cmd := exec.Command(
"pm2",
cmdArgs...,
)
m.logger.DebugfOp(op, "executing command: %s", strings.Join(cmd.Args, " "))
processStdOut, err := cmd.StdoutPipe()
if err != nil {
return &standarderror.Error{Op: op, Err: err}
}
processStdErr, err := cmd.StderrPipe()
if err != nil {
return &standarderror.Error{Op: op, Err: err}
}
readFromPipe := func(outputType string, reader io.ReadCloser) {
readFromPipeOp := fmt.Sprintf("pm2.%s.%s", p.name(), outputType)
r := bufio.NewReader(reader)
defer reader.Close() // nolint: errcheck
for {
line, _, err := r.ReadLine()
if err != nil {
if err != io.EOF {
m.logger.ErrorOp(readFromPipeOp, err)
}
break
}
if len(line) != 0 {
m.logger.DebugfOp(readFromPipeOp, string(line))
}
}
}
go readFromPipe("stdout", processStdOut)
go readFromPipe("stderr", processStdErr)
if err := cmd.Start(); err != nil {
return &standarderror.Error{Op: op, Err: err}
func stop(logger xlog.Logger, process Process) error {
const op string = "pm2.stop"
if err := run(logger, stopCommand, process); err != nil {
return xerror.New(op, err)
}
return nil
}
func run(logger xlog.Logger, pm2Cmd pm2Command, process Process) error {
const op string = "pm2.run"
resolver := func() error {
args := []string{
string(pm2Cmd),
process.binary(),
}
if pm2Cmd == startCommand {
args = append(args, "--interpreter=none", "--")
args = append(args, process.args()...)
}
cmd, err := xexec.Command(logger, "pm2", args...)
if err != nil {
return err
}
xexec.LogBeforeExecute(logger, cmd)
return cmd.Start()
}
if err := resolver(); err != nil {
return xerror.New(op, err)
}
return nil
}

View File

@@ -3,73 +3,75 @@ package pm2
import (
"time"
"github.com/thecodingmachine/gotenberg/internal/pkg/logger"
"github.com/thecodingmachine/gotenberg/internal/pkg/standarderror"
"github.com/thecodingmachine/gotenberg/internal/pkg/xerror"
"github.com/thecodingmachine/gotenberg/internal/pkg/xlog"
)
const unoconvWarmupTime time.Duration = 5 * time.Second
type unoconv struct {
manager *processManager
type unoconvProcess struct {
logger xlog.Logger
}
// NewUnoconv returns a unoconv listener
// NewUnoconvProcess returns a unoconv listener
// process.
func NewUnoconv(logger *logger.Logger) Process {
return &unoconv{
manager: &processManager{logger: logger},
func NewUnoconvProcess(logger xlog.Logger) Process {
return unoconvProcess{
logger: logger,
}
}
func (p *unoconv) Fullname() string {
func (p unoconvProcess) Fullname() string {
return "unoconv listener"
}
func (p *unoconv) Start() error {
const op string = "pm2.unoconv.Start"
if err := p.manager.start(p); err != nil {
return &standarderror.Error{Op: op, Err: err}
func (p unoconvProcess) Start() error {
const op string = "pm2.unoconvProcess.Start"
if err := start(p.logger, p); err != nil {
return xerror.New(op, err)
}
return nil
}
func (p *unoconv) Shutdown() error {
const op string = "pm2.unoconv.Shutdown"
if err := p.manager.shutdown(p); err != nil {
return &standarderror.Error{Op: op, Err: err}
}
return nil
}
func (p *unoconv) args() []string {
return []string{
"--listener",
"--verbose",
}
}
func (p *unoconv) name() string {
return "unoconv"
}
func (p *unoconv) viable() bool {
func (p unoconvProcess) IsViable() bool {
// TODO find a way to check if
// the unoconv listener
// is correctly started?
return true
}
func (p *unoconv) warmup() {
const op string = "pm2.unoconv.warmup"
p.manager.logger.DebugfOp(
op,
"allowing %v to startup",
unoconvWarmupTime,
func (p unoconvProcess) Stop() error {
const op string = "pm2.unoconvProcess.Stop"
if err := stop(p.logger, p); err != nil {
return xerror.New(op, err)
}
return nil
}
func (p unoconvProcess) args() []string {
return []string{
"--listener",
"--verbose",
}
}
func (p unoconvProcess) binary() string {
return "unoconv"
}
func (p unoconvProcess) warmup() {
const (
op string = "pm2.unoconvProcess.warmup"
warmupTime time.Duration = 3 * time.Second
)
time.Sleep(unoconvWarmupTime)
p.logger.DebugfOp(
op,
"waiting '%v' for allowing '%s' to warmup",
warmupTime,
p.Fullname(),
)
time.Sleep(warmupTime)
}
// Compile-time checks to ensure type implements desired interfaces.
var (
_ = Process(new(unoconv))
_ = Process(new(unoconvProcess))
)

View File

@@ -1,20 +0,0 @@
package pm2
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/thecodingmachine/gotenberg/test"
)
func TestUnoconvStart(t *testing.T) {
p := NewUnoconv(test.CreateTestLogger())
err := p.Start()
require.Nil(t, err)
}
func TestUnoconvShutdown(t *testing.T) {
p := NewUnoconv(test.CreateTestLogger())
err := p.Shutdown()
require.Nil(t, err)
}

View File

@@ -12,19 +12,22 @@ import (
"github.com/mafredri/cdp/protocol/page"
"github.com/mafredri/cdp/protocol/target"
"github.com/mafredri/cdp/rpcc"
"github.com/thecodingmachine/gotenberg/internal/pkg/standarderror"
"github.com/thecodingmachine/gotenberg/internal/pkg/timeout"
"github.com/thecodingmachine/gotenberg/internal/pkg/xcontext"
"github.com/thecodingmachine/gotenberg/internal/pkg/xerror"
"github.com/thecodingmachine/gotenberg/internal/pkg/xlog"
"github.com/thecodingmachine/gotenberg/internal/pkg/xtime"
"golang.org/x/sync/errgroup"
)
type chrome struct {
url string
opts *ChromeOptions
type chromePrinter struct {
logger xlog.Logger
url string
opts ChromePrinterOptions
}
// ChromeOptions helps customizing the
// ChromePrinterOptions helps customizing the
// Google Chrome printer behaviour.
type ChromeOptions struct {
type ChromePrinterOptions struct {
WaitTimeout float64
WaitDelay float64
HeaderHTML string
@@ -38,26 +41,27 @@ type ChromeOptions struct {
Landscape bool
}
func (p *chrome) Print(destination string) error {
const op string = "printer.chrome.Print"
ctx, cancel := timeout.Context(p.opts.WaitTimeout + p.opts.WaitDelay)
func (p chromePrinter) Print(destination string) error {
const op string = "printer.chromePrinter.Print"
logOptions(p.logger, p.opts)
ctx, cancel := xcontext.WithTimeout(p.logger, p.opts.WaitTimeout+p.opts.WaitDelay)
defer cancel()
resolver := func() error {
devt, err := devtool.New("http://localhost:9222").Version(ctx)
if err != nil {
return &standarderror.Error{Op: op, Err: err}
return err
}
// connect to WebSocket URL (page) that speaks the Chrome DevTools Protocol.
devtConn, err := rpcc.DialContext(ctx, devt.WebSocketDebuggerURL)
if err != nil {
return &standarderror.Error{Op: op, Err: err}
return err
}
defer devtConn.Close() // nolint: errcheck
// create a new CDP Client that uses conn.
devtClient := cdp.NewClient(devtConn)
newContextTarget, err := devtClient.Target.CreateBrowserContext(ctx)
if err != nil {
return &standarderror.Error{Op: op, Err: err}
return err
}
// create a new blank target with the new browser context.
createTargetArgs := target.
@@ -65,13 +69,13 @@ func (p *chrome) Print(destination string) error {
SetBrowserContextID(newContextTarget.BrowserContextID)
newTarget, err := devtClient.Target.CreateTarget(ctx, createTargetArgs)
if err != nil {
return &standarderror.Error{Op: op, Err: err}
return err
}
// connect the client to the new target.
newTargetWsURL := fmt.Sprintf("ws://127.0.0.1:9222/devtools/page/%s", newTarget.TargetID)
newContextConn, err := rpcc.DialContext(ctx, newTargetWsURL)
if err != nil {
return &standarderror.Error{Op: op, Err: err}
return err
}
defer newContextConn.Close() // nolint: errcheck
// create a new CDP Client that uses newContextConn.
@@ -86,10 +90,10 @@ func (p *chrome) Print(destination string) error {
func() error { return targetClient.Page.Enable(ctx) },
func() error { return targetClient.Runtime.Enable(ctx) },
); err != nil {
return &standarderror.Error{Op: op, Err: err}
return err
}
if err := p.navigate(ctx, targetClient); err != nil {
return &standarderror.Error{Op: op, Err: err}
return err
}
print, err := targetClient.Page.PrintToPDF(
ctx,
@@ -107,58 +111,67 @@ func (p *chrome) Print(destination string) error {
SetPrintBackground(true),
)
if err != nil {
return &standarderror.Error{Op: op, Err: err}
return err
}
if err := ioutil.WriteFile(destination, print.Data, 0644); err != nil {
return &standarderror.Error{Op: op, Err: err}
return err
}
return nil
}
if err := resolver(); err != nil {
return timeout.Err(ctx, err)
return xcontext.MustHandleError(
ctx,
xerror.New(op, err),
)
}
return nil
}
func (p *chrome) navigate(ctx context.Context, client *cdp.Client) error {
const op string = "printer.chrome.navigate"
// make sure Page events are enabled.
if err := client.Page.Enable(ctx); err != nil {
return &standarderror.Error{Op: op, Err: err}
func (p chromePrinter) navigate(ctx context.Context, client *cdp.Client) error {
const op string = "printer.chromePrinter.navigate"
resolver := func() error {
// make sure Page events are enabled.
if err := client.Page.Enable(ctx); err != nil {
return err
}
// make sure Network events are enabled.
if err := client.Network.Enable(ctx, nil); err != nil {
return err
}
// create all clients for events.
domContentEventFired, err := client.Page.DOMContentEventFired(ctx)
if err != nil {
return err
}
defer domContentEventFired.Close() // nolint: errcheck
loadEventFired, err := client.Page.LoadEventFired(ctx)
if err != nil {
return err
}
defer loadEventFired.Close() // nolint: errcheck
loadingFinished, err := client.Network.LoadingFinished(ctx)
if err != nil {
return err
}
defer loadingFinished.Close() // nolint: errcheck
if _, err := client.Page.Navigate(ctx, page.NewNavigateArgs(p.url)); err != nil {
return err
}
if err := runBatch(
// wait for all events.
func() error { _, err := domContentEventFired.Recv(); return err },
func() error { _, err := loadEventFired.Recv(); return err },
func() error { _, err := loadingFinished.Recv(); return err },
); err != nil {
return err
}
// wait for a given amount of time (useful for javascript delay).
time.Sleep(xtime.Duration(p.opts.WaitDelay))
return nil
}
// make sure Network events are enabled.
if err := client.Network.Enable(ctx, nil); err != nil {
return &standarderror.Error{Op: op, Err: err}
if err := resolver(); err != nil {
return xerror.New(op, err)
}
// create all clients for events.
domContentEventFired, err := client.Page.DOMContentEventFired(ctx)
if err != nil {
return &standarderror.Error{Op: op, Err: err}
}
defer domContentEventFired.Close() // nolint: errcheck
loadEventFired, err := client.Page.LoadEventFired(ctx)
if err != nil {
return &standarderror.Error{Op: op, Err: err}
}
defer loadEventFired.Close() // nolint: errcheck
loadingFinished, err := client.Network.LoadingFinished(ctx)
if err != nil {
return &standarderror.Error{Op: op, Err: err}
}
defer loadingFinished.Close() // nolint: errcheck
if _, err := client.Page.Navigate(ctx, page.NewNavigateArgs(p.url)); err != nil {
return &standarderror.Error{Op: op, Err: err}
}
if err := runBatch(
// wait for all events.
func() error { _, err := domContentEventFired.Recv(); return err },
func() error { _, err := loadEventFired.Recv(); return err },
func() error { _, err := loadingFinished.Recv(); return err },
); err != nil {
return &standarderror.Error{Op: op, Err: err}
}
// wait for a given amount of time (useful for javascript delay).
time.Sleep(timeout.Duration(p.opts.WaitDelay))
return nil
}
@@ -174,5 +187,5 @@ func runBatch(fn ...func() error) error {
// Compile-time checks to ensure type implements desired interfaces.
var (
_ = Printer(new(chrome))
_ = Printer(new(chromePrinter))
)

View File

@@ -1,5 +1,3 @@
/*
Package printer contains structs which convert
a specific file type to PDF.
*/
// Package printer helps converting
// a specific file type to PDF.
package printer

View File

@@ -2,13 +2,17 @@ package printer
import (
"fmt"
"github.com/thecodingmachine/gotenberg/internal/pkg/xlog"
)
// NewHTML returns an HTML printer.
func NewHTML(fpath string, opts *ChromeOptions) Printer {
// NewHTMLPrinter returns a Printer which
// is able to convert an HTML file to PDF.
func NewHTMLPrinter(logger xlog.Logger, fpath string, opts ChromePrinterOptions) Printer {
URL := fmt.Sprintf("file://%s", fpath)
return &chrome{
url: URL,
opts: opts,
return chromePrinter{
logger: logger,
url: URL,
opts: opts,
}
}

View File

@@ -7,37 +7,46 @@ import (
"io/ioutil"
"path/filepath"
"github.com/labstack/gommon/random"
"github.com/microcosm-cc/bluemonday"
"github.com/russross/blackfriday/v2"
"github.com/thecodingmachine/gotenberg/internal/pkg/standarderror"
"github.com/thecodingmachine/gotenberg/internal/pkg/xerror"
"github.com/thecodingmachine/gotenberg/internal/pkg/xlog"
"github.com/thecodingmachine/gotenberg/internal/pkg/xrand"
)
// NewMarkdown returns a Markdown printer.
func NewMarkdown(fpath string, opts *ChromeOptions) (Printer, error) {
const op string = "printer.NewMarkdown"
tmpl, err := template.
New(filepath.Base(fpath)).
Funcs(template.FuncMap{"toHTML": markdownToHTML}).
ParseFiles(fpath)
// NewMarkdownPrinter returns a Printer which
// is able to convert Markdown files to PDF.
func NewMarkdownPrinter(logger xlog.Logger, fpath string, opts ChromePrinterOptions) (Printer, error) {
const op string = "printer.NewMarkdownPrinter"
resolver := func() (string, error) {
tmpl, err := template.
New(filepath.Base(fpath)).
Funcs(template.FuncMap{"toHTML": markdownToHTML}).
ParseFiles(fpath)
if err != nil {
return "", err
}
dirPath := filepath.Dir(fpath)
data := &templateData{DirPath: dirPath}
var buffer bytes.Buffer
if err := tmpl.Execute(&buffer, data); err != nil {
return "", err
}
baseFilename := xrand.Get()
dst := fmt.Sprintf("%s/%s.html", dirPath, baseFilename)
if err := ioutil.WriteFile(dst, buffer.Bytes(), 0644); err != nil {
return "", err
}
return fmt.Sprintf("file://%s", dst), nil
}
URL, err := resolver()
if err != nil {
return nil, &standarderror.Error{Op: op, Err: err}
return chromePrinter{}, xerror.New(op, err)
}
dirPath := filepath.Dir(fpath)
data := &templateData{DirPath: dirPath}
var buffer bytes.Buffer
if err := tmpl.Execute(&buffer, data); err != nil {
return nil, &standarderror.Error{Op: op, Err: err}
}
baseFilename := random.String(32)
dst := fmt.Sprintf("%s/%s.html", dirPath, baseFilename)
if err := ioutil.WriteFile(dst, buffer.Bytes(), 0644); err != nil {
return nil, &standarderror.Error{Op: op, Err: err}
}
URL := fmt.Sprintf("file://%s", dst)
return &chrome{
url: URL,
opts: opts,
return chromePrinter{
logger: logger,
url: URL,
opts: opts,
}, nil
}
@@ -50,7 +59,7 @@ func markdownToHTML(dirPath, filename string) (template.HTML, error) {
fpath := fmt.Sprintf("%s/%s", dirPath, filename)
b, err := ioutil.ReadFile(fpath)
if err != nil {
return "", &standarderror.Error{Op: op, Err: err}
return "", xerror.New(op, err)
}
unsafe := blackfriday.Run(b)
content := bluemonday.UGCPolicy().SanitizeBytes(unsafe)

View File

@@ -2,57 +2,71 @@ package printer
import (
"context"
"os/exec"
"github.com/thecodingmachine/gotenberg/internal/pkg/standarderror"
"github.com/thecodingmachine/gotenberg/internal/pkg/timeout"
"github.com/thecodingmachine/gotenberg/internal/pkg/xcontext"
"github.com/thecodingmachine/gotenberg/internal/pkg/xerror"
"github.com/thecodingmachine/gotenberg/internal/pkg/xexec"
"github.com/thecodingmachine/gotenberg/internal/pkg/xlog"
)
type merge struct {
type mergePrinter struct {
ctx context.Context
logger xlog.Logger
fpaths []string
opts *MergeOptions
opts MergePrinterOptions
}
// MergeOptions helps customizing the
// merge printer behaviour.
type MergeOptions struct {
// MergePrinterOptions helps customizing the
// merge Printer behaviour.
type MergePrinterOptions struct {
WaitTimeout float64
}
// NewMerge returns a merge printer.
func NewMerge(fpaths []string, opts *MergeOptions) Printer {
return &merge{
// NewMergePrinter returns a Printer which
// is able to merge PDFs.
func NewMergePrinter(logger xlog.Logger, fpaths []string, opts MergePrinterOptions) Printer {
return mergePrinter{
logger: logger,
fpaths: fpaths,
opts: opts,
}
}
func (p *merge) Print(destination string) error {
const op string = "printer.merge.Print"
func (p mergePrinter) Print(destination string) error {
const op string = "printer.mergePrinter.Print"
logOptions(p.logger, p.opts)
/*
context.Context may be providen from
an officePrinter which needs to merge
its result files.
*/
if p.ctx == nil {
ctx, cancel := timeout.Context(p.opts.WaitTimeout)
ctx, cancel := xcontext.WithTimeout(p.logger, p.opts.WaitTimeout)
defer cancel()
p.ctx = ctx
}
p.logger.DebugfOp(op, "merging '%v'...", p.fpaths)
resolver := func() error {
var cmdArgs []string
cmdArgs = append(cmdArgs, p.fpaths...)
cmdArgs = append(cmdArgs, "cat", "output", destination)
cmd := exec.CommandContext(p.ctx, "pdftk", cmdArgs...)
_, err := cmd.Output()
var args []string
args = append(args, p.fpaths...)
args = append(args, "cat", "output", destination)
cmd, err := xexec.CommandContext(p.ctx, p.logger, "pdftk", args...)
if err != nil {
return &standarderror.Error{Op: op, Err: err}
return err
}
return nil
xexec.LogBeforeExecute(p.logger, cmd)
return cmd.Run()
}
if err := resolver(); err != nil {
return timeout.Err(p.ctx, err)
return xcontext.MustHandleError(
p.ctx,
xerror.New(op, err),
)
}
return nil
}
// Compile-time checks to ensure type implements desired interfaces.
var (
_ = Printer(new(merge))
_ = Printer(new(mergePrinter))
)

View File

@@ -4,67 +4,75 @@ import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"sync"
"github.com/labstack/gommon/random"
"github.com/thecodingmachine/gotenberg/internal/pkg/standarderror"
"github.com/thecodingmachine/gotenberg/internal/pkg/timeout"
"github.com/thecodingmachine/gotenberg/internal/pkg/xcontext"
"github.com/thecodingmachine/gotenberg/internal/pkg/xerror"
"github.com/thecodingmachine/gotenberg/internal/pkg/xexec"
"github.com/thecodingmachine/gotenberg/internal/pkg/xlog"
"github.com/thecodingmachine/gotenberg/internal/pkg/xrand"
)
type office struct {
type officePrinter struct {
logger xlog.Logger
fpaths []string
opts *OfficeOptions
opts OfficePrinterOptions
}
// OfficeOptions helps customizing the
// Office printer behaviour.
type OfficeOptions struct {
// OfficePrinterOptions helps customizing the
// Office Printer behaviour.
type OfficePrinterOptions struct {
WaitTimeout float64
Landscape bool
}
// NewOffice returns an Office printer.
func NewOffice(fpaths []string, opts *OfficeOptions) Printer {
return &office{
// NewOfficePrinter returns a Printer which
// is able to convert Office documents to PDF.
func NewOfficePrinter(logger xlog.Logger, fpaths []string, opts OfficePrinterOptions) Printer {
return officePrinter{
logger: logger,
fpaths: fpaths,
opts: opts,
}
}
func (p *office) Print(destination string) error {
const op string = "printer.office.Print"
ctx, cancel := timeout.Context(p.opts.WaitTimeout)
func (p officePrinter) Print(destination string) error {
const op string = "printer.officePrinter.Print"
logOptions(p.logger, p.opts)
ctx, cancel := xcontext.WithTimeout(p.logger, p.opts.WaitTimeout)
defer cancel()
fpaths := make([]string, len(p.fpaths))
resolver := func() error {
fpaths := make([]string, len(p.fpaths))
dirPath := filepath.Dir(destination)
for i, fpath := range p.fpaths {
baseFilename := random.String(32)
baseFilename := xrand.Get()
tmpDest := fmt.Sprintf("%s/%d%s.pdf", dirPath, i, baseFilename)
if err := unoconv(ctx, fpath, tmpDest, p.opts); err != nil {
return &standarderror.Error{Op: op, Err: err}
p.logger.DebugfOp(op, "converting '%s' to PDF...", fpath)
if err := unoconv(ctx, p.logger, fpath, tmpDest, p.opts); err != nil {
return err
}
p.logger.DebugfOp(op, "'%s.pdf' created", baseFilename)
fpaths[i] = tmpDest
}
return nil
if len(fpaths) == 1 {
p.logger.DebugOp(op, "only one PDF created, nothing to merge")
if err := os.Rename(fpaths[0], destination); err != nil {
return err
}
return nil
}
m := mergePrinter{
ctx: ctx,
fpaths: fpaths,
}
return m.Print(destination)
}
if err := resolver(); err != nil {
return timeout.Err(ctx, err)
}
if len(fpaths) == 1 {
if err := os.Rename(fpaths[0], destination); err != nil {
return &standarderror.Error{Op: op, Err: err}
}
return nil
}
m := &merge{
ctx: ctx,
fpaths: fpaths,
}
if err := m.Print(destination); err != nil {
return &standarderror.Error{Op: op, Err: err}
return xcontext.MustHandleError(
ctx,
xerror.New(op, err),
)
}
return nil
}
@@ -72,31 +80,41 @@ func (p *office) Print(destination string) error {
// nolint: gochecknoglobals
var mu sync.Mutex
func unoconv(ctx context.Context, fpath, destination string, opts *OfficeOptions) error {
func unoconv(ctx context.Context, logger xlog.Logger, fpath, destination string, opts OfficePrinterOptions) error {
const op string = "printer.unoconv"
// TODO check if timeout while waiting for the lock.
logger.DebugOp(op, "waiting lock to be released...")
mu.Lock()
defer mu.Unlock()
cmdArgs := []string{
"--format",
"pdf",
logger.DebugOp(op, "lock released")
resolver := func() error {
args := []string{
"--format",
"pdf",
}
if opts.Landscape {
args = append(args, "--printer", "PaperOrientation=landscape")
}
args = append(args, "--output", destination, fpath)
cmd, err := xexec.CommandContext(
ctx,
logger,
"unoconv",
args...,
)
if err != nil {
return err
}
xexec.LogBeforeExecute(logger, cmd)
return cmd.Run()
}
if opts.Landscape {
cmdArgs = append(cmdArgs, "--printer", "PaperOrientation=landscape")
}
cmdArgs = append(cmdArgs, "--output", destination, fpath)
cmd := exec.CommandContext(
ctx,
"unoconv",
cmdArgs...,
)
_, err := cmd.Output()
if err != nil {
return &standarderror.Error{Op: op, Err: err}
if err := resolver(); err != nil {
return xerror.New(op, err)
}
return nil
}
// Compile-time checks to ensure type implements desired interfaces.
var (
_ = Printer(new(office))
_ = Printer(new(officePrinter))
)

View File

@@ -1,7 +1,16 @@
package printer
import (
"github.com/thecodingmachine/gotenberg/internal/pkg/xlog"
)
// Printer is a type that can create a PDF file from a source.
// The source is defined in the underlying implementation.
type Printer interface {
Print(destination string) error
}
func logOptions(logger xlog.Logger, opts interface{}) {
const op string = "printer.logOptions"
logger.DebugfOp(op, "options: %+v", opts)
}

View File

@@ -1,9 +1,15 @@
package printer
// NewURL returns a URL printer.
func NewURL(url string, opts *ChromeOptions) Printer {
return &chrome{
url: url,
opts: opts,
import (
"github.com/thecodingmachine/gotenberg/internal/pkg/xlog"
)
// NewURLPrinter returns a Printer which
// is able to convert a URL to PDF.
func NewURLPrinter(logger xlog.Logger, url string, opts ChromePrinterOptions) Printer {
return chromePrinter{
logger: logger,
url: url,
opts: opts,
}
}

View File

@@ -1,3 +0,0 @@
// Package random helps generating
// a random string.
package random

View File

@@ -1,7 +0,0 @@
/*
Package standarderror helps standardizing
the errors in the application.
Credits: https://middlemost.com/failure-is-your-domain/
*/
package standarderror

View File

@@ -1,108 +0,0 @@
package standarderror
import (
"bytes"
"fmt"
)
const (
// Internal is a code
// for internal errors.
Internal = "internal"
// Invalid is a code
// for validation errors.
Invalid = "invalid"
// Timeout is a code
// for timeout errors.
Timeout = "timeout"
)
// Error defines a standard application
// error.
type Error struct {
// Code is a machine-readable
// error code.
Code string
// Message is a human-readable
// message.
Message string
// Op is a logical operation.
Op string
// Err is a nested error.
Err error
}
// Error returns the string representation of the error message.
func (err *Error) Error() string {
var buf bytes.Buffer
// if wrapping an error, print its Error() message.
// Otherwise print the error code & message.
if err.Err != nil {
buf.WriteString(err.Err.Error())
} else {
if err.Code != "" {
fmt.Fprintf(&buf, "<%s> ", err.Code)
}
buf.WriteString(err.Message)
}
return buf.String()
}
// Code returns the code of the root error, if available.
// Otherwise returns Internal.
func Code(err error) string {
if err == nil {
return ""
}
e, ok := err.(*Error)
if ok && e.Code != "" {
return e.Code
}
if ok && e.Err != nil {
return Code(e.Err)
}
return Internal
}
const defaultMessage string = "an internal error has occurred: please contact technical support"
// Message returns the human-readable message of the error, if available.
// Otherwise returns a generic error message.
func Message(err error) string {
if err == nil {
return ""
}
e, ok := err.(*Error)
if ok && e.Message != "" {
return e.Message
}
if ok && e.Err != nil {
return Message(e.Err)
}
return defaultMessage
}
// Op returns the logical operation of the error, if available.
// Otherwise returns an empty string.
func Op(err error) string {
if err == nil {
return ""
}
e, ok := err.(*Error)
if !ok {
return ""
}
var buf bytes.Buffer
if e.Op != "" {
fmt.Fprintf(&buf, "%s", e.Op)
}
if nestedOp := Op(e.Err); nestedOp != "" {
fmt.Fprintf(&buf, ": %s", nestedOp)
}
return buf.String()
}
// Compile-time checks to ensure type implements desired interfaces.
var (
_ = error(new(Error))
)

View File

@@ -1,70 +0,0 @@
package standarderror
import (
"errors"
"testing"
"github.com/stretchr/testify/assert"
)
func scenario1() error {
rootErr := errors.New("root error")
nestedErr := &Error{
Code: Invalid,
Op: "bar",
Message: "nested error",
Err: rootErr,
}
err := &Error{
Op: "foo",
Err: nestedErr,
}
return err
}
func scenario2() error {
nestedErr := &Error{
Code: Invalid,
Op: "bar",
Message: "nested error",
}
err := &Error{
Code: Internal,
Op: "foo",
Err: nestedErr,
}
return err
}
func TestError(t *testing.T) {
err := scenario1()
assert.Equal(t, "root error", err.Error())
err = scenario2()
assert.Equal(t, "<invalid> nested error", err.Error())
}
func TestCode(t *testing.T) {
assert.Equal(t, "", Code(nil))
err := scenario1()
assert.Equal(t, Invalid, Code(err))
err = scenario2()
assert.Equal(t, Internal, Code(err))
err = errors.New("some error")
assert.Equal(t, Internal, Code(err))
}
func TestMessage(t *testing.T) {
assert.Equal(t, "", Message(nil))
err := scenario1()
assert.Equal(t, "nested error", Message(err))
err = errors.New("some error")
assert.Equal(t, defaultMessage, Message(err))
}
func TestOp(t *testing.T) {
assert.Equal(t, "", Op(nil))
err := scenario1()
assert.Equal(t, "foo: bar", Op(err))
err = errors.New("some error")
assert.Equal(t, "", Op(err))
}

View File

@@ -1,3 +0,0 @@
// Package timeout helps managing
// context with timeout.
package timeout

View File

@@ -1,47 +0,0 @@
package timeout
import (
"context"
"fmt"
"strings"
"time"
"github.com/thecodingmachine/gotenberg/internal/pkg/standarderror"
)
// Context creates a context with timeout for
// given second.
func Context(seconds float64) (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), Duration(seconds))
}
// Duration creates a duration from seconds.
func Duration(seconds float64) time.Duration {
return time.Duration(1000*seconds) * time.Millisecond
}
// Err checks if there is an error in the given context
// and wraps the previous error inside a standarderror.Error.
func Err(ctx context.Context, previousErr error) error {
const op string = "timeout.Err"
if previousErr == nil {
panic(fmt.Sprintf("%s: previous error should not be nil", op))
}
err := ctx.Err()
if err == nil {
return previousErr
}
if strings.Contains(err.Error(), context.DeadlineExceeded.Error()) {
return &standarderror.Error{
Code: standarderror.Timeout,
Message: "context has timed out",
Op: op,
Err: previousErr,
}
}
return &standarderror.Error{
Message: "context finished with an error",
Op: op,
Err: previousErr,
}
}

View File

@@ -1,40 +0,0 @@
package timeout
import (
"errors"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/thecodingmachine/gotenberg/internal/pkg/standarderror"
"github.com/thecodingmachine/gotenberg/test"
)
func TestDuration(t *testing.T) {
expected := time.Duration(1500) * time.Millisecond
result := Duration(1.5)
assert.Equal(t, expected.String(), result.String())
}
func TestErr(t *testing.T) {
previousErr := errors.New("previous error")
// should be OK.
ctx, cancel := Context(5)
defer cancel()
assert.NotNil(t, Err(ctx, previousErr))
// should timeout.
ctx, cancel = Context(0.5)
defer cancel()
time.Sleep(Duration(1))
err := Err(ctx, previousErr)
assert.NotNil(t, err)
standardized := test.RequireStandardError(t, err)
assert.Equal(t, standarderror.Timeout, standardized.Code)
// should failed.
ctx, cancel = Context(5)
cancel()
err = Err(ctx, previousErr)
assert.NotNil(t, err)
standardized = test.RequireStandardError(t, err)
assert.Equal(t, standarderror.Internal, standarderror.Code(err))
}

View File

@@ -0,0 +1,8 @@
/*
Package xassert is a helper for converting
and/or validating strings.
All functions return our standard xerror.Error
in case of error.
*/
package xassert

View File

@@ -0,0 +1,88 @@
package xassert
import (
"fmt"
"github.com/thecodingmachine/gotenberg/internal/pkg/xerror"
)
// RuleFloat64 is an interface for
// validating a float64.
type RuleFloat64 interface {
with(key string, value float64)
validate() error
}
type baseRuleFloat64 struct {
key string
value float64
}
func (r *baseRuleFloat64) with(key string, value float64) {
r.key = key
r.value = value
}
type ruleFloat64NotInferiorTo struct {
*baseRuleFloat64
lowerBound float64
}
func (r ruleFloat64NotInferiorTo) validate() error {
const op string = "xassert.ruleFloat64NotInferiorTo.validate"
if r.value < r.lowerBound {
return xerror.Invalid(
op,
fmt.Sprintf("'%s' should be > '%f', got '%f'", r.key, r.lowerBound, r.value),
nil,
)
}
return nil
}
/*
Float64NotInferiorTo returns a RuleFloat64 for
validating that a float64 is not inferior to
given lower bound.
*/
func Float64NotInferiorTo(lowerBound float64) RuleFloat64 {
return ruleFloat64NotInferiorTo{
&baseRuleFloat64{},
lowerBound,
}
}
type ruleFloat64NotSuperiorTo struct {
*baseRuleFloat64
upperBound float64
}
func (r ruleFloat64NotSuperiorTo) validate() error {
const op string = "xassert.ruleFloat64NotSuperiorTo.validate"
if r.value > r.upperBound {
return xerror.Invalid(
op,
fmt.Sprintf("'%s' should be < '%f', got '%f'", r.key, r.upperBound, r.value),
nil,
)
}
return nil
}
/*
Float64NotSuperiorTo returns a RuleFloat64 for
validating that a float64 is not superior to
given upper bound.
*/
func Float64NotSuperiorTo(upperBound float64) RuleFloat64 {
return ruleFloat64NotSuperiorTo{
&baseRuleFloat64{},
upperBound,
}
}
// Compile-time checks to ensure type implements desired interfaces.
var (
_ = RuleFloat64(new(ruleFloat64NotInferiorTo))
_ = RuleFloat64(new(ruleFloat64NotSuperiorTo))
)

View File

@@ -0,0 +1,32 @@
package xassert
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/thecodingmachine/gotenberg/test/internalpkg/xerrortest"
)
func TestFloat64NotInferiorTo(t *testing.T) {
rule := Float64NotInferiorTo(0.0)
// should be OK.
rule.with("FOO", 10.0)
err := rule.validate()
assert.Nil(t, err)
// should not be OK.
rule.with("FOO", -10.0)
err = rule.validate()
xerrortest.AssertError(t, err)
}
func TestFloat64NotSuperiorTo(t *testing.T) {
rule := Float64NotSuperiorTo(0.0)
// should be OK.
rule.with("FOO", -10.0)
err := rule.validate()
assert.Nil(t, err)
// should not be OK.
rule.with("FOO", 10.0)
err = rule.validate()
xerrortest.AssertError(t, err)
}

View File

@@ -0,0 +1,88 @@
package xassert
import (
"fmt"
"github.com/thecodingmachine/gotenberg/internal/pkg/xerror"
)
// RuleInt64 is an interface for
// validating an int64.
type RuleInt64 interface {
with(key string, value int64)
validate() error
}
type baseRuleInt64 struct {
key string
value int64
}
func (r *baseRuleInt64) with(key string, value int64) {
r.key = key
r.value = value
}
type ruleInt64NotInferiorTo struct {
*baseRuleInt64
lowerBound int64
}
func (r ruleInt64NotInferiorTo) validate() error {
const op string = "xassert.ruleInt64NotInferiorTo.validate"
if r.value < r.lowerBound {
return xerror.Invalid(
op,
fmt.Sprintf("'%s' should be > '%d', got '%d'", r.key, r.lowerBound, r.value),
nil,
)
}
return nil
}
/*
Int64NotInferiorTo returns a RuleInt64 for
validating that an int64 is not inferior to
given lower bound.
*/
func Int64NotInferiorTo(lowerBound int64) RuleInt64 {
return &ruleInt64NotInferiorTo{
&baseRuleInt64{},
lowerBound,
}
}
type ruleInt64NotSuperiorTo struct {
*baseRuleInt64
upperBound int64
}
func (r ruleInt64NotSuperiorTo) validate() error {
const op string = "xassert.ruleInt64NotSuperiorTo.validate"
if r.value > r.upperBound {
return xerror.Invalid(
op,
fmt.Sprintf("'%s' should be < '%d', got '%d'", r.key, r.upperBound, r.value),
nil,
)
}
return nil
}
/*
Int64NotSuperiorTo returns a RuleInt64 for
validating that an int64 is not superior to
given upper bound.
*/
func Int64NotSuperiorTo(upperBound int64) RuleInt64 {
return ruleInt64NotSuperiorTo{
&baseRuleInt64{},
upperBound,
}
}
// Compile-time checks to ensure type implements desired interfaces.
var (
_ = RuleInt64(new(ruleInt64NotInferiorTo))
_ = RuleInt64(new(ruleInt64NotSuperiorTo))
)

View File

@@ -0,0 +1,32 @@
package xassert
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/thecodingmachine/gotenberg/test/internalpkg/xerrortest"
)
func TestInt64NotInferiorTo(t *testing.T) {
rule := Int64NotInferiorTo(0)
// should be OK.
rule.with("FOO", 10)
err := rule.validate()
assert.Nil(t, err)
// should not be OK.
rule.with("FOO", -10)
err = rule.validate()
xerrortest.AssertError(t, err)
}
func TestInt64NotSuperiorTo(t *testing.T) {
rule := Int64NotSuperiorTo(0)
// should be OK.
rule.with("FOO", -10)
err := rule.validate()
assert.Nil(t, err)
// should not be OK.
rule.with("FOO", 10)
err = rule.validate()
xerrortest.AssertError(t, err)
}

View File

@@ -0,0 +1,60 @@
package xassert
import (
"fmt"
"github.com/thecodingmachine/gotenberg/internal/pkg/xerror"
)
// RuleString is an interface for
// validating a string.
type RuleString interface {
with(key, value string)
validate() error
}
type baseRuleString struct {
key string
value string
}
func (r *baseRuleString) with(key, value string) {
r.key = key
r.value = value
}
type ruleStringOneOf struct {
*baseRuleString
values []string
}
func (r ruleStringOneOf) validate() error {
const op string = "xassert.ruleStringOneOf.validate"
for _, v := range r.values {
if r.value == v {
return nil
}
}
return xerror.Invalid(
op,
fmt.Sprintf("'%s' should be one of '%v', got '%s'", r.key, r.values, r.value),
nil,
)
}
/*
StringOneOf returns a RuleString for
validating that a string is one of given
values.
*/
func StringOneOf(values []string) RuleString {
return ruleStringOneOf{
&baseRuleString{},
values,
}
}
// Compile-time checks to ensure type implements desired interfaces.
var (
_ = RuleString(new(ruleStringOneOf))
)

View File

@@ -0,0 +1,20 @@
package xassert
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/thecodingmachine/gotenberg/test/internalpkg/xerrortest"
)
func TestStringOfOne(t *testing.T) {
rule := StringOneOf([]string{"foo", "bar", "baz"})
// should be OK.
rule.with("FOO", "foo")
err := rule.validate()
assert.Nil(t, err)
// should not be OK.
rule.with("FOO", "qux")
err = rule.validate()
xerrortest.AssertError(t, err)
}

View File

@@ -0,0 +1,185 @@
package xassert
import (
"fmt"
"os"
"strconv"
"github.com/thecodingmachine/gotenberg/internal/pkg/xerror"
)
/*
String applies validation on a string.
If string is empty or validation fails,
returns the default value.
The key is used to identify the value.
*/
func String(key, value, defaultValue string, rules ...RuleString) (string, error) {
const op string = "xassert.String"
result := defaultValue
if value != "" {
result = value
}
for _, rule := range rules {
rule.with(key, result)
if err := rule.validate(); err != nil {
return defaultValue, xerror.New(op, err)
}
}
return result, nil
}
/*
StringFromEnv returns the value of given environment
variable or the default value if not found or
validation fails.
*/
func StringFromEnv(envVar, defaultValue string, rules ...RuleString) (string, error) {
const op string = "xassert.StringFromEnv"
value := os.Getenv(envVar)
result, err := String(envVar, value, defaultValue, rules...)
if err != nil {
return result, xerror.New(op, err)
}
return result, nil
}
/*
Int64 tries to convert a string to an int64.
If string is empty, conversion or validation fails,
returns the default value.
The key is used to identify the value.
*/
func Int64(key, value string, defaultValue int64, rules ...RuleInt64) (int64, error) {
const op string = "xassert.Int64"
result := defaultValue
if value != "" {
parsedValue, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return defaultValue, xerror.Invalid(
op,
fmt.Sprintf("'%s' is not an integer, got '%s'", key, value),
err,
)
}
result = parsedValue
}
for _, rule := range rules {
rule.with(key, result)
if err := rule.validate(); err != nil {
return defaultValue, xerror.New(op, err)
}
}
return result, nil
}
/*
Int64FromEnv returns the int64 representation of the
value of given environment variable.
If not found, empty, conversion or validation fails,
returns the default value.
*/
func Int64FromEnv(envVar string, defaultValue int64, rules ...RuleInt64) (int64, error) {
const op string = "xassert.Int64FromEnv"
value := os.Getenv(envVar)
result, err := Int64(envVar, value, defaultValue, rules...)
if err != nil {
return result, xerror.New(op, err)
}
return result, nil
}
/*
Float64 tries to convert a string to a float64.
If string is empty, conversion or validation fails,
returns the default value.
The key is used to identify the value.
*/
func Float64(key, value string, defaultValue float64, rules ...RuleFloat64) (float64, error) {
const op string = "xassert.Float64"
result := defaultValue
if value != "" {
parsedValue, err := strconv.ParseFloat(value, 64)
if err != nil {
return defaultValue, xerror.Invalid(
op,
fmt.Sprintf("'%s' is not a float, got '%s'", key, value),
err,
)
}
result = parsedValue
}
for _, rule := range rules {
rule.with(key, result)
if err := rule.validate(); err != nil {
return defaultValue, xerror.New(op, err)
}
}
return result, nil
}
/*
Float64FromEnv returns the float64 representation of the
value of given environment variable.
If not found, empty, conversion or validation fails,
returns the default value.
*/
func Float64FromEnv(envVar string, defaultValue float64, rules ...RuleFloat64) (float64, error) {
const op string = "xassert.Float64FromEnv"
value := os.Getenv(envVar)
result, err := Float64(envVar, value, defaultValue, rules...)
if err != nil {
return result, xerror.New(op, err)
}
return result, nil
}
/*
Bool tries to convert a string to a boolean.
If string is empty or conversion fails, returns the
default value.
The key is used to identify the value.
*/
func Bool(key, value string, defaultValue bool) (bool, error) {
const op string = "xassert.Bool"
result := defaultValue
if value != "" {
parsedValue, err := strconv.ParseBool(value)
if err != nil {
return defaultValue, xerror.Invalid(
op,
fmt.Sprintf("'%s' is not a boolean, got '%s'", key, value),
err,
)
}
result = parsedValue
}
return result, nil
}
/*
BoolFromEnv returns the boolean representation of the
value of given environment variable.
If not found, empty or conversion fails, returns the
default value.
*/
func BoolFromEnv(envVar string, defaultValue bool) (bool, error) {
const op string = "xassert.BoolFromEnv"
value := os.Getenv(envVar)
result, err := Bool(envVar, value, defaultValue)
if err != nil {
return result, xerror.New(op, err)
}
return result, nil
}

View File

@@ -0,0 +1,289 @@
package xassert
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/thecodingmachine/gotenberg/test/internalpkg/xerrortest"
)
func TestString(t *testing.T) {
const (
defaultValue string = "FOO"
)
var expected string
rule := StringOneOf([]string{"FOO", "BAR"})
// empty value, result should be equal
// to the default value.
v, err := String("foo", "", defaultValue)
expected = defaultValue
assert.Equal(t, expected, v)
assert.Nil(t, err)
// result should be equal to given value
// as it is one of "FOO" and "BAR".
expected = "FOO"
v, err = String("foo", expected, defaultValue, rule)
assert.Equal(t, expected, v)
assert.Nil(t, err)
// should not be OK as given value is not
// one of "FOO" and "BAR".
v, err = String("foo", "BAZ", defaultValue, rule)
expected = defaultValue
assert.Equal(t, expected, v)
xerrortest.AssertError(t, err)
}
func TestStringFromEnv(t *testing.T) {
const (
envVar string = "FOO"
defaultValue string = "FOO"
)
var expected string
rule := StringOneOf([]string{"FOO", "BAR"})
// no environment variable set,
// value should be equal to default value.
v, err := StringFromEnv(envVar, defaultValue)
expected = defaultValue
assert.Equal(t, expected, v)
assert.Nil(t, err)
// result should be equal to environment variable
// value as it is one of "FOO" and "BAR".
expected = "BAR"
os.Setenv(envVar, expected)
v, err = StringFromEnv(envVar, defaultValue, rule)
assert.Equal(t, expected, v)
assert.Nil(t, err)
os.Unsetenv(envVar)
// should not be OK as environment variable
// value is not one of "FOO" and "BAR".
os.Setenv(envVar, "BAZ")
v, err = StringFromEnv(envVar, defaultValue, rule)
expected = defaultValue
assert.Equal(t, expected, v)
xerrortest.AssertError(t, err)
os.Unsetenv(envVar)
}
func TestInt64(t *testing.T) {
const (
defaultValue int64 = 10
)
var expected int64
rule := Int64NotInferiorTo(6)
// empty value, result should be equal
// to the default value.
v, err := Int64("foo", "", defaultValue)
expected = defaultValue
assert.Equal(t, expected, v)
assert.Nil(t, err)
// result should be equal to given value
// but as integer.
v, err = Int64("foo", "5", defaultValue)
expected = 5
assert.Equal(t, expected, v)
assert.Nil(t, err)
// should not be OK as given value is not
// a string representation of an integer.
v, err = Int64("foo", "foo", defaultValue)
expected = defaultValue
assert.Equal(t, expected, v)
xerrortest.AssertError(t, err)
// should not be OK as given value does not
// validate the rule x >= 6.
v, err = Int64("foo", "5", defaultValue, rule)
expected = defaultValue
assert.Equal(t, expected, v)
xerrortest.AssertError(t, err)
}
func TestInt64FromEnv(t *testing.T) {
const (
envVar string = "FOO"
defaultValue int64 = 10
)
var expected int64
rule := Int64NotInferiorTo(6)
// no environment variable set,
// value should be equal to default value.
v, err := Int64FromEnv(envVar, defaultValue)
expected = defaultValue
assert.Equal(t, expected, v)
assert.Nil(t, err)
// result should be equal to environment variable
// value but as integer.
os.Setenv(envVar, "5")
v, err = Int64FromEnv(envVar, defaultValue)
expected = 5
assert.Equal(t, expected, v)
assert.Nil(t, err)
os.Unsetenv(envVar)
// should not be OK as environment variable
// value is not a string representation of an integer.
os.Setenv(envVar, "foo")
v, err = Int64FromEnv(envVar, defaultValue)
expected = defaultValue
assert.Equal(t, expected, v)
xerrortest.AssertError(t, err)
os.Unsetenv(envVar)
// should not be OK as environment variable
// value does not validate the rule x >= 6.
os.Setenv(envVar, "5")
v, err = Int64FromEnv(envVar, defaultValue, rule)
expected = defaultValue
assert.Equal(t, expected, v)
xerrortest.AssertError(t, err)
os.Unsetenv(envVar)
}
func TestFloat64(t *testing.T) {
const defaultValue float64 = 10.0
var expected float64
rule := Float64NotInferiorTo(6.0)
// empty value, result should be equal
// to the default value.
v, err := Float64("foo", "", defaultValue)
expected = defaultValue
assert.Equal(t, expected, v)
assert.Nil(t, err)
// result should be equal to given value
// but as float.
v, err = Float64("foo", "5.5", defaultValue)
expected = 5.5
assert.Equal(t, expected, v)
assert.Nil(t, err)
// should not be OK as given value is not
// a string representation of a float.
v, err = Float64("foo", "foo", defaultValue)
expected = defaultValue
assert.Equal(t, expected, v)
xerrortest.AssertError(t, err)
// should not be OK as given value does not
// validate the rule x >= 6.
v, err = Float64("foo", "5", defaultValue, rule)
expected = defaultValue
assert.Equal(t, expected, v)
xerrortest.AssertError(t, err)
}
func TestFloat64FromEnv(t *testing.T) {
const (
envVar string = "FOO"
defaultValue float64 = 10.0
)
var expected float64
rule := Float64NotInferiorTo(6.0)
// no environment variable set,
// value should be equal to default value.
v, err := Float64FromEnv(envVar, defaultValue)
expected = defaultValue
assert.Equal(t, expected, v)
assert.Nil(t, err)
// result should be equal to environment variable
// value but as float.
os.Setenv(envVar, "5.5")
v, err = Float64FromEnv(envVar, defaultValue)
expected = 5.5
assert.Equal(t, expected, v)
assert.Nil(t, err)
os.Unsetenv(envVar)
// should not be OK as environment variable
// value is not a string representation of a float.
os.Setenv(envVar, "foo")
v, err = Float64FromEnv(envVar, defaultValue)
expected = defaultValue
assert.Equal(t, expected, v)
xerrortest.AssertError(t, err)
os.Unsetenv(envVar)
// should not be OK as environment variable
// value does not validate the rule x >= 6.
os.Setenv(envVar, "5")
v, err = Float64FromEnv(envVar, defaultValue, rule)
expected = defaultValue
assert.Equal(t, expected, v)
xerrortest.AssertError(t, err)
os.Unsetenv(envVar)
}
func TestBool(t *testing.T) {
const defaultValue bool = true
var expected bool
// empty value, result should be equal
// to the default value.
v, err := Bool("foo", "", defaultValue)
expected = defaultValue
assert.Equal(t, expected, v)
assert.Nil(t, err)
// result should be equal to given value
// but as boolean.
v, err = Bool("foo", "1", defaultValue)
expected = true
assert.Equal(t, expected, v)
assert.Nil(t, err)
v, err = Bool("foo", "true", defaultValue)
expected = true
assert.Equal(t, expected, v)
assert.Nil(t, err)
v, err = Bool("foo", "0", defaultValue)
expected = false
assert.Equal(t, expected, v)
assert.Nil(t, err)
v, err = Bool("foo", "false", defaultValue)
expected = false
assert.Equal(t, expected, v)
assert.Nil(t, err)
// should not be OK as given value is not
// a string representation of a boolean.
v, err = Bool("foo", "foo", defaultValue)
expected = defaultValue
assert.Equal(t, expected, v)
xerrortest.AssertError(t, err)
}
func TestBoolFromEnv(t *testing.T) {
const (
envVar string = "FOO"
defaultValue bool = true
)
var expected bool
// no environment variable set,
// value should be equal to default value.
v, err := BoolFromEnv(envVar, defaultValue)
expected = defaultValue
assert.Equal(t, expected, v)
assert.Nil(t, err)
// result should be equal to environment variable
// value but as boolean.
os.Setenv(envVar, "1")
v, err = BoolFromEnv(envVar, defaultValue)
expected = true
assert.Equal(t, expected, v)
assert.Nil(t, err)
os.Unsetenv(envVar)
os.Setenv(envVar, "true")
v, err = BoolFromEnv(envVar, defaultValue)
expected = true
assert.Equal(t, expected, v)
assert.Nil(t, err)
os.Unsetenv(envVar)
os.Setenv(envVar, "0")
v, err = BoolFromEnv(envVar, defaultValue)
expected = false
assert.Equal(t, expected, v)
assert.Nil(t, err)
os.Unsetenv(envVar)
os.Setenv(envVar, "false")
v, err = BoolFromEnv(envVar, defaultValue)
expected = false
assert.Equal(t, expected, v)
assert.Nil(t, err)
os.Unsetenv(envVar)
// should not be OK as environment variable
// value is not a string representation of a boolean.
os.Setenv(envVar, "foo")
v, err = BoolFromEnv(envVar, defaultValue)
expected = defaultValue
assert.Equal(t, expected, v)
xerrortest.AssertError(t, err)
os.Unsetenv(envVar)
}

View File

@@ -0,0 +1,3 @@
// Package xcontext helps managing
// context.Context with timeout.
package xcontext

View File

@@ -0,0 +1,56 @@
package xcontext
import (
"context"
"fmt"
"strings"
"github.com/thecodingmachine/gotenberg/internal/pkg/xerror"
"github.com/thecodingmachine/gotenberg/internal/pkg/xlog"
"github.com/thecodingmachine/gotenberg/internal/pkg/xtime"
)
// WithTimeout creates a context.Context which
// times out after given seconds.
func WithTimeout(logger xlog.Logger, seconds float64) (context.Context, context.CancelFunc) {
const op string = "xcontext.WithTimeout"
logger.DebugfOp(op, "creating context with '%.2fs' of timeout...", seconds)
return context.WithTimeout(context.Background(), xtime.Duration(seconds))
}
/*
MustHandleError checks if there is an error
in the given Context.
If no error, returns the previous error.
If context.DeadlineExceeded, wraps the previous
error inside an xerror.Error with xerror.TimeoutCode.
Otherwise wraps the previous error inside an
xerror.Error.
It panics if no previous error.
*/
func MustHandleError(ctx context.Context, previousErr error) error {
const op string = "xcontext.MustHandleError"
if previousErr == nil {
panic(fmt.Sprintf("%s: previous error should not be nil", op))
}
err := ctx.Err()
if err == nil {
// we do not wrap the previous error
// as it should be wrapped by the caller.
return previousErr
}
// context has timed out
if strings.Contains(err.Error(), context.DeadlineExceeded.Error()) {
return xerror.Timeout(op, "context has timed out", previousErr)
}
/*
context has another error: we do not
wrap the error from the Context as the previous
error should contain it.
*/
return xerror.New(op, previousErr)
}

View File

@@ -0,0 +1,43 @@
package xcontext
import (
"errors"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/thecodingmachine/gotenberg/internal/pkg/xerror"
"github.com/thecodingmachine/gotenberg/internal/pkg/xtime"
"github.com/thecodingmachine/gotenberg/test/internalpkg/xerrortest"
"github.com/thecodingmachine/gotenberg/test/internalpkg/xlogtest"
)
func TestMustHandleError(t *testing.T) {
previousErr := errors.New("previous error")
logger := xlogtest.DebugLogger()
// context should not have an error.
ctx, cancel := WithTimeout(logger, 5)
defer cancel()
err := MustHandleError(ctx, previousErr)
assert.Equal(t, previousErr, err)
// should panic.
ctx, cancel = WithTimeout(logger, 5)
defer cancel()
assert.Panics(t, func() {
MustHandleError(ctx, nil)
})
// context should timed out.
ctx, cancel = WithTimeout(logger, 0.5)
defer cancel()
time.Sleep(xtime.Duration(1))
err = MustHandleError(ctx, previousErr)
xerr := xerrortest.AssertError(t, err)
assert.Equal(t, xerror.TimeoutCode, xerror.Code(xerr))
// context should have an error different
// than context.DeadlineExceeded.
ctx, cancel = WithTimeout(logger, 5)
cancel()
err = MustHandleError(ctx, previousErr)
xerr = xerrortest.AssertError(t, err)
assert.Equal(t, xerror.InternalCode, xerror.Code(xerr))
}

View File

@@ -0,0 +1,7 @@
/*
Package xerror helps standardizing
the errors through the application.
Credits: https://middlemost.com/failure-is-your-domain/
*/
package xerror

View File

@@ -0,0 +1,152 @@
package xerror
import (
"bytes"
"fmt"
"strings"
)
// ErrorCode is machine-readable error code.
type ErrorCode string
const (
// InternalCode is an internal error.
InternalCode ErrorCode = "internal"
// InvalidCode occurs when a validation
// failed.
InvalidCode ErrorCode = "invalid"
// TimeoutCode occurs when something
// timed out.
TimeoutCode ErrorCode = "timeout"
)
// Error defines our standard application
// error.
type Error struct {
code ErrorCode
message string
op string
err error
}
// Error returns the string representation of the error message.
func (e Error) Error() string {
var buf bytes.Buffer
// if wrapping an error, print its Error() message.
// Otherwise print the error code & message.
if e.err != nil {
buf.WriteString(e.err.Error())
} else {
if e.code != "" {
fmt.Fprintf(&buf, "<%s> ", e.code)
}
buf.WriteString(e.message)
}
return buf.String()
}
/*
New returns a xerror.Error.
Should be used for wrapping an error
at the end of a function.
*/
func New(op string, previous error) error {
return &Error{
op: op,
err: previous,
}
}
/*
Invalid returns a xerror.Error.
Should be used when an input
is wrong.
*/
func Invalid(op, message string, previous error) error {
return &Error{
code: InvalidCode,
message: message,
op: op,
err: previous,
}
}
/*
Timeout returns a xerror.Error.
Should be used when a timeout occurs.
*/
func Timeout(op, message string, previous error) error {
return &Error{
code: TimeoutCode,
message: message,
op: op,
err: previous,
}
}
// Code returns the code of the root error, if available.
// Otherwise returns InternalCode.
func Code(err error) ErrorCode {
if err == nil {
return ""
}
e, ok := err.(*Error)
if ok && e.code != "" {
return e.code
}
if ok && e.err != nil {
return Code(e.err)
}
return InternalCode
}
const defaultMessage string = "an internal error has occurred: please contact technical support"
// Message returns the human-readable message of the error, if available.
// Otherwise returns a generic error message.
func Message(err error) string {
if err == nil {
return ""
}
e, ok := err.(*Error)
if ok && e.message != "" {
return e.message
}
if ok && e.err != nil {
return Message(e.err)
}
return defaultMessage
}
// Op returns the logical operation of the error, if available.
// Otherwise returns an empty string.
func Op(err error) string {
if err == nil {
return ""
}
e, ok := err.(*Error)
if !ok {
return ""
}
var buf bytes.Buffer
nestedOp := Op(e.err)
if nestedOp != "" {
// we want to avoid having the same op chained.
if e.op != "" && !strings.Contains(nestedOp, e.op) {
fmt.Fprintf(&buf, "%s: %s", e.op, nestedOp)
} else {
fmt.Fprintf(&buf, "%s", nestedOp)
}
} else if e.op != "" {
fmt.Fprintf(&buf, "%s", e.op)
}
return buf.String()
}
// Compile-time checks to ensure type implements desired interfaces.
var (
_ = error(new(Error))
)

View File

@@ -0,0 +1,97 @@
package xerror
import (
"errors"
"fmt"
"testing"
"github.com/stretchr/testify/assert"
)
/*
Error 1.0: op = "foo"
Error 1.1: op = "bar"
Error 1.2: code = "invalid", op = "baz", message = "nested error"
Error 1.3: message = "root error"
*/
func scenario1() error {
rootErr := errors.New("root error")
nestedErr := Invalid("baz", "nested error", rootErr)
wrappingErr := New("bar", nestedErr)
return New("foo", wrappingErr)
}
/*
Error 2.0: op = "foo"
Error 2.1: op = "bar"
Error 2.2: code = "timeout", op = "bar", message = "nested error"
*/
func scenario2() error {
nestedErr := Timeout("bar", "nested error", nil)
wrappingErr := New("bar", nestedErr)
return New("foo", wrappingErr)
}
// Error 3.0: code = "", op = "foo"
func scenario3() error {
return New("foo", nil)
}
func TestError(t *testing.T) {
// should return the Error 1.3
// message.
err := scenario1()
assert.Equal(t, "root error", err.Error())
// should return the Error 2.2 message with
// its code.
err = scenario2()
assert.Equal(t, "<timeout> nested error", err.Error())
}
func TestCode(t *testing.T) {
// should be an empty code if no error.
assert.Equal(t, "", fmt.Sprintf("%s", Code(nil)))
// should be the code of Error 1.2.
err := scenario1()
assert.Equal(t, InvalidCode, Code(err))
// should be the code of Error 2.2.
err = scenario2()
assert.Equal(t, TimeoutCode, Code(err))
// should be the default code.
err = scenario3()
assert.Equal(t, InternalCode, Code(err))
err = errors.New("some error")
assert.Equal(t, InternalCode, Code(err))
}
func TestMessage(t *testing.T) {
// should be an empty message if no error.
assert.Equal(t, "", Message(nil))
// should be the message of Error 1.2.
err := scenario1()
assert.Equal(t, "nested error", Message(err))
// should be the default message.
err = errors.New("some error")
assert.Equal(t, defaultMessage, Message(err))
}
func TestOp(t *testing.T) {
// should be an empty op if no error.
assert.Equal(t, "", Op(nil))
// should be the chain of op in this order:
// Error 1.0 -> Error 1.1 -> Error 1.2.
err := scenario1()
assert.Equal(t, "foo: bar: baz", Op(err))
/*
should be the chain of op in this order:
Error 2.0 -> Error 2.1.
As Error 2.1 and Error 2.2 shares the same
op, Error 2.2 op is not displayed.
*/
err = scenario2()
assert.Equal(t, "foo: bar", Op(err))
// should be an empty op if not Error.
err = errors.New("some error")
assert.Equal(t, "", Op(err))
}

View File

@@ -0,0 +1,8 @@
/*
Package xexec helps creating exec.Cmd
with logging.
All functions return our standard xerror.Error
in case of error.
*/
package xexec

View File

@@ -0,0 +1,99 @@
package xexec
import (
"bufio"
"context"
"fmt"
"io"
"os/exec"
"strings"
"github.com/thecodingmachine/gotenberg/internal/pkg/xerror"
"github.com/thecodingmachine/gotenberg/internal/pkg/xlog"
)
/*
Command is a wrapper around exec.Command.
If given xlog.Logger has a xlog.DebugLevel,
also logs the output from the command.
*/
func Command(logger xlog.Logger, binary string, args ...string) (*exec.Cmd, error) {
const op string = "xexec.Command"
cmd := exec.Command(binary, args...)
if err := pipe(logger, cmd); err != nil {
return nil, xerror.New(op, err)
}
return cmd, nil
}
/*
CommandContext is a wrapper around exec.CommandContext.
If given xlog.Logger has a xlog.DebugLevel,
also logs the output from the command.
*/
func CommandContext(ctx context.Context, logger xlog.Logger, binary string, args ...string) (*exec.Cmd, error) {
const op string = "xexec.CommandContext"
cmd := exec.CommandContext(ctx, binary, args...)
if err := pipe(logger, cmd); err != nil {
return nil, xerror.New(op, err)
}
return cmd, nil
}
// LogBeforeExecute logs a command before its execution.
func LogBeforeExecute(logger xlog.Logger, cmd *exec.Cmd) {
const op string = "xexec.LogBeforeExecute"
logger.DebugfOp(op, "executing command: %s", strings.Join(cmd.Args, " "))
}
func pipe(logger xlog.Logger, cmd *exec.Cmd) error {
const op string = "xexec.pipe"
if logger.Level() != xlog.DebugLevel {
return nil
}
// if xlog.DebugLevel, log the output
// from the command.
resolver := func() error {
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
stderr, err := cmd.StderrPipe()
if err != nil {
return err
}
go logCommandOutput(logger, stdout, "stdout", cmd)
go logCommandOutput(logger, stderr, "stderr", cmd)
return nil
}
if err := resolver(); err != nil {
return xerror.New(op, err)
}
return nil
}
func logCommandOutput(logger xlog.Logger, reader io.ReadCloser, outputType string, cmd *exec.Cmd) {
var op string
if len(cmd.Args) >= 2 {
op = fmt.Sprintf("%s.%s.%s", cmd.Args[0], cmd.Args[1], outputType)
} else {
// len(cmd.Args) should always be >= 1.
op = fmt.Sprintf("%s.%s", cmd.Args[0], outputType)
}
r := bufio.NewReader(reader)
defer reader.Close() // nolint: errcheck
for {
line, _, err := r.ReadLine()
if err != nil {
if err != io.EOF {
logger.ErrorOp(op, err)
}
break
}
if len(line) != 0 {
logger.DebugOp(op, string(line))
}
}
}

View File

@@ -0,0 +1,39 @@
package xexec
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/thecodingmachine/gotenberg/test/internalpkg/xlogtest"
)
func TestCommand(t *testing.T) {
logger := xlogtest.DebugLogger()
// should pipe command output as
// xlog.Logger has a xlog.DebugLevel.
cmd, err := Command(logger, "echo", "Hello", "World")
assert.Nil(t, err)
LogBeforeExecute(logger, cmd)
// should not pipe command output as
// xlog.Logger has a xlog.InfoLevel.
logger = xlogtest.InfoLogger()
cmd, err = Command(logger, "echo", "Hello", "World")
LogBeforeExecute(logger, cmd)
assert.Nil(t, err)
}
func TestCommandContext(t *testing.T) {
logger := xlogtest.DebugLogger()
// should pipe command output as
// xlog.Logger has a xlog.DebugLevel.
cmd, err := CommandContext(context.Background(), logger, "echo")
assert.Nil(t, err)
LogBeforeExecute(logger, cmd)
// should not pipe command output as
// xlog.Logger has a xlog.InfoLevel.
logger = xlogtest.InfoLogger()
cmd, err = CommandContext(context.Background(), logger, "echo", "Hello", "World")
LogBeforeExecute(logger, cmd)
assert.Nil(t, err)
}

17
internal/pkg/xlog/doc.go Normal file
View File

@@ -0,0 +1,17 @@
/*
Package xlog defines a standard logger
for the application.
It uses structured logging thanks to
https://github.com/sirupsen/logrus.
All messages have at least two fields:
A "trace" field which helps to identify
messages belonging to the same context.
An "op" field which helps to identify
the logical operation associated
with the message.
*/
package xlog

141
internal/pkg/xlog/xlog.go Normal file
View File

@@ -0,0 +1,141 @@
package xlog
import (
"fmt"
"os"
"github.com/mattn/go-isatty"
"github.com/sirupsen/logrus"
)
// Level helps setting the severity
// of the messages displayed.
type Level string
const (
// DebugLevel is the lowest level.
DebugLevel Level = "DEBUG"
// InfoLevel is the intermediate level.
InfoLevel Level = "INFO"
// ErrorLevel is the highest level.
ErrorLevel Level = "ERROR"
)
// Logger enforces specific log message formats.
type Logger struct {
entry *logrus.Entry
level Level
}
// New returns a xlog.Logger.
func New(level Level, trace string) Logger {
l := logrus.New()
l.SetLevel(mustLogrusLevel(level))
if !isatty.IsTerminal(os.Stdout.Fd()) {
l.SetFormatter(&logrus.JSONFormatter{})
}
return Logger{
entry: l.WithField("trace", trace),
level: level,
}
}
func mustLogrusLevel(level Level) logrus.Level {
const op string = "xlog.mustLogrusLevel"
switch level {
case DebugLevel:
return logrus.DebugLevel
case InfoLevel:
return logrus.InfoLevel
case ErrorLevel:
return logrus.ErrorLevel
default:
panic(fmt.Sprintf("%s: '%s' is not associated with any logrus.Level", op, level))
}
}
// Levels returns a slice of string
// with all severities.
func Levels() []string {
return []string{
string(DebugLevel),
string(InfoLevel),
string(ErrorLevel),
}
}
/*
MustParseLevel returns the Level corresponding
to given string.
It panics if no correspondence.
*/
func MustParseLevel(level string) Level {
const op string = "xlog.MustParseLevel"
switch level {
case string(DebugLevel):
return DebugLevel
case string(InfoLevel):
return InfoLevel
case string(ErrorLevel):
return ErrorLevel
default:
panic(fmt.Sprintf("%s: '%s' is not one of '%v'", op, level, Levels()))
}
}
// Level returns the current Level.
func (l Logger) Level() Level {
return l.level
}
// WithFields returns a new xlog.Logger with
// given fields.
func (l Logger) WithFields(fields map[string]interface{}) Logger {
return Logger{
entry: l.entry.WithFields(fields),
level: l.level,
}
}
// DebugOp logs a debug message for given
// logical operation.
func (l Logger) DebugOp(op, message string) {
l.entry.WithField("op", op).Debug(message)
}
// DebugfOp logs a debug message for given
// logical operation and format.
func (l Logger) DebugfOp(op, format string, args ...interface{}) {
l.entry.WithField("op", op).Debugf(format, args...)
}
// InfoOp logs an info message for given
// logical operation.
func (l Logger) InfoOp(op, message string) {
l.entry.WithField("op", op).Info(message)
}
// InfofOp logs an info message for given
// logical operation and format.
func (l Logger) InfofOp(op, format string, args ...interface{}) {
l.entry.WithField("op", op).Infof(format, args...)
}
// ErrorOp logs an error for given
// logical operation.
func (l Logger) ErrorOp(op string, err error) {
l.entry.WithField("op", op).Error(err.Error())
}
// ErrorfOp logs an error message for given
// logical operation and format.
func (l Logger) ErrorfOp(op, format string, args ...interface{}) {
l.entry.WithField("op", op).Errorf(format, args...)
}
// FatalOp logs an error for given
// logical operation and exit 1.
func (l Logger) FatalOp(op string, err error) {
l.entry.WithField("op", op).Fatal(err.Error())
}

View File

@@ -0,0 +1,3 @@
// Package xrand helps generating
// random strings.
package xrand

View File

@@ -1,4 +1,4 @@
package random
package xrand
import (
"github.com/labstack/gommon/random"

View File

@@ -1,4 +1,4 @@
package random
package xrand
import (
"testing"

View File

@@ -0,0 +1,6 @@
/*
Package xtime helps generating
time.Duration from seconds represented
as float64.
*/
package xtime

View File

@@ -0,0 +1,10 @@
package xtime
import (
"time"
)
// Duration creates a time.Duration from seconds.
func Duration(seconds float64) time.Duration {
return time.Duration(1000*seconds) * time.Millisecond
}

View File

@@ -0,0 +1,14 @@
package xtime
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestDuration(t *testing.T) {
expected := time.Duration(1500) * time.Millisecond
result := Duration(1.5)
assert.Equal(t, expected.String(), result.String())
}