new feature: users are now able to provide more file types to convert by adding entries in their configuration file (#3)

* new feature: users are now able to provide more file types to convert by adding entries in their configuration file

* removing README blueprint: was not working correctly

* improving code coverage of config package
This commit is contained in:
Julien Neuhart
2018-05-03 17:18:55 +02:00
committed by GitHub
parent ef9801b1fd
commit 4997b73bac
31 changed files with 770 additions and 692 deletions

View File

@@ -7,139 +7,50 @@ It should be located where the user starts the application from the CLI.
package config
import (
"io/ioutil"
"fmt"
"text/template"
"github.com/sirupsen/logrus"
"gopkg.in/yaml.v2"
)
type (
// AppConfig gathers all data required to instantiate the application.
AppConfig struct {
// Port is the port which the application will listen to.
Port string
// Logs contains the logging configuration.
Logs struct {
// Level is the level of messages which will be logged.
Level logrus.Level
// Formatter defines the logging format when a TTY is not attached.
Formatter logrus.Formatter
}
// CommandsConfig is... an instance of CommandsConfig.
CommandsConfig *CommandsConfig
// appConfig gathers all configuration data.
appConfig struct {
port string
logsLevel logrus.Level
logsFormatter logrus.Formatter
// commands associates a file extension with a Command instance.
// Particular case: ".pdf" extension is used for the merge command.
commands map[string]*Command
}
// CommandsConfig gathers all commands' configurations as defined
// by the user in the gotenberg.yml file.
CommandsConfig struct {
// Markdown is the command's configuration for converting
// an Markdown file to PDF.
Markdown *CommandConfig
// HTML is the command's configuration for converting
// an HTML file to PDF.
HTML *CommandConfig
// Office is the command's configuration for converting
// an Office document to PDF.
Office *CommandConfig
// Merge is the command's configuration for merging
// multiple PDF files into one PDF file.
Merge *CommandConfig
}
// CommandConfig is a command's configuration.
CommandConfig struct {
// Command gathers information on how to launch an external binary used for converting
// a file to PDF.
Command struct {
// Template is the data-driven template of the command.
Template *template.Template
// Timeout is the duration in seconds after which the command's process will be killed
// if it does not finish before.
Timeout int
// Template is the data-driven template of the command.
Template *template.Template
}
)
// NewAppConfig instantiates the application's configuration.
// If something bad happens here, the application should not start.
func NewAppConfig(configurationFilePath string) (*AppConfig, error) {
fileConfig, err := loadFileConfig(configurationFilePath)
if err != nil {
return nil, err
}
// our default instance of appConfig.
var config = &appConfig{}
c := &AppConfig{}
c.Port = fileConfig.Port
if err := makeLogs(c, fileConfig); err != nil {
return nil, err
}
if err := makeCommandsConfig(c, fileConfig); err != nil {
return nil, err
}
return c, nil
// Reset reinitializes our configuration.
func Reset() {
config = &appConfig{}
}
// fileConfig gathers all data coming from the configuration file gotenberg.yml.
type fileConfig struct {
Port string `yaml:"port"`
Logs struct {
Level string `yaml:"level"`
Format string `yaml:"format"`
} `yaml:"logs"`
Commands struct {
Markdown struct {
Timeout int `yaml:"timeout"`
Template string `yaml:"template"`
} `yaml:"markdown"`
HTML struct {
Timeout int
Template string
} `yaml:"html"`
Office struct {
Timeout int `yaml:"timeout"`
Template string `yaml:"template"`
} `yaml:"office"`
Merge struct {
Timeout int `yaml:"timeout"`
Template string `yaml:"template"`
} `yaml:"merge"`
} `yaml:"commands"`
// WithPort sets the port which will be used by the application.
func WithPort(port string) {
config.port = port
}
// loadFileConfig instantiates a fileConfig instance by loading
// the configuration file gotenberg.yml.
func loadFileConfig(configurationFilePath string) (*fileConfig, error) {
c := &fileConfig{}
data, err := ioutil.ReadFile(configurationFilePath)
if err != nil {
return nil, err
}
if err := yaml.Unmarshal(data, &c); err != nil {
return nil, err
}
return c, nil
}
// makeLogs is a simple wrapper which populates all data related
// to application's logging.
func makeLogs(appConfig *AppConfig, fileConfig *fileConfig) error {
lvl, err := getLoggingLevelFromFileConfig(fileConfig)
if err != nil {
return err
}
formatter, err := getLoggingFormatterFromFileConfig(fileConfig)
if err != nil {
return err
}
appConfig.Logs.Level = lvl
appConfig.Logs.Formatter = formatter
return nil
// GetPort returns the current port.
func GetPort() string {
return config.port
}
// levels associates logging levels as defined in the configuration file gotenberg.yml
@@ -153,102 +64,122 @@ var levels = map[string]logrus.Level{
"PANIC": logrus.PanicLevel,
}
type wrongLoggingLevelError struct{}
type wrongLogsLevelError struct{}
const wrongLoggingLevelErrorMessage = "Accepted values for logging level: DEBUG, INFO, WARN, ERROR, FATAL, PANIC"
const wrongLogsLevelErrorMessage = "accepted values for logs level: DEBUG, INFO, WARN, ERROR, FATAL, PANIC"
func (e *wrongLoggingLevelError) Error() string {
return wrongLoggingLevelErrorMessage
func (e *wrongLogsLevelError) Error() string {
return wrongLogsLevelErrorMessage
}
// getLoggingLevelFromFileConfig returns a logrus level if a matching was found
// with the one defined by the user.
// If no match, throws an error.
func getLoggingLevelFromFileConfig(c *fileConfig) (logrus.Level, error) {
l, ok := levels[c.Logs.Level]
// WithLogsLevel sets the logs level.
// If the given string does not match with a logrus level,
// throws an error.
func WithLogsLevel(level string) error {
l, ok := levels[level]
if !ok {
return 999, &wrongLoggingLevelError{}
return &wrongLogsLevelError{}
}
return l, nil
config.logsLevel = l
return nil
}
// levels associates logging formats as defined in the configuration file gotenberg.yml
// GetLogsLevel returns the current logs level.
func GetLogsLevel() logrus.Level {
return config.logsLevel
}
// formatters associates logging formatter as defined in the configuration file gotenberg.yml
// with its counterpart from the logrus library.
var formatters = map[string]logrus.Formatter{
"text": &logrus.TextFormatter{},
"json": &logrus.JSONFormatter{},
}
type wrongLoggingFormatError struct{}
type wrongLogsFormatterError struct{}
const wrongLoggingFormatErrorMessage = "Accepted value for logging format: text, json"
const wrongLogsFormatterErrorMessage = "accepted value for logs formatter: text, json"
func (e *wrongLoggingFormatError) Error() string {
return wrongLoggingFormatErrorMessage
func (e *wrongLogsFormatterError) Error() string {
return wrongLogsFormatterErrorMessage
}
// getLoggingLevelFromFileConfig returns a logrus Formatter if a matching was found
// with the format defined by the user.
// If no match, throws an error.
func getLoggingFormatterFromFileConfig(c *fileConfig) (logrus.Formatter, error) {
f, ok := formatters[c.Logs.Format]
// WithLogsFormatter sets the logs formatter.
// If the given string does not match with a logrus formatter,
// throws an error.
func WithLogsFormatter(formatter string) error {
f, ok := formatters[formatter]
if !ok {
return nil, &wrongLoggingFormatError{}
return &wrongLogsFormatterError{}
}
return f, nil
}
// makeCommandsConfigs is a simple wrapper which populates all data related
// to commands' configurations.
func makeCommandsConfig(appConfig *AppConfig, fileConfig *fileConfig) error {
appConfig.CommandsConfig = &CommandsConfig{}
appConfig.CommandsConfig.Markdown = &CommandConfig{}
appConfig.CommandsConfig.HTML = &CommandConfig{}
appConfig.CommandsConfig.Office = &CommandConfig{}
appConfig.CommandsConfig.Merge = &CommandConfig{}
appConfig.CommandsConfig.Markdown.Timeout = fileConfig.Commands.Markdown.Timeout
appConfig.CommandsConfig.HTML.Timeout = fileConfig.Commands.HTML.Timeout
appConfig.CommandsConfig.Office.Timeout = fileConfig.Commands.Office.Timeout
appConfig.CommandsConfig.Merge.Timeout = fileConfig.Commands.Merge.Timeout
tmplMarkdown, err := getCommandTemplate(fileConfig.Commands.Markdown.Template, "Markdown")
if err != nil {
return err
}
tmplHTML, err := getCommandTemplate(fileConfig.Commands.HTML.Template, "HTML")
if err != nil {
return err
}
tmplOffice, err := getCommandTemplate(fileConfig.Commands.Office.Template, "Office")
if err != nil {
return err
}
tmplMerge, err := getCommandTemplate(fileConfig.Commands.Merge.Template, "Merge")
if err != nil {
return err
}
appConfig.CommandsConfig.Markdown.Template = tmplMarkdown
appConfig.CommandsConfig.HTML.Template = tmplHTML
appConfig.CommandsConfig.Office.Template = tmplOffice
appConfig.CommandsConfig.Merge.Template = tmplMerge
config.logsFormatter = f
return nil
}
// getCommandTemplate is a simple helper for parsing a command template as defined by the user.
// If the user gives us a wrong template, throws an error.
func getCommandTemplate(command string, commandName string) (*template.Template, error) {
t, err := template.New(commandName).Parse(command)
// GetLogsFormatter returns the current logs formatter.
func GetLogsFormatter() logrus.Formatter {
return config.logsFormatter
}
// NewCommand instantiates a Command. If the given command string
// is not a valid template, throws an error.
func NewCommand(command string, timeout int) (*Command, error) {
t, err := template.New(command).Parse(command)
if err != nil {
return nil, err
}
return t, nil
return &Command{t, timeout}, nil
}
type fileExtensionAlreadyUsedError struct {
extension string
command *Command
existingCommand *Command
}
const fileExtensionAlreadyUsedErrorMessage = "file extension '%s' from command '%s' is already used by command '%s'"
func (e *fileExtensionAlreadyUsedError) Error() string {
return fmt.Sprintf(fileExtensionAlreadyUsedErrorMessage, e.extension, e.command.Template.Name(), e.existingCommand.Template.Name())
}
// WithCommand adds a Command instance and associates it with the given
// file extension. If the file extension is already used by another Command
// instance, throws an error.
func WithCommand(extension string, command *Command) error {
if config.commands == nil {
config.commands = make(map[string]*Command)
}
existingCommand, ok := config.commands[extension]
if ok {
return &fileExtensionAlreadyUsedError{extension, command, existingCommand}
}
config.commands[extension] = command
return nil
}
type noCommandFoundForFileExtensionError struct {
extension string
}
const noCommandFoundForFileExtensionErrorMessage = "no command found for file extension '%s'"
func (e *noCommandFoundForFileExtensionError) Error() string {
return fmt.Sprintf(noCommandFoundForFileExtensionErrorMessage, e.extension)
}
// GetCommand returns the Command instance associated with the given
// file extension. If no Command instance found, throws an error.
func GetCommand(extension string) (*Command, error) {
c, ok := config.commands[extension]
if !ok {
return nil, &noCommandFoundForFileExtensionError{extension}
}
return c, nil
}

View File

@@ -1,77 +1,171 @@
package config
import (
"path/filepath"
"fmt"
"testing"
"github.com/sirupsen/logrus"
)
func TestNewAppConfig(t *testing.T) {
var path string
func TestReset(t *testing.T) {
c := &appConfig{}
config.port = "3000"
Reset()
// case 1: uses an empty configuration file path.
if _, err := NewAppConfig(""); err == nil {
t.Error("AppConfig should not have been instantiated by using an empty configuration file path")
}
// case 2: uses a broken configuration file.
path, _ = filepath.Abs("../../_tests/configurations/broken-gotenberg.yml")
if _, err := NewAppConfig(path); err == nil {
t.Errorf("AppConfig should not have been instantiated with '%s'", path)
}
// case 3: uses a configuration file with a wrong logging level.
path, _ = filepath.Abs("../../_tests/configurations/wrong-logging-level-gotenberg.yml")
if _, err := NewAppConfig(path); err == nil {
t.Errorf("AppConfig should not have been instantiated with '%s'", path)
}
// case 4: uses a configuration file with a wrong logging format.
path, _ = filepath.Abs("../../_tests/configurations/wrong-logging-format-gotenberg.yml")
if _, err := NewAppConfig(path); err == nil {
t.Errorf("AppConfig should not have been instantiated with '%s'", path)
}
// case 5: uses a configuration file with a wrong markdown command template.
path, _ = filepath.Abs("../../_tests/configurations/wrong-markdown-command-template-gotenberg.yml")
if _, err := NewAppConfig(path); err == nil {
t.Errorf("AppConfig should not have been instantiated with '%s'", path)
}
// case 6: uses a configuration file with a wrong HTML command template.
path, _ = filepath.Abs("../../_tests/configurations/wrong-html-command-template-gotenberg.yml")
if _, err := NewAppConfig(path); err == nil {
t.Errorf("AppConfig should not have been instantiated with '%s'", path)
}
// case 7: uses a configuration file with a wrong Office command template.
path, _ = filepath.Abs("../../_tests/configurations/wrong-office-command-template-gotenberg.yml")
if _, err := NewAppConfig(path); err == nil {
t.Errorf("AppConfig should not have been instantiated with '%s'", path)
}
// case 8: uses a configuration file with a wrong merge command template.
path, _ = filepath.Abs("../../_tests/configurations/wrong-merge-command-template-gotenberg.yml")
if _, err := NewAppConfig(path); err == nil {
t.Errorf("AppConfig should not have been instantiated with '%s'", path)
}
// case 9: uses a correct configuration file.
path, _ = filepath.Abs("../../_tests/configurations/gotenberg.yml")
if _, err := NewAppConfig(path); err != nil {
t.Errorf("AppConfig should have been instantiated with '%s'", path)
if c.port != config.port {
t.Error("Configuration should have been reset")
}
}
func TestWrongLoggingLevelError(t *testing.T) {
err := &wrongLoggingLevelError{}
if err.Error() != wrongLoggingLevelErrorMessage {
t.Errorf("Error returned a wrong message: got '%s' want '%s'", err.Error(), wrongLoggingLevelErrorMessage)
func TestWithPort(t *testing.T) {
port := "3000"
WithPort(port)
if config.port != port {
t.Errorf("Configuration populated with a wrong port: got '%s' want '%s'", config.port, port)
}
}
func TestWrongLoggingFormatError(t *testing.T) {
err := &wrongLoggingFormatError{}
if err.Error() != wrongLoggingFormatErrorMessage {
t.Errorf("Error returned a wrong message: got '%s' want '%s'", err.Error(), wrongLoggingFormatErrorMessage)
func TestGetPort(t *testing.T) {
port := "3000"
config.port = port
if GetPort() != port {
t.Errorf("Configuration returned a wrong port: got '%s' want '%s'", GetPort(), port)
}
}
func TestWrongLogsLevelError(t *testing.T) {
err := &wrongLogsLevelError{}
if err.Error() != wrongLogsLevelErrorMessage {
t.Errorf("Error returned a wrong message: got '%s' want '%s'", err.Error(), wrongLogsLevelErrorMessage)
}
}
func TestWithLogsLevel(t *testing.T) {
var lvl string
// case 1: uses a wrong logs level.
lvl = "text"
if err := WithLogsLevel(lvl); err == nil {
t.Errorf("Configuration should not have been populated by using '%s' as logs level", lvl)
}
// case 2: uses a correct logs level.
lvl = "DEBUG"
if err := WithLogsLevel(lvl); err != nil {
t.Errorf("Configuration should have been populated by using '%s' as logs level", lvl)
}
}
func TestGetLogsLevel(t *testing.T) {
lvl := logrus.DebugLevel
config.logsLevel = lvl
if GetLogsLevel() != lvl {
t.Errorf("Configuration returned a wrong logs level: got '%s' want '%s'", GetLogsLevel(), lvl)
}
}
func TestWrongLogsFormatterError(t *testing.T) {
err := &wrongLogsFormatterError{}
if err.Error() != wrongLogsFormatterErrorMessage {
t.Errorf("Error returned a wrong message: got '%s' want '%s'", err.Error(), wrongLogsFormatterErrorMessage)
}
}
func TestWithLogsFormatter(t *testing.T) {
var formatter string
// case 1: uses a wrong logs formatter.
formatter = "DEBUG"
if err := WithLogsFormatter(formatter); err == nil {
t.Errorf("Configuration should not have been populated by using '%s' as logs formatter", formatter)
}
// case 2: uses a correct logs formatter.
formatter = "text"
if err := WithLogsFormatter(formatter); err != nil {
t.Errorf("Configuration should have been populated by using '%s' as logs formatter", formatter)
}
}
func TestGetLogsFormatter(t *testing.T) {
formatter := &logrus.TextFormatter{}
config.logsFormatter = formatter
if GetLogsFormatter() != formatter {
t.Errorf("Configuration returned a wrong logs formatter: got '%v' want '%v'", GetLogsFormatter(), formatter)
}
}
func TestNewCommand(t *testing.T) {
var cmd string
// case 1: uses a wrong command template.
cmd = "pdftk {{ range $filePath := FilesPaths }} {{ $filePath }} {{ end }} cat output {{ .ResultFilePath }}"
if _, err := NewCommand(cmd, 0); err == nil {
t.Errorf("Command should not have been instantiated by using '%s' as command template", cmd)
}
// case 2: uses a correct command template.
cmd = "pdftk {{ range $filePath := .FilesPaths }} {{ $filePath }} {{ end }} cat output {{ .ResultFilePath }}"
if _, err := NewCommand(cmd, 0); err != nil {
t.Errorf("Command should have been instantiated by using '%s' as command template", cmd)
}
}
func TestFileExtensionAlreadyUsedError(t *testing.T) {
ext := ".pdf"
cmd1, _ := NewCommand("echo", 0)
cmd2, _ := NewCommand("echo", 0)
expected := fmt.Sprintf(fileExtensionAlreadyUsedErrorMessage, ext, cmd1.Template.Name(), cmd2.Template.Name())
err := &fileExtensionAlreadyUsedError{ext, cmd1, cmd2}
if err.Error() != expected {
t.Errorf("Error returned a wrong message: got '%s' want '%s'", err.Error(), expected)
}
}
func TestWithCommand(t *testing.T) {
ext := ".pdf"
cmd, _ := NewCommand("echo", 0)
// case 1: uses a command with a file extension not already referenced.
if err := WithCommand(ext, cmd); err != nil {
t.Errorf("Configuration should have been populated by using a command with the file extension '%s'", ext)
}
// case 2: uses a command with a file extension already referenced.
if err := WithCommand(ext, cmd); err == nil {
t.Errorf("Configuration should not have been populated by using a command with the file extension '%s'", ext)
}
}
func TestNoCommandFoundForFileExtensionError(t *testing.T) {
ext := ".pdf"
expected := fmt.Sprintf(noCommandFoundForFileExtensionErrorMessage, ext)
err := &noCommandFoundForFileExtensionError{ext}
if err.Error() != expected {
t.Errorf("Error returned a wrong message: got '%s' want '%s'", err.Error(), expected)
}
}
func TestGetCommand(t *testing.T) {
Reset()
ext := ".pdf"
cmd, _ := NewCommand("echo", 0)
WithCommand(ext, cmd)
// case 1: uses a file extension which has a command associated.
if _, err := GetCommand(ext); err != nil {
t.Errorf("Configuration should have been able to return a command by using the file extension '%s'", ext)
}
// case 2: uses a file extension which has no command associated.
ext = ".docx"
if _, err := GetCommand(ext); err == nil {
t.Errorf("Configuration should not have been able to return a command by using the file extension '%s'", ext)
}
}

94
app/config/parser.go Normal file
View File

@@ -0,0 +1,94 @@
package config
import (
"io/ioutil"
"gopkg.in/yaml.v2"
)
// ParseFile instantiates the application's configuration using the given YAML file.
func ParseFile(configurationFilePath string) error {
fileConfig, err := readFile(configurationFilePath)
if err != nil {
return err
}
WithPort(fileConfig.Port)
if err := WithLogsLevel(fileConfig.Logs.Level); err != nil {
return err
}
if err := WithLogsFormatter(fileConfig.Logs.Formatter); err != nil {
return err
}
// handles merge command first...
cmd, err := NewCommand(fileConfig.Commands.Merge.Template, fileConfig.Commands.Merge.Timeout)
if err != nil {
return err
}
WithCommand(".pdf", cmd)
// ...then conversion commands!
for _, command := range fileConfig.Commands.Conversions {
cmd, err := NewCommand(command.Template, command.Timeout)
if err != nil {
return err
}
for _, ext := range command.Extensions {
if err := WithCommand(ext, cmd); err != nil {
return err
}
}
}
return nil
}
type (
// fileConfig gathers all data coming from the configuration file gotenberg.yml.
fileConfig struct {
Port string `yaml:"port"`
Logs struct {
Level string `yaml:"level"`
Formatter string `yaml:"formatter"`
} `yaml:"logs"`
Commands struct {
Merge *mergeCommand `yaml:"merge"`
Conversions []*conversionCommand `yaml:"conversions,omitempty"`
} `yaml:"commands"`
}
// mergeCommand gathers all data regarding the... merge command.
mergeCommand struct {
Template string `yaml:"template"`
Timeout int `yaml:"timeout"`
}
// conversionCommand gathers all data regarding a conversion command.
conversionCommand struct {
Template string `yaml:"template"`
Timeout int `yaml:"timeout"`
Extensions []string `yaml:"extensions"`
}
)
// readFile instantiates a fileConfig instance by reading
// the given YAML file.
func readFile(configurationFilePath string) (*fileConfig, error) {
c := &fileConfig{}
data, err := ioutil.ReadFile(configurationFilePath)
if err != nil {
return nil, err
}
if err := yaml.Unmarshal(data, &c); err != nil {
return nil, err
}
return c, nil
}

62
app/config/parser_test.go Normal file
View File

@@ -0,0 +1,62 @@
package config
import (
"path/filepath"
"testing"
)
func load(configurationFilePath string) error {
Reset()
return ParseFile(configurationFilePath)
}
func TestParseFile(t *testing.T) {
var path string
// case 1: uses an empty configuration file path.
if err := load(""); err == nil {
t.Error("Configuration should not have been populated by using an empty configuration file path")
}
// case 2: uses a broken configuration file.
path, _ = filepath.Abs("../../_tests/configurations/broken-gotenberg.yml")
if err := load(path); err == nil {
t.Errorf("Configuration should not have been populated with '%s'", path)
}
// case 3: uses a configuration file with a wrong logging level.
path, _ = filepath.Abs("../../_tests/configurations/wrong-logging-level-gotenberg.yml")
if err := load(path); err == nil {
t.Errorf("Configuration should not have been populated with '%s'", path)
}
// case 4: uses a configuration file with a wrong logging formatter.
path, _ = filepath.Abs("../../_tests/configurations/wrong-logging-formatter-gotenberg.yml")
if err := load(path); err == nil {
t.Errorf("Configuration should not have been populated with '%s'", path)
}
// case 5: uses a configuration file with a wrong merge command template.
path, _ = filepath.Abs("../../_tests/configurations/wrong-merge-command-template-gotenberg.yml")
if err := load(path); err == nil {
t.Errorf("Configuration should not have been populated with '%s'", path)
}
// case 6: uses a configuration file with a wrong command template.
path, _ = filepath.Abs("../../_tests/configurations/wrong-command-template-gotenberg.yml")
if err := load(path); err == nil {
t.Errorf("Configuration should not have been populated with '%s'", path)
}
// case 7: uses a configuration file with a duplicate command.
path, _ = filepath.Abs("../../_tests/configurations/duplicate-command-gotenberg.yml")
if err := load(path); err == nil {
t.Errorf("Configuration should not have been populated with '%s'", path)
}
// case 8: uses a correct configuration file.
path, _ = filepath.Abs("../../_tests/configurations/gotenberg.yml")
if err := load(path); err != nil {
t.Errorf("Configuration should have been populated with '%s'", path)
}
}