Compare commits

..

10 Commits
1.0.0 ... 2.0.0

Author SHA1 Message Date
Julien Neuhart
c1f6100382 updating documentation for 2.0.0 (#9)
* updating documentation for 2.0.0

* improving documentation
2018-05-16 14:21:32 +02:00
Julien Neuhart
223c0de35a adding goreleaser 2018-05-16 13:51:51 +02:00
Julien Neuhart
e3366d0b62 adding new attribute lock (#8) 2018-05-16 12:06:30 +02:00
Julien Neuhart
5100693789 new attribute interpreter for commands (#7) 2018-05-16 10:55:08 +02:00
Julien Neuhart
faec55dd01 slighty improvement of logging (#6) 2018-05-16 10:02:07 +02:00
Julien Neuhart
2871fb1ffe improving logging (#5)
* small refactoring of error messages

* (very) small refactoring of structs instantiation

* improving logging with debug messages

* using INFO level as default + changing previous debug message to info message
2018-05-04 17:43:19 +02:00
Julien Neuhart
810d35ffbe fixing typo in image name (again) 2018-05-03 17:26:47 +02:00
Julien Neuhart
4997b73bac 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
2018-05-03 17:18:55 +02:00
Julien Neuhart
ef9801b1fd typo in image name... 2018-04-30 10:53:22 +02:00
Julien Neuhart
1f2317dc74 master branch for contributions 2018-04-23 13:20:54 +02:00
54 changed files with 2406 additions and 774 deletions

View File

@@ -1,136 +0,0 @@
<p align="center">
<img src="https://user-images.githubusercontent.com/8983173/38133342-11df3bd8-340f-11e8-9fe4-50baecdceeca.png" alt="Gotenberg's logo" width="250" height="250" />
</p>
<h3 align="center">Gotenberg</h3>
<p align="center">A stateless API for converting Markdown files, HTML files and Office documents to PDF</p>
<p align="center">
<a href="https://microbadger.com/images/thecodingmachine/gotenberg:{{ .Orbit.Latest }}">
<img src="https://images.microbadger.com/badges/image/thecodingmachine/gotenberg:{{ .Orbit.Latest }}.svg" alt="MicroBadger layers">
</a>
<a href="https://travis-ci.org/thecodingmachine/gotenberg">
<img src="https://travis-ci.org/thecodingmachine/gotenberg.svg?branch={{ .Orbit.Branch }}" alt="Travis CI">
</a>
<a href="https://godoc.org/github.com/thecodingmachine/gotenberg">
<img src="https://godoc.org/github.com/thecodingmachine/gotenberg?status.svg" alt="GoDoc">
</a>
<a href="https://goreportcard.com/report/thecodingmachine/gotenberg">
<img src="https://goreportcard.com/badge/github.com/thecodingmachine/gotenberg" alt="Go Report Card">
</a>
<a href="https://codecov.io/gh/thecodingmachine/gotenberg/branch/{{ .Orbit.Branch }}">
<img src="https://codecov.io/gh/thecodingmachine/gotenberg/branch/{{ .Orbit.Branch }}/graph/badge.svg" alt="Codecov">
</a>
</p>
---
At TheCodingMachine, we build a lot of web applications (intranets, extranets and so on) which require to generate PDF from
various sources. Each time, we ended up using some well known libraries like **wkhtmltopdf** or **unoconv** and kind of lost time by
reimplementing a solution from a project to another project. Meh.
# Menu
* [Usage](#usage)
* [Security](#security)
* [Scalability](#scalability)
* [Custom implementation](#custom-implementation)
* [Clients](#clients)
## Usage
Let's say you're starting the API using this simple command:
```sh
$ docker run --rm -p 3000:3000 thecodingmachine/gotenberg:{{ .Orbit.Latest }}
```
The API is now available on your host under `http://127.0.0.1:3000`.
It accepts `POST` requests with a `multipart/form-data` Content-Type. Your form data should provide one or more files to convert.
It currently accepts the following:
* Markdown files
* HTML files
* Office documents (.docx, .doc, .odt, .pptx, .ppt, .odp and so on)
* PDF files (if more than one file to convert)
**Heads up:** the API relies on the file extension to determine which library to use for conversion.
There are two use cases:
* If you send one file, it will convert it and return the resulting PDF
* If many files, it will convert them to PDF, merge the resulting PDFs into a single PDF and return it
### Examples:
* One file
```sh
$ curl --request POST \
--url http://127.0.0.1:3000 \
--header 'Content-Type: multipart/form-data' \
--form files=@file.docx \
> result.pdf
```
* Many files
```sh
$ curl --request POST \
--url http://127.0.0.1:3000 \
--header 'Content-Type: multipart/form-data' \
--form files=@file.md \
--form files=@file.html \
--form files=@file.pdf \
--form files=@file.docx \
> result.pdf
```
## Security
The API does not provide any authentication mechanisms. Make sure to not put it on a public facing port and your client(s) should always
controls what is sent to the API.
## Scalability
Some libraries like **unoconv** cannot perform concurrent conversions. That's why the API does only one conversion at a time.
If your API is under heavy load, a request will take time to be processed.
Fortunately, you may pass through this limitation by scaling the API.
In the following example, I'll demonstrate how to do some vertical scaling (= on the same machine) with Docker Compose, but of course horizontal scaling works too!
```yaml
version: '3'
services:
# your others services
gotenberg:
image: gotenberg:1.0.0
```
You may now launch your services using:
```bash
docker-compose up --scale gotenberg=your_number_of_instances
```
When requesting the Gotenberg service with your client(s), Docker will automatically redirect a request to a Gotenberg container
according to the round-robin strategy.
## Custom implementation
The API relies on a simple YAML configuration file called `gotenberg.yml`. It allows you to tweak some values and even provides you
a way to change the commands called for each kind of conversion. The configuration file should be located under `/gotenberg` in your container.
The default configuration is located here: [.ci/gotenberg.yml](.ci/gotenberg.yml)
## Clients
* https://github.com/thecodingmachine/gotenberg-php-client (PHP client)
* Add your own client by submitting a [pull request](../../pulls)!
---
Would you like to update this documentation ? Feel free to open an [issue](../../issues).

View File

@@ -8,26 +8,53 @@ logs:
# Accepted values: text, json.
# When a TTY is not attached, the output will be in the defined format.
format: "text"
formatter: "text"
# You don't like a library which is used for a conversion? You may provide here your own implementation.
# You don't like a library which is used for a conversion? You want to handle a new file type?
# You may provide here your own implementation!
commands:
markdown:
# Duration in seconds after which the command will be killed if it has not finished.
timeout: 30
# The command template: you have access to FilePath and ResultFilePath variables.
template: "markdown-pdf {{ .FilePath }} -o {{ .ResultFilePath }}"
html:
timeout: 30
template: "xvfb-run -e /dev/stdout wkhtmltopdf {{ .FilePath }} {{ .ResultFilePath }}"
office:
timeout: 30
template: "unoconv --format pdf --output \"{{ .ResultFilePath }}\" \"{{ .FilePath }}\""
# Some libraries like unoconv cannot perform concurrent conversions. That's why the API does only one conversion at a time.
# If your current implementation uses libraries which are able to perform concurrent conversions, you may
# change this value to false.
lock: true
# Unlike others commands' templates, you have access to FilesPaths instead of FilePath: it gathers all PDF files which should be merged.
merge:
template: "pdftk {{ range $filePath := .FilesPaths }} {{ $filePath }} {{ end }} cat output {{ .ResultFilePath }}"
interpreter: "/bin/sh -c"
timeout: 30
# Unlike others commands' templates, you have access to FilesPaths instead of FilePath: it gathers all PDF files which should be merged.
template: "pdftk {{ range $filePath := .FilesPaths }} {{ $filePath }} {{ end }} cat output {{ .ResultFilePath }}"
# You may add more commands (or less, or even none).
conversions:
# The command template: you have access to FilePath and ResultFilePath variables.
- template: "markdown-pdf {{ .FilePath }} -o {{ .ResultFilePath }}"
# The binary which will call the command.
interpreter: "/bin/sh -c"
# Duration in seconds after which the command will be killed if it has not finished.
timeout: 30
# Files with the following extensions will be converted by the current command.
extensions:
- ".md"
- template: "xvfb-run -e /dev/stdout wkhtmltopdf {{ .FilePath }} {{ .ResultFilePath }}"
interpreter: "/bin/sh -c"
timeout: 30
extensions:
- ".html"
- ".htm"
- template: "unoconv --format pdf --output \"{{ .ResultFilePath }}\" \"{{ .FilePath }}\""
interpreter: "/bin/sh -c"
timeout: 30
extensions:
- ".doc"
- ".docx"
- ".odt"
- ".xls"
- ".xlsx"
- ".ods"
- ".ppt"
- ".pptx"
- ".odp"

28
.goreleaser.yml Normal file
View File

@@ -0,0 +1,28 @@
---
project_name: Gotenberg
builds:
- main: ./main.go
binary: gotenberg
env:
- CGO_ENABLED=0
goos:
- linux
- darwin
- windows
goarch:
- 386
- amd64
- arm
- arm64
archive:
name_template: '{{ .Binary }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}'
replacements:
darwin: Darwin
linux: Linux
windows: Windows
386: i386
amd64: x86_64
format: tar.gz
format_overrides:
- goos: windows
format: zip

8
Gopkg.lock generated
View File

@@ -1,6 +1,12 @@
# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'.
[[projects]]
branch = "master"
name = "github.com/dustin/go-humanize"
packages = ["."]
revision = "02af3965c54e8cacf948b97fef38925c4120652c"
[[projects]]
name = "github.com/gorilla/context"
packages = ["."]
@@ -55,6 +61,6 @@
[solve-meta]
analyzer-name = "dep"
analyzer-version = 1
inputs-digest = "9d7a607d9b015e46c535126cbc62648ce78459507cd523d6ff0e048051cf3228"
inputs-digest = "97e0dbb20631792bc25ad19e6a85a90a9d24bb03f773cd36dee11eebae152091"
solver-name = "gps-cdcl"
solver-version = 1

View File

@@ -48,3 +48,7 @@
[[constraint]]
name = "github.com/sirupsen/logrus"
version = "1.0.5"
[[constraint]]
branch = "master"
name = "github.com/dustin/go-humanize"

View File

@@ -4,11 +4,11 @@
<h3 align="center">Gotenberg</h3>
<p align="center">A stateless API for converting Markdown files, HTML files and Office documents to PDF</p>
<p align="center">
<a href="https://microbadger.com/images/thecodingmachine/gotenberg:1.0.0">
<img src="https://images.microbadger.com/badges/image/thecodingmachine/gotenberg:1.0.0.svg" alt="MicroBadger layers">
<a href="https://microbadger.com/images/thecodingmachine/gotenberg:2.0.0">
<img src="https://images.microbadger.com/badges/image/thecodingmachine/gotenberg:2.0.0.svg" alt="MicroBadger layers">
</a>
<a href="https://travis-ci.org/thecodingmachine/gotenberg">
<img src="https://travis-ci.org/thecodingmachine/gotenberg.svg?branch=1.0.0" alt="Travis CI">
<img src="https://travis-ci.org/thecodingmachine/gotenberg.svg?branch=master" alt="Travis CI">
</a>
<a href="https://godoc.org/github.com/thecodingmachine/gotenberg">
<img src="https://godoc.org/github.com/thecodingmachine/gotenberg?status.svg" alt="GoDoc">
@@ -16,8 +16,8 @@
<a href="https://goreportcard.com/report/thecodingmachine/gotenberg">
<img src="https://goreportcard.com/badge/github.com/thecodingmachine/gotenberg" alt="Go Report Card">
</a>
<a href="https://codecov.io/gh/thecodingmachine/gotenberg/branch/1.0.0">
<img src="https://codecov.io/gh/thecodingmachine/gotenberg/branch/1.0.0/graph/badge.svg" alt="Codecov">
<a href="https://codecov.io/gh/thecodingmachine/gotenberg/branch/master">
<img src="https://codecov.io/gh/thecodingmachine/gotenberg/branch/master/graph/badge.svg" alt="Codecov">
</a>
</p>
@@ -40,18 +40,18 @@ reimplementing a solution from a project to another project. Meh.
Let's say you're starting the API using this simple command:
```sh
$ docker run --rm -p 3000:3000 thecodingmachine/gotenberg:1.0.0
$ docker run --rm -p 3000:3000 thecodingmachine/gotenberg:2.0.0
```
The API is now available on your host under `http://127.0.0.1:3000`.
It accepts `POST` requests with a `multipart/form-data` Content-Type. Your form data should provide one or more files to convert.
It currently accepts the following:
The default image accepts the following:
* Markdown files
* HTML files
* Office documents (.docx, .doc, .odt, .pptx, .ppt, .odp and so on)
* PDF files (if more than one file to convert)
* PDF files
**Heads up:** the API relies on the file extension to determine which library to use for conversion.
@@ -107,7 +107,7 @@ services:
# your others services
gotenberg:
image: gotenberg:1.0.0
image: thecodingmachine/gotenberg:2.0.0
```
You may now launch your services using:
@@ -122,9 +122,75 @@ according to the round-robin strategy.
## Custom implementation
The API relies on a simple YAML configuration file called `gotenberg.yml`. It allows you to tweak some values and even provides you
a way to change the commands called for each kind of conversion. The configuration file should be located under `/gotenberg` in your container.
a way to change the commands called for each kind of conversion.
The default configuration is located here: [.ci/gotenberg.yml](.ci/gotenberg.yml)
Below the default configuration file:
```yaml
# The port the application will listen to.
port: 3000
logs:
# Accepted values, in order of severity: DEBUG, INFO, WARN, ERROR, FATAL, PANIC.
# Messages at and above the selected level will be logged.
level: "INFO"
# Accepted values: text, json.
# When a TTY is not attached, the output will be in the defined format.
formatter: "text"
# You don't like a library which is used for a conversion? You want to handle a new file type?
# You may provide here your own implementation!
commands:
# Some libraries like unoconv cannot perform concurrent conversions. That's why the API does only one conversion at a time.
# If your current implementation uses libraries which are able to perform concurrent conversions, you may
# change this value to false.
lock: true
# Unlike others commands' templates, you have access to FilesPaths instead of FilePath: it gathers all PDF files which should be merged.
merge:
template: "pdftk {{ range $filePath := .FilesPaths }} {{ $filePath }} {{ end }} cat output {{ .ResultFilePath }}"
interpreter: "/bin/sh -c"
timeout: 30
# You may add more commands (or less, or even none).
conversions:
# The command template: you have access to FilePath and ResultFilePath variables.
- template: "markdown-pdf {{ .FilePath }} -o {{ .ResultFilePath }}"
# The binary which will call the command.
interpreter: "/bin/sh -c"
# Duration in seconds after which the command will be killed if it has not finished.
timeout: 30
# Files with the following extensions will be converted by the current command.
extensions:
- ".md"
- template: "xvfb-run -e /dev/stdout wkhtmltopdf {{ .FilePath }} {{ .ResultFilePath }}"
interpreter: "/bin/sh -c"
timeout: 30
extensions:
- ".html"
- ".htm"
- template: "unoconv --format pdf --output \"{{ .ResultFilePath }}\" \"{{ .FilePath }}\""
interpreter: "/bin/sh -c"
timeout: 30
extensions:
- ".doc"
- ".docx"
- ".odt"
- ".xls"
- ".xlsx"
- ".ods"
- ".ppt"
- ".pptx"
- ".odp"
```
We provide binaries for a wide range of OS and architecture in the [releases page](../../releases),
so feel free to create your own Docker image for your implementation of the Gotenberg API :metal:
## Clients

View File

@@ -1,20 +1,39 @@
port: 3000
logs:
level: "DEBUG"
format: {
formatter: {
value: [
...
}
commands:
markdown:
timeout: 30
template: "markdown-pdf {{ .FilePath }} -o {{ .ResultFilePath }}"
html:
timeout: 30
template: "xvfb-run -e /dev/stdout wkhtmltopdf {{ .FilePath }} {{ .ResultFilePath }}"
office:
timeout: 30
template: "unoconv --format pdf --output \"{{ .ResultFilePath }}\" \"{{ .FilePath }}\""
lock: true
merge:
timeout: 30
template: "pdftk {{ range $filePath := .FilesPaths }} {{ $filePath }} {{ end }} cat output {{ .ResultFilePath }}"
interpreter: "/bin/sh -c"
timeout: 30
conversions:
- template: "markdown-pdf {{ .FilePath }} -o {{ .ResultFilePath }}"
interpreter: "/bin/sh -c"
timeout: 30
extensions:
- ".md"
- template: "xvfb-run -e /dev/stdout wkhtmltopdf {{ .FilePath }} {{ .ResultFilePath }}"
interpreter: "/bin/sh -c"
timeout: 30
extensions:
- ".html"
- ".htm"
- template: "unoconv --format pdf --output \"{{ .ResultFilePath }}\" \"{{ .FilePath }}\""
interpreter: "/bin/sh -c"
timeout: 30
extensions:
- ".doc"
- ".docx"
- ".odt"
- ".xls"
- ".xlsx"
- ".ods"
- ".ppt"
- ".pptx"
- ".odp"

View File

@@ -0,0 +1,40 @@
port: 3000
logs:
level: "DEBUG"
formatter: "text"
commands:
lock: true
merge:
template: "pdftk {{ range $filePath := .FilesPaths }} {{ $filePath }} {{ end }} cat output {{ .ResultFilePath }}"
interpreter: "/bin/sh -c"
timeout: 30
conversions:
- template: "markdown-pdf {{ .FilePath }} -o {{ .ResultFilePath }}"
interpreter: "/bin/sh -c"
timeout: 30
extensions:
- ".md"
- template: "markdown-pdf {{ .FilePath }} -o {{ .ResultFilePath }}"
interpreter: "/bin/sh -c"
timeout: 30
extensions:
- ".md"
- template: "xvfb-run -e /dev/stdout wkhtmltopdf {{ .FilePath }} {{ .ResultFilePath }}"
interpreter: "/bin/sh -c"
timeout: 30
extensions:
- ".html"
- ".htm"
- template: "unoconv --format pdf --output \"{{ .ResultFilePath }}\" \"{{ .FilePath }}\""
interpreter: "/bin/sh -c"
timeout: 30
extensions:
- ".doc"
- ".docx"
- ".odt"
- ".xls"
- ".xlsx"
- ".ods"
- ".ppt"
- ".pptx"
- ".odp"

View File

@@ -1,17 +1,35 @@
port: 3000
logs:
level: "DEBUG"
format: "text"
formatter: "text"
commands:
markdown:
timeout: 30
template: "markdown-pdf {{ .FilePath }} -o {{ .ResultFilePath }}"
html:
timeout: 30
template: "xvfb-run -e /dev/stdout wkhtmltopdf {{ .FilePath }} {{ .ResultFilePath }}"
office:
timeout: 30
template: "unoconv --format pdf --output \"{{ .ResultFilePath }}\" \"{{ .FilePath }}\""
lock: true
merge:
timeout: 30
template: "pdftk {{ range $filePath := .FilesPaths }} {{ $filePath }} {{ end }} cat output {{ .ResultFilePath }}"
interpreter: "/bin/sh -c"
timeout: 30
conversions:
- template: "markdown-pdf {{ .FilePath }} -o {{ .ResultFilePath }}"
interpreter: "/bin/sh -c"
timeout: 30
extensions:
- ".md"
- template: "xvfb-run -e /dev/stdout wkhtmltopdf {{ .FilePath }} {{ .ResultFilePath }}"
interpreter: "/bin/sh -c"
timeout: 30
extensions:
- ".html"
- ".htm"
- template: "unoconv --format pdf --output \"{{ .ResultFilePath }}\" \"{{ .FilePath }}\""
interpreter: "/bin/sh -c"
timeout: 30
extensions:
- ".doc"
- ".docx"
- ".odt"
- ".xls"
- ".xlsx"
- ".ods"
- ".ppt"
- ".pptx"
- ".odp"

View File

@@ -1,17 +1,35 @@
port: 3000
logs:
level: "DEBUG"
format: "text"
formatter: "text"
commands:
markdown:
timeout: 30
template: "markdown-pdf {{ .FilePath }} -o {{ .ResultFilePath }}"
html:
timeout: 30
template: "xvfb-run -e /dev/stdout wkhtmltopdf {{ .FilePath }} {{ .ResultFilePath }}"
office:
timeout: 30
template: "unoconv --format pdf --output \"{{ .ResultFilePath }}\" \"{{ .FilePath }}\""
lock: true
merge:
timeout: 0
template: "pdftk {{ range $filePath := .FilesPaths }} {{ $filePath }} {{ end }} cat output {{ .ResultFilePath }}"
interpreter: "/bin/sh -c"
timeout: 0
conversions:
- template: "markdown-pdf {{ .FilePath }} -o {{ .ResultFilePath }}"
interpreter: "/bin/sh -c"
timeout: 30
extensions:
- ".md"
- template: "xvfb-run -e /dev/stdout wkhtmltopdf {{ .FilePath }} {{ .ResultFilePath }}"
interpreter: "/bin/sh -c"
timeout: 30
extensions:
- ".html"
- ".htm"
- template: "unoconv --format pdf --output \"{{ .ResultFilePath }}\" \"{{ .FilePath }}\""
interpreter: "/bin/sh -c"
timeout: 30
extensions:
- ".doc"
- ".docx"
- ".odt"
- ".xls"
- ".xlsx"
- ".ods"
- ".ppt"
- ".pptx"
- ".odp"

View File

@@ -0,0 +1,35 @@
port: 3000
logs:
level: "DEBUG"
formatter: "text"
commands:
lock: false
merge:
template: "pdftk {{ range $filePath := .FilesPaths }} {{ $filePath }} {{ end }} cat output {{ .ResultFilePath }}"
interpreter: "/bin/sh -c"
timeout: 30
conversions:
- template: "markdown-pdf {{ .FilePath }} -o {{ .ResultFilePath }}"
interpreter: "/bin/sh -c"
timeout: 30
extensions:
- ".md"
- template: "xvfb-run -e /dev/stdout wkhtmltopdf {{ .FilePath }} {{ .ResultFilePath }}"
interpreter: "/bin/sh -c"
timeout: 30
extensions:
- ".html"
- ".htm"
- template: "unoconv --format pdf --output \"{{ .ResultFilePath }}\" \"{{ .FilePath }}\""
interpreter: "/bin/sh -c"
timeout: 30
extensions:
- ".doc"
- ".docx"
- ".odt"
- ".xls"
- ".xlsx"
- ".ods"
- ".ppt"
- ".pptx"
- ".odp"

View File

@@ -1,17 +1,35 @@
port: 3000
logs:
level: "DEBUG"
format: "text"
formatter: "text"
commands:
markdown:
timeout: 0
template: "markdown-pdf {{ .FilePath }} -o {{ .ResultFilePath }}"
html:
timeout: 0
template: "xvfb-run -e /dev/stdout wkhtmltopdf {{ .FilePath }} {{ .ResultFilePath }}"
office:
timeout: 0
template: "unoconv --format pdf --output \"{{ .ResultFilePath }}\" \"{{ .FilePath }}\""
lock: true
merge:
timeout: 0
template: "pdftk {{ range $filePath := .FilesPaths }} {{ $filePath }} {{ end }} cat output {{ .ResultFilePath }}"
interpreter: "/bin/sh -c"
timeout: 0
conversions:
- template: "markdown-pdf {{ .FilePath }} -o {{ .ResultFilePath }}"
interpreter: "/bin/sh -c"
timeout: 0
extensions:
- ".md"
- template: "xvfb-run -e /dev/stdout wkhtmltopdf {{ .FilePath }} {{ .ResultFilePath }}"
interpreter: "/bin/sh -c"
timeout: 0
extensions:
- ".html"
- ".htm"
- template: "unoconv --format pdf --output \"{{ .ResultFilePath }}\" \"{{ .FilePath }}\""
interpreter: "/bin/sh -c"
timeout: 0
extensions:
- ".doc"
- ".docx"
- ".odt"
- ".xls"
- ".xlsx"
- ".ods"
- ".ppt"
- ".pptx"
- ".odp"

View File

@@ -0,0 +1,35 @@
port: 3000
logs:
level: "DEBUG"
formatter: "text"
commands:
lock: true
merge:
template: "pdftk {{ range $filePath := .FilesPaths }} {{ $filePath }} {{ end }} cat output {{ .ResultFilePath }}"
interpreter: "/bin/sh -c"
timeout: 30
conversions:
- template: "markdown-pdf {{ FilePath }} -o {{ .ResultFilePath }}"
interpreter: "/bin/sh -c"
timeout: 30
extensions:
- ".md"
- template: "xvfb-run -e /dev/stdout wkhtmltopdf {{ .FilePath }} {{ .ResultFilePath }}"
interpreter: "/bin/sh -c"
timeout: 30
extensions:
- ".html"
- ".htm"
- template: "unoconv --format pdf --output \"{{ .ResultFilePath }}\" \"{{ .FilePath }}\""
interpreter: "/bin/sh -c"
timeout: 30
extensions:
- ".doc"
- ".docx"
- ".odt"
- ".xls"
- ".xlsx"
- ".ods"
- ".ppt"
- ".pptx"
- ".odp"

View File

@@ -1,17 +0,0 @@
port: 3000
logs:
level: "DEBUG"
format: "text"
commands:
markdown:
timeout: 30
template: "markdown-pdf {{ .FilePath }} -o {{ .ResultFilePath }}"
html:
timeout: 30
template: "xvfb-run -e /dev/stdout wkhtmltopdf {{ FilePath }} {{ .ResultFilePath }}"
office:
timeout: 30
template: "unoconv --format pdf --output \"{{ .ResultFilePath }}\" \"{{ .FilePath }}\""
merge:
timeout: 30
template: "pdftk {{ range $filePath := .FilesPaths }} {{ $filePath }} {{ end }} cat output {{ .ResultFilePath }}"

View File

@@ -1,17 +0,0 @@
port: 3000
logs:
level: "DEBUG"
format: "DEBUG"
commands:
markdown:
timeout: 30
template: "markdown-pdf {{ .FilePath }} -o {{ .ResultFilePath }}"
html:
timeout: 30
template: "xvfb-run -e /dev/stdout wkhtmltopdf {{ .FilePath }} {{ .ResultFilePath }}"
office:
timeout: 30
template: "unoconv --format pdf --output \"{{ .ResultFilePath }}\" \"{{ .FilePath }}\""
merge:
timeout: 30
template: "pdftk {{ range $filePath := .FilesPaths }} {{ $filePath }} {{ end }} cat output {{ .ResultFilePath }}"

View File

@@ -0,0 +1,35 @@
port: 3000
logs:
level: "DEBUG"
formatter: "DEBUG"
commands:
lock: true
merge:
template: "pdftk {{ range $filePath := .FilesPaths }} {{ $filePath }} {{ end }} cat output {{ .ResultFilePath }}"
interpreter: "/bin/sh -c"
timeout: 30
conversions:
- template: "markdown-pdf {{ .FilePath }} -o {{ .ResultFilePath }}"
interpreter: "/bin/sh -c"
timeout: 30
extensions:
- ".md"
- template: "xvfb-run -e /dev/stdout wkhtmltopdf {{ .FilePath }} {{ .ResultFilePath }}"
interpreter: "/bin/sh -c"
timeout: 30
extensions:
- ".html"
- ".htm"
- template: "unoconv --format pdf --output \"{{ .ResultFilePath }}\" \"{{ .FilePath }}\""
interpreter: "/bin/sh -c"
timeout: 30
extensions:
- ".doc"
- ".docx"
- ".odt"
- ".xls"
- ".xlsx"
- ".ods"
- ".ppt"
- ".pptx"
- ".odp"

View File

@@ -1,17 +1,35 @@
port: 3000
logs:
level: "text"
format: "text"
formatter: "text"
commands:
markdown:
timeout: 30
template: "markdown-pdf {{ .FilePath }} -o {{ .ResultFilePath }}"
html:
timeout: 30
template: "xvfb-run -e /dev/stdout wkhtmltopdf {{ .FilePath }} {{ .ResultFilePath }}"
office:
timeout: 30
template: "unoconv --format pdf --output \"{{ .ResultFilePath }}\" \"{{ .FilePath }}\""
lock: true
merge:
timeout: 30
template: "pdftk {{ range $filePath := .FilesPaths }} {{ $filePath }} {{ end }} cat output {{ .ResultFilePath }}"
interpreter: "/bin/sh -c"
timeout: 30
conversions:
- template: "markdown-pdf {{ .FilePath }} -o {{ .ResultFilePath }}"
interpreter: "/bin/sh -c"
timeout: 30
extensions:
- ".md"
- template: "xvfb-run -e /dev/stdout wkhtmltopdf {{ .FilePath }} {{ .ResultFilePath }}"
interpreter: "/bin/sh -c"
timeout: 30
extensions:
- ".html"
- ".htm"
- template: "unoconv --format pdf --output \"{{ .ResultFilePath }}\" \"{{ .FilePath }}\""
interpreter: "/bin/sh -c"
timeout: 30
extensions:
- ".doc"
- ".docx"
- ".odt"
- ".xls"
- ".xlsx"
- ".ods"
- ".ppt"
- ".pptx"
- ".odp"

View File

@@ -1,17 +0,0 @@
port: 3000
logs:
level: "DEBUG"
format: "text"
commands:
markdown:
timeout: 30
template: "markdown-pdf {{ FilePath }} -o {{ .ResultFilePath }}"
html:
timeout: 30
template: "xvfb-run -e /dev/stdout wkhtmltopdf {{ .FilePath }} {{ .ResultFilePath }}"
office:
timeout: 30
template: "unoconv --format pdf --output \"{{ .ResultFilePath }}\" \"{{ .FilePath }}\""
merge:
timeout: 30
template: "pdftk {{ range $filePath := .FilesPaths }} {{ $filePath }} {{ end }} cat output {{ .ResultFilePath }}"

View File

@@ -1,17 +1,35 @@
port: 3000
logs:
level: "DEBUG"
format: "text"
formatter: "text"
commands:
markdown:
timeout: 30
template: "markdown-pdf {{ .FilePath }} -o {{ .ResultFilePath }}"
html:
timeout: 30
template: "xvfb-run -e /dev/stdout wkhtmltopdf {{ .FilePath }} {{ .ResultFilePath }}"
office:
timeout: 30
template: "unoconv --format pdf --output {{ .ResultFilePath }}\" \"{{ .FilePath }}\""
lock: true
merge:
template: "pdftk {{ range $filePath := FilesPaths }} {{ $filePath }} {{ end }} cat output {{ .ResultFilePath }}"
interpreter: "/bin/sh -c"
timeout: 30
template: "pdftk {{ range $filePath := .FilesPaths }} {{ $filePath }} cat output {{ .ResultFilePath }}"
conversions:
- template: "markdown-pdf {{ .FilePath }} -o {{ .ResultFilePath }}"
interpreter: "/bin/sh -c"
timeout: 30
extensions:
- ".md"
- template: "xvfb-run -e /dev/stdout wkhtmltopdf {{ .FilePath }} {{ .ResultFilePath }}"
interpreter: "/bin/sh -c"
timeout: 30
extensions:
- ".html"
- ".htm"
- template: "unoconv --format pdf --output \"{{ .ResultFilePath }}\" \"{{ .FilePath }}\""
interpreter: "/bin/sh -c"
timeout: 30
extensions:
- ".doc"
- ".docx"
- ".odt"
- ".xls"
- ".xlsx"
- ".ods"
- ".ppt"
- ".pptx"
- ".odp"

View File

@@ -1,17 +0,0 @@
port: 3000
logs:
level: "DEBUG"
format: "text"
commands:
markdown:
timeout: 30
template: "markdown-pdf {{ .FilePath }} -o {{ .ResultFilePath }}"
html:
timeout: 30
template: "xvfb-run -e /dev/stdout wkhtmltopdf {{ .FilePath }} {{ .ResultFilePath }}"
office:
timeout: 30
template: "unoconv --format pdf --output \"{{ ResultFilePath }}\" \"{{ .FilePath }}\""
merge:
timeout: 30
template: "pdftk {{ range $filePath := .FilesPaths }} {{ $filePath }} {{ end }} cat output {{ .ResultFilePath }}"

View File

@@ -7,139 +7,54 @@ It should be located where the user starts the application from the CLI.
package config
import (
"io/ioutil"
"fmt"
"strings"
"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
lock bool
// 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
// The binary which will call the command.
Interpreter []string
// 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 +68,146 @@ 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
}
// WithLock sets the lock strategy.
func WithLock(lock bool) {
config.lock = lock
}
// HasLock returns the current lock strategy.
func HasLock() bool {
return config.lock
}
type interpreterEmptyError struct {
command string
}
const interpreterEmptyErrorMessage = "the interepreter for command %s should not be empty"
func (e *interpreterEmptyError) Error() string {
return fmt.Sprintf(interpreterEmptyErrorMessage, e.command)
}
// NewCommand instantiates a Command. If the given command string
// is not a valid template, throws an error.
func NewCommand(command string, interpreter string, timeout int) (*Command, error) {
t, err := template.New(command).Parse(command)
if err != nil {
return nil, err
}
return t, nil
if interpreter == "" {
return nil, &interpreterEmptyError{command}
}
return &Command{t, strings.Fields(interpreter), 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,201 @@
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 TestWithLock(t *testing.T) {
lock := true
WithLock(lock)
if config.lock != lock {
t.Errorf("Configuration populated with a wrong lock strategy: got '%t' want '%t'", config.lock, lock)
}
}
func TestHasLock(t *testing.T) {
lock := true
config.lock = true
if HasLock() != lock {
t.Errorf("Configuration returned a wrong port: got '%t' want '%t'", HasLock(), lock)
}
}
func TestInterpreterEmptyError(t *testing.T) {
err := &interpreterEmptyError{"echo hello world"}
expected := fmt.Sprintf(interpreterEmptyErrorMessage, err.command)
if err.Error() != expected {
t.Errorf("Error returned a wrong message: got '%s' want '%s'", err.Error(), expected)
}
}
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, "/bin/sh -c", 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, "/bin/sh -c", 0); err != nil {
t.Errorf("Command should have been instantiated by using '%s' as command template", cmd)
}
// case 3: uses an empty interpreter.
if _, err := NewCommand(cmd, "", 0); err == nil {
t.Error("Command should not have been instantiated by using an empty interpreter")
}
}
func TestFileExtensionAlreadyUsedError(t *testing.T) {
ext := ".pdf"
cmd1, _ := NewCommand("echo", "/bin/sh -c", 0)
cmd2, _ := NewCommand("echo", "/bin/sh -c", 0)
err := &fileExtensionAlreadyUsedError{ext, cmd1, cmd2}
expected := fmt.Sprintf(fileExtensionAlreadyUsedErrorMessage, err.extension, err.command.Template.Name(), err.existingCommand.Template.Name())
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", "/bin/sh -c", 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) {
err := &noCommandFoundForFileExtensionError{".pdf"}
expected := fmt.Sprintf(noCommandFoundForFileExtensionErrorMessage, err.extension)
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", "/bin/sh -c", 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)
}
}

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

@@ -0,0 +1,99 @@
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
}
WithLock(fileConfig.Commands.Lock)
// handles merge command first...
cmd, err := NewCommand(fileConfig.Commands.Merge.Template, fileConfig.Commands.Merge.Interpreter, 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.Interpreter, 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 {
Lock bool `yaml:"lock"`
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"`
Interpreter string `yaml:"interpreter"`
Timeout int `yaml:"timeout"`
}
// conversionCommand gathers all data regarding a conversion command.
conversionCommand struct {
Template string `yaml:"template"`
Interpreter string `yaml:"interpreter"`
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)
}
}

View File

@@ -12,10 +12,40 @@ import (
type key uint32
const (
converterKey key = iota
requestIDKey key = iota
converterKey
resultFilePathKey
)
// WithRequestID populates a request's context with the given request ID
// and returns the updated request.
func WithRequestID(r *http.Request, requestID string) *http.Request {
ctx := r.Context()
ctx = context.WithValue(ctx, requestIDKey, requestID)
r = r.WithContext(ctx)
return r
}
type requestIDNotFoundError struct{}
const requestIDNotFoundErrorMessage = "the request ID was not found in request context"
func (e *requestIDNotFoundError) Error() string {
return requestIDNotFoundErrorMessage
}
// GetRequestID returns the request ID if found in
// the request's context. Otherwise throws an error.
func GetRequestID(r *http.Request) (string, error) {
ID, ok := r.Context().Value(requestIDKey).(string)
if !ok {
return "", &requestIDNotFoundError{}
}
return ID, nil
}
// WithConverter populates a request's context with the given converter
// and returns the updated request.
func WithConverter(r *http.Request, converter *converter.Converter) *http.Request {
@@ -28,7 +58,7 @@ func WithConverter(r *http.Request, converter *converter.Converter) *http.Reques
type converterNotFoundError struct{}
const converterNotFoundErrorMessage = "The converter was not found in request context"
const converterNotFoundErrorMessage = "the converter was not found in request context"
func (e *converterNotFoundError) Error() string {
return converterNotFoundErrorMessage
@@ -57,7 +87,7 @@ func WithResultFilePath(r *http.Request, resultFilePath string) *http.Request {
type resultFilePathNotFoundError struct{}
const resultFilePathNotFoundErrorMessage = "The result file path was not found in request context"
const resultFilePathNotFoundErrorMessage = "the result file path was not found in request context"
func (e *resultFilePathNotFoundError) Error() string {
return resultFilePathNotFoundErrorMessage

View File

@@ -6,8 +6,40 @@ import (
"testing"
"github.com/thecodingmachine/gotenberg/app/converter"
"github.com/satori/go.uuid"
)
func TestWithRequestID(t *testing.T) {
requestID := uuid.NewV4().String()
req := WithRequestID(httptest.NewRequest(http.MethodPost, "/", nil), requestID)
if ID, _ := req.Context().Value(requestIDKey).(string); ID != requestID {
t.Errorf("Context returned a wrong converter: got '%s' want '%s'", ID, requestID)
}
}
func TestRequestIDNotFoundError(t *testing.T) {
err := &requestIDNotFoundError{}
if err.Error() != requestIDNotFoundErrorMessage {
t.Errorf("Error returned a wrong message: got '%s' want '%s'", err.Error(), requestIDNotFoundErrorMessage)
}
}
func TestGetRequestID(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/", nil)
// case 1: uses a request without a request ID entry in its context.
if _, err := GetRequestID(req); err == nil {
t.Error("Context should not have a request ID entry")
}
// case 2: uses a request with a request ID entry in its context.
req = WithRequestID(req, uuid.NewV4().String())
if _, err := GetRequestID(req); err != nil {
t.Error("Context should have a request ID entry")
}
}
func TestWithConverter(t *testing.T) {
req := WithConverter(httptest.NewRequest(http.MethodPost, "/", nil), &converter.Converter{})
if c, _ := req.Context().Value(converterKey).(*converter.Converter); c == nil {
@@ -15,6 +47,13 @@ func TestWithConverter(t *testing.T) {
}
}
func TestConverterNotFoundError(t *testing.T) {
err := &converterNotFoundError{}
if err.Error() != converterNotFoundErrorMessage {
t.Errorf("Error returned a wrong message: got '%s' want '%s'", err.Error(), converterNotFoundErrorMessage)
}
}
func TestGetConverter(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/", nil)
@@ -38,6 +77,13 @@ func TestWithResultFilePath(t *testing.T) {
}
}
func TestResultFilePathNotFoundError(t *testing.T) {
err := &resultFilePathNotFoundError{}
if err.Error() != resultFilePathNotFoundErrorMessage {
t.Errorf("Error returned a wrong message: got '%s' want '%s'", err.Error(), resultFilePathNotFoundErrorMessage)
}
}
func TestGetResultFilePath(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/", nil)
@@ -52,17 +98,3 @@ func TestGetResultFilePath(t *testing.T) {
t.Error("Context should have a result file path entry")
}
}
func TestConverterNotFoundError(t *testing.T) {
err := &converterNotFoundError{}
if err.Error() != converterNotFoundErrorMessage {
t.Errorf("Error returned a wrong message: got '%s' want '%s'", err.Error(), converterNotFoundErrorMessage)
}
}
func TestResultFilePathNotFoundError(t *testing.T) {
err := &resultFilePathNotFoundError{}
if err.Error() != resultFilePathNotFoundErrorMessage {
t.Errorf("Error returned a wrong message: got '%s' want '%s'", err.Error(), resultFilePathNotFoundErrorMessage)
}
}

View File

@@ -9,6 +9,7 @@ import (
gfile "github.com/thecodingmachine/gotenberg/app/converter/file"
"github.com/thecodingmachine/gotenberg/app/converter/process"
"github.com/thecodingmachine/gotenberg/app/logger"
"github.com/satori/go.uuid"
)
@@ -22,7 +23,7 @@ type Converter struct {
// NoFileToConvertError is raided when a request has not file attached to it.
type NoFileToConvertError struct{}
const noFileToConvertErrorMessage = "No file to convert"
const noFileToConvertErrorMessage = "no file to convert"
func (e *NoFileToConvertError) Error() string {
return noFileToConvertErrorMessage
@@ -38,6 +39,8 @@ func NewConverter(r *http.Request) (*Converter, error) {
return nil, err
}
logger.Debugf("created working directory %s", c.workingDir)
reader, err := r.MultipartReader()
if err != nil {
return c, err
@@ -75,7 +78,7 @@ func NewConverter(r *http.Request) (*Converter, error) {
func (c *Converter) Convert() (string, error) {
var filesPaths []string
for _, f := range c.files {
if f.Type != gfile.PDFType {
if f.Extension != ".pdf" {
path, err := process.Unconv(c.workingDir, f)
if err != nil {
return "", err
@@ -101,5 +104,11 @@ func (c *Converter) Convert() (string, error) {
// Clear removes all file inside its working directory.
func (c *Converter) Clear() error {
return os.RemoveAll(c.workingDir)
if err := os.RemoveAll(c.workingDir); err != nil {
logger.Error(fmt.Errorf("failed to remove working directory %s", c.workingDir))
return err
}
logger.Debugf("removed working directory %s", c.workingDir)
return nil
}

View File

@@ -10,7 +10,6 @@ import (
"testing"
"github.com/thecodingmachine/gotenberg/app/config"
"github.com/thecodingmachine/gotenberg/app/converter/process"
)
func makeRequest(filesPaths ...string) *http.Request {
@@ -43,10 +42,17 @@ func makeRequest(filesPaths ...string) *http.Request {
return req
}
func loadCommandConfigs(configurationFilePath string) {
func load(configurationFilePath string) {
config.Reset()
path, _ := filepath.Abs(configurationFilePath)
c, _ := config.NewAppConfig(path)
process.Load(c.CommandsConfig)
config.ParseFile(path)
}
func TestNoFileToConvertError(t *testing.T) {
err := &NoFileToConvertError{}
if err.Error() != noFileToConvertErrorMessage {
t.Errorf("Error returned a wrong message: got '%s' want '%s'", err.Error(), noFileToConvertErrorMessage)
}
}
func TestNewConverter(t *testing.T) {
@@ -55,6 +61,8 @@ func TestNewConverter(t *testing.T) {
oPath string
)
load("../../_tests/configurations/gotenberg.yml")
// case 1: uses a request with a single file.
path, _ = filepath.Abs("../../_tests/file.docx")
if _, err := NewConverter(makeRequest(path)); err != nil {
@@ -94,7 +102,7 @@ func TestConvert(t *testing.T) {
c *Converter
)
loadCommandConfigs("../../_tests/configurations/gotenberg.yml")
load("../../_tests/configurations/gotenberg.yml")
// case 1: uses a request with a single file.
path, _ = filepath.Abs("../../_tests/file.docx")
@@ -111,7 +119,7 @@ func TestConvert(t *testing.T) {
t.Errorf("Converter should have been able to convert '%s' and '%s' to PDF", path, oPath)
}
loadCommandConfigs("../../_tests/configurations/timeout-gotenberg.yml")
load("../../_tests/configurations/timeout-gotenberg.yml")
// case 3: uses a request with a single file and a configuration with an unsuitable timeout for the conversion commands.
path, _ = filepath.Abs("../../_tests/file.docx")
@@ -120,7 +128,7 @@ func TestConvert(t *testing.T) {
t.Errorf("Converter should not have been able to convert '%s' to PDF", path)
}
loadCommandConfigs("../../_tests/configurations/merge-timeout-gotenberg.yml")
load("../../_tests/configurations/merge-timeout-gotenberg.yml")
// case 4: uses a request with two files and a configuration with an unsuitable timeout for the merge command.
path, _ = filepath.Abs("../../_tests/file.pdf")
@@ -132,16 +140,11 @@ func TestConvert(t *testing.T) {
}
func TestClear(t *testing.T) {
load("../../_tests/configurations/gotenberg.yml")
path, _ := filepath.Abs("../../_tests/file.docx")
c, _ := NewConverter(makeRequest(path))
if err := c.Clear(); err != nil {
t.Error("Converter should have been able to clear itself")
}
}
func TestNoFileToConvertError(t *testing.T) {
err := &NoFileToConvertError{}
if err.Error() != noFileToConvertErrorMessage {
t.Errorf("Error returned a wrong message: got '%s' want '%s'", err.Error(), noFileToConvertErrorMessage)
}
}

View File

@@ -7,71 +7,32 @@ import (
"os"
"path/filepath"
"github.com/thecodingmachine/gotenberg/app/config"
"github.com/thecodingmachine/gotenberg/app/logger"
"github.com/dustin/go-humanize"
"github.com/satori/go.uuid"
)
// File represents a file which has been created
// from a request.
type File struct {
// Type is the kind of file.
Type Type
// Extension is the extension of the file.
Extension string
// Path is the file path.
Path string
}
// Type represents what kind of file we're dealing with.
type Type uint32
const (
// PDFType represents a... PDF file.
PDFType Type = iota
// MarkdownType represents a... Markdown file.
MarkdownType
// HTMLType represents an... HTML file.
HTMLType
// OfficeType represents an... Office document.
OfficeType
)
// filesTypes associates a file extension with its file kind counterpart.
var filesTypes = map[string]Type{
".pdf": PDFType,
".md": MarkdownType,
".htm": HTMLType,
".html": HTMLType,
".doc": OfficeType,
".docx": OfficeType,
".odt": OfficeType,
".xls": OfficeType,
".xlsx": OfficeType,
".ods": OfficeType,
".ppt": OfficeType,
".pptx": OfficeType,
".odp": OfficeType,
}
type fileTypeNotFoundError struct {
fileName string
}
func (e *fileTypeNotFoundError) Error() string {
return fmt.Sprintf("File type was not found for '%s'", e.fileName)
}
// NewFile creates a file in the considered directory.
// Returns a *File instance or an error if something bad happened.
func NewFile(workingDir string, r io.Reader, fileName string) (*File, error) {
ext := filepath.Ext(fileName)
t, ok := filesTypes[ext]
if !ok {
return nil, &fileTypeNotFoundError{fileName: fileName}
if _, err := config.GetCommand(ext); err != nil {
return nil, err
}
f := &File{
Path: MakeFilePath(workingDir, ext),
Type: t,
}
f := &File{ext, MakeFilePath(workingDir, ext)}
file, err := os.Create(f.Path)
if err != nil {
@@ -80,7 +41,7 @@ func NewFile(workingDir string, r io.Reader, fileName string) (*File, error) {
defer file.Close()
_, err = io.Copy(file, r)
n, err := io.Copy(file, r)
if err != nil {
return nil, err
}
@@ -88,6 +49,7 @@ func NewFile(workingDir string, r io.Reader, fileName string) (*File, error) {
// resets the read pointer.
file.Seek(0, 0)
logger.Debugf("working file %s has been created from %s (%s copied)", f.Path, fileName, humanize.Bytes(uint64(n)))
return f, nil
}

View File

@@ -2,13 +2,22 @@ package file
import (
"bytes"
"fmt"
"os"
"path/filepath"
"testing"
"github.com/thecodingmachine/gotenberg/app/config"
)
func load(configurationFilePath string) {
config.Reset()
path, _ := filepath.Abs(configurationFilePath)
config.ParseFile(path)
}
func TestNewFile(t *testing.T) {
load("../../../_tests/configurations/gotenberg.yml")
workingDir := "test"
os.Mkdir(workingDir, 0666)
@@ -17,21 +26,13 @@ func TestNewFile(t *testing.T) {
t.Error("File should not have been instantiated with an empty buffer")
}
// case 2: uses a reader from a correct file type.
// case 2: uses a file name.
filePath, _ := filepath.Abs("../../../_tests/file.pdf")
r, _ := os.Open(filePath)
defer r.Close()
if _, err := NewFile(workingDir, r, "file.pdf"); err != nil {
t.Errorf("File should have been instantiated using a reader from '%s'", filePath)
t.Errorf("File should have been instantiated using a reader of '%s'", filePath)
}
os.RemoveAll(workingDir)
}
func TestFileTypeNotFoundError(t *testing.T) {
fileName := "file.wp"
err := &fileTypeNotFoundError{fileName: fileName}
expected := fmt.Sprintf("File type was not found for '%s'", fileName)
if err.Error() != expected {
t.Errorf("Error returned a wrong message: got '%s' want '%s'", err.Error(), expected)
}
}

View File

@@ -6,16 +6,15 @@ import (
"fmt"
"os/exec"
"sync"
"text/template"
"time"
"github.com/thecodingmachine/gotenberg/app/config"
gfile "github.com/thecodingmachine/gotenberg/app/converter/file"
"github.com/thecodingmachine/gotenberg/app/logger"
)
type runner struct {
mu sync.Mutex
commandsConfig *config.CommandsConfig
mu sync.Mutex
}
var forest = &runner{}
@@ -25,17 +24,27 @@ type commandTimeoutError struct {
timeout int
}
const commandTimeoutErrorMessage = "the command %s has reached the %d second(s) timeout"
func (e *commandTimeoutError) Error() string {
return fmt.Sprintf("The command '%s' has reached the %d second(s) timeout", e.command, e.timeout)
return fmt.Sprintf(commandTimeoutErrorMessage, e.command, e.timeout)
}
// run runs the given command. If timeout is reached or
// something bad happened, returns an error.
func (r *runner) run(command string, timeout int) error {
r.mu.Lock()
defer r.mu.Unlock()
func (r *runner) run(command string, interpreter []string, timeout int) error {
if config.HasLock() {
r.mu.Lock()
defer r.mu.Unlock()
} else {
logger.Warn("lock disabled")
}
binary := interpreter[0]
parameters := append(interpreter[1:], command)
cmd := exec.Command(binary, parameters...)
logger.Debugf("executing command %s", cmd.Args)
cmd := exec.Command("/bin/sh", "-c", command)
if err := cmd.Start(); err != nil {
return err
}
@@ -51,10 +60,8 @@ func (r *runner) run(command string, timeout int) error {
if err := cmd.Process.Kill(); err != nil {
return err
}
return &commandTimeoutError{
command: command,
timeout: timeout,
}
return &commandTimeoutError{command, timeout}
case err := <-done:
if err != nil {
return err
@@ -64,64 +71,32 @@ func (r *runner) run(command string, timeout int) error {
}
}
// Load loads the commands configuration coming from the application configuration.
func Load(config *config.CommandsConfig) {
forest.commandsConfig = config
}
// conversionData will be applied to the data-driven templates of conversions commands.
type conversionData struct {
FilePath string
ResultFilePath string
}
type impossibleConversionError struct{}
const impossibleConversionErrorMessage = "Impossible conversion"
func (e *impossibleConversionError) Error() string {
return impossibleConversionErrorMessage
}
// Unconv converts a file to PDF and returns the new file path.
func Unconv(workingDir string, file *gfile.File) (string, error) {
cmdData := &conversionData{
FilePath: file.Path,
ResultFilePath: gfile.MakeFilePath(workingDir, ".pdf"),
}
cmdData := &conversionData{file.Path, gfile.MakeFilePath(workingDir, ".pdf")}
var (
cmdTimeout int
cmdTemplate *template.Template
)
switch file.Type {
case gfile.MarkdownType:
cmdTimeout = forest.commandsConfig.Markdown.Timeout
cmdTemplate = forest.commandsConfig.Markdown.Template
break
case gfile.HTMLType:
cmdTimeout = forest.commandsConfig.HTML.Timeout
cmdTemplate = forest.commandsConfig.HTML.Template
break
case gfile.OfficeType:
cmdTimeout = forest.commandsConfig.Office.Timeout
cmdTemplate = forest.commandsConfig.Office.Template
break
default:
return "", &impossibleConversionError{}
}
var data bytes.Buffer
if err := cmdTemplate.Execute(&data, cmdData); err != nil {
return "", err
}
err := forest.run(data.String(), cmdTimeout)
cmd, err := config.GetCommand(file.Extension)
if err != nil {
return "", err
}
var data bytes.Buffer
if err := cmd.Template.Execute(&data, cmdData); err != nil {
return "", err
}
err = forest.run(data.String(), cmd.Interpreter, cmd.Timeout)
if err != nil {
return "", err
}
logger.Debugf("created %s from %s", cmdData.ResultFilePath, cmdData.FilePath)
return cmdData.ResultFilePath, nil
}
@@ -133,23 +108,23 @@ type mergeData struct {
// Merge merges many PDF files to one unique PDF file and returns the new file path.
func Merge(workingDir string, filesPaths []string) (string, error) {
cmdData := &mergeData{
FilesPaths: filesPaths,
ResultFilePath: gfile.MakeFilePath(workingDir, ".pdf"),
}
cmdData := &mergeData{filesPaths, gfile.MakeFilePath(workingDir, ".pdf")}
cmdTimeout := forest.commandsConfig.Merge.Timeout
cmdTemplate := forest.commandsConfig.Merge.Template
var data bytes.Buffer
if err := cmdTemplate.Execute(&data, cmdData); err != nil {
return "", err
}
err := forest.run(data.String(), cmdTimeout)
cmd, err := config.GetCommand(".pdf")
if err != nil {
return "", err
}
var data bytes.Buffer
if err := cmd.Template.Execute(&data, cmdData); err != nil {
return "", err
}
err = forest.run(data.String(), cmd.Interpreter, cmd.Timeout)
if err != nil {
return "", err
}
logger.Debugf("created %s from %+v", cmdData.ResultFilePath, cmdData.FilesPaths)
return cmdData.ResultFilePath, nil
}

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"github.com/thecodingmachine/gotenberg/app/config"
@@ -22,42 +23,51 @@ func makeFile(workingDir string, fileName string) *gfile.File {
return f
}
func loadCommandConfigs(configurationFilePath string) {
func load(configurationFilePath string) {
config.Reset()
path, _ := filepath.Abs(configurationFilePath)
c, _ := config.NewAppConfig(path)
Load(c.CommandsConfig)
config.ParseFile(path)
}
func TestLoad(t *testing.T) {
path, _ := filepath.Abs("../../../_tests/configurations/gotenberg.yml")
c, _ := config.NewAppConfig(path)
Load(c.CommandsConfig)
func TestCommandTimeoutError(t *testing.T) {
err := &commandTimeoutError{"echo hello", 30}
expected := fmt.Sprintf(commandTimeoutErrorMessage, err.command, err.timeout)
if c.CommandsConfig != forest.commandsConfig {
t.Error("Commands configuration should have been loaded correctly")
if err.Error() != expected {
t.Errorf("Error returned a wrong message: got '%s' want '%s'", err.Error(), expected)
}
}
func TestRun(t *testing.T) {
var cmd string
load("../../../_tests/configurations/gotenberg.yml")
// case 1: uses a simple command.
cmd = "echo Hello world"
if err := forest.run(cmd, 30); err != nil {
if err := forest.run(cmd, strings.Fields("/bin/sh -c"), 30); err != nil {
t.Errorf("Command '%s' should have worked", cmd)
}
// case 2: uses a simple command but with an unsuitable timeout.
cmd = "sleep 5"
if err := forest.run(cmd, 0); err == nil {
if err := forest.run(cmd, strings.Fields("/bin/sh -c"), 0); err == nil {
t.Errorf("Command '%s' should not have worked", cmd)
}
// case 3: uses a broken command.
cmd = "helloworld"
if err := forest.run(cmd, 30); err == nil {
if err := forest.run(cmd, strings.Fields("/bin/sh -c"), 30); err == nil {
t.Errorf("Command '%s' should not have worked", cmd)
}
load("../../../_tests/configurations/no-lock-gotenberg.yml")
// case 4: uses a configuration with a no lock strategy.
cmd = "echo Hello world"
if err := forest.run(cmd, strings.Fields("/bin/sh -c"), 30); err != nil {
t.Errorf("Command '%s' should have worked", cmd)
}
}
func TestUnconv(t *testing.T) {
@@ -66,7 +76,7 @@ func TestUnconv(t *testing.T) {
workingDir := "test"
os.Mkdir(workingDir, 0666)
loadCommandConfigs("../../../_tests/configurations/gotenberg.yml")
load("../../../_tests/configurations/gotenberg.yml")
// case 1: uses an Markdown file type.
file = makeFile(workingDir, "file.md")
@@ -92,7 +102,7 @@ func TestUnconv(t *testing.T) {
t.Errorf("Converting '%s' to PDF should not have worked", file.Path)
}
loadCommandConfigs("../../../_tests/configurations/timeout-gotenberg.yml")
load("../../../_tests/configurations/timeout-gotenberg.yml")
// case 5: uses a command with an unsuitable timeout.
file = makeFile(workingDir, "file.docx")
@@ -107,7 +117,7 @@ func TestMerge(t *testing.T) {
workingDir := "test"
os.Mkdir(workingDir, 0666)
loadCommandConfigs("../../../_tests/configurations/gotenberg.yml")
load("../../../_tests/configurations/gotenberg.yml")
var filesPaths []string
path, _ := filepath.Abs("../../../_tests/file.pdf")
@@ -119,7 +129,7 @@ func TestMerge(t *testing.T) {
t.Error("Merge should have worked")
}
loadCommandConfigs("../../../_tests/configurations/timeout-gotenberg.yml")
load("../../../_tests/configurations/timeout-gotenberg.yml")
// case 2: uses a command with an unsuitable timeout.
if _, err := Merge(workingDir, filesPaths); err == nil {
@@ -128,21 +138,3 @@ func TestMerge(t *testing.T) {
os.RemoveAll(workingDir)
}
func TestImpossibleConversionError(t *testing.T) {
err := &impossibleConversionError{}
if err.Error() != impossibleConversionErrorMessage {
t.Errorf("Error returned a wrong message: got '%s' want '%s'", err.Error(), impossibleConversionErrorMessage)
}
}
func TestCommandTimeoutError(t *testing.T) {
err := &commandTimeoutError{
command: "echo hello",
timeout: 30,
}
expected := fmt.Sprintf("The command '%s' has reached the %d second(s) timeout", err.command, err.timeout)
if err.Error() != expected {
t.Errorf("Error returned a wrong message: got '%s' want '%s'", err.Error(), expected)
}
}

View File

@@ -12,7 +12,9 @@ import (
ghttp "github.com/thecodingmachine/gotenberg/app/http"
"github.com/thecodingmachine/gotenberg/app/logger"
"github.com/dustin/go-humanize"
"github.com/justinas/alice"
"github.com/satori/go.uuid"
)
// GetHandlersChain returns the handlers chaining
@@ -23,8 +25,10 @@ func GetHandlersChain() http.Handler {
type requestHasNoContentError struct{}
const requestHasNoContentErrorMessage = "request has not content"
func (e *requestHasNoContentError) Error() string {
return "Request has not content"
return requestHasNoContentErrorMessage
}
// enforeContentLengthHandler checks if the request has content.
@@ -37,6 +41,10 @@ func enforceContentLengthHandler(next http.Handler) http.Handler {
return
}
requestID := uuid.NewV4().String()
r = context.WithRequestID(r, requestID)
logger.Infof("identified new request (%s) with %s", humanize.Bytes(uint64(r.ContentLength)), requestID)
next.ServeHTTP(w, r)
})
}
@@ -120,6 +128,13 @@ func serveHandler(w http.ResponseWriter, r *http.Request) {
return
}
requestID, err := context.GetRequestID(r)
if err != nil {
logger.Error(err)
}
logger.Debugf("serving result file %s for request %s...", path, requestID)
done := make(chan error, 1)
go func() {
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", resultFileInfo.Name()))
@@ -134,6 +149,8 @@ func serveHandler(w http.ResponseWriter, r *http.Request) {
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
logger.Error(err)
} else {
logger.Infof("result file %s (%s) sent for request %s", path, humanize.Bytes(uint64(resultFileInfo.Size())), requestID)
}
cleanup(r)
@@ -143,11 +160,11 @@ func serveHandler(w http.ResponseWriter, r *http.Request) {
func cleanup(r *http.Request) {
c, err := context.GetConverter(r)
if err != nil {
logger.Warn(err.Error())
logger.Error(err)
return
}
if err := c.Clear(); err != nil {
logger.Warn(err.Error())
logger.Error(err)
}
}

View File

@@ -11,7 +11,6 @@ import (
"github.com/thecodingmachine/gotenberg/app/config"
"github.com/thecodingmachine/gotenberg/app/context"
"github.com/thecodingmachine/gotenberg/app/converter/process"
"github.com/justinas/alice"
)
@@ -46,10 +45,10 @@ func makeRequest(filesPaths ...string) *http.Request {
return req
}
func loadCommandConfigs(configurationFilePath string) {
func load(configurationFilePath string) {
config.Reset()
path, _ := filepath.Abs(configurationFilePath)
c, _ := config.NewAppConfig(path)
process.Load(c.CommandsConfig)
config.ParseFile(path)
}
func fakeSuccessHandler(w http.ResponseWriter, r *http.Request) {
@@ -63,6 +62,13 @@ func TestGetHandlersChain(t *testing.T) {
}
}
func TestRequestHasNoContentError(t *testing.T) {
err := &requestHasNoContentError{}
if err.Error() != requestHasNoContentErrorMessage {
t.Errorf("Error returned a wrong message: got '%s' want '%s'", err.Error(), requestHasNoContentErrorMessage)
}
}
func TestEnforceContentLengthHandler(t *testing.T) {
var (
req *http.Request
@@ -140,7 +146,7 @@ func TestConvertHandler(t *testing.T) {
t.Errorf("Handler returned a wrong status code: got '%v' want '%v'", status, http.StatusBadRequest)
}
loadCommandConfigs("../_tests/configurations/merge-timeout-gotenberg.yml")
load("../_tests/configurations/merge-timeout-gotenberg.yml")
// case 3: sends a request with two files and using an unsuitable timeout for merge commande.
path, _ = filepath.Abs("../_tests/file.pdf")
@@ -152,7 +158,7 @@ func TestConvertHandler(t *testing.T) {
t.Errorf("Handler returned a wrong status code: got '%v' want '%v'", status, http.StatusInternalServerError)
}
loadCommandConfigs("../_tests/configurations/gotenberg.yml")
load("../_tests/configurations/gotenberg.yml")
// case 4: sends a request with two files.
path, _ = filepath.Abs("../_tests/file.pdf")

View File

@@ -1,4 +1,4 @@
// Package http provides functions for detecting a request or a file content type.
// Package http provides functions for detecting a request content type.
package http
import (
@@ -15,8 +15,10 @@ const MultipartFormDataContentType ContentType = "multipart/form-data"
type notAuthorizedContentTypeError struct{}
const notAuthorizedContentTypeErrorMessage = "accepted value for 'Content-Type': %s"
func (e *notAuthorizedContentTypeError) Error() string {
return fmt.Sprintf("Accepted value for 'Content-Type': %s", MultipartFormDataContentType)
return fmt.Sprintf(notAuthorizedContentTypeErrorMessage, MultipartFormDataContentType)
}
// CheckAuthorizedContentType checks if the request header header has an authorized content type.

View File

@@ -31,7 +31,8 @@ func TestCheckAuthorizedContentType(t *testing.T) {
func TestNotAuthorizedContentTypeError(t *testing.T) {
err := &notAuthorizedContentTypeError{}
expected := fmt.Sprintf("Accepted value for 'Content-Type': %s", MultipartFormDataContentType)
expected := fmt.Sprintf(notAuthorizedContentTypeErrorMessage, MultipartFormDataContentType)
if err.Error() != expected {
t.Errorf("Error returned a wrong message: got '%s' want '%s'", err.Error(), expected)
}

View File

@@ -17,10 +17,7 @@ var log = newLogger()
// newLogger instantiates a logger instance with default values.
func newLogger() *logger {
l := &logger{
logger: logrus.New(),
}
l := &logger{logrus.New()}
l.logger.Out = os.Stdout
l.logger.Level = logrus.InfoLevel
@@ -45,7 +42,7 @@ func Debug(message string) {
// Debugf is a wrapper of the logrus Debugf function.
func Debugf(format string, args ...interface{}) {
log.logger.Debugf(format, args)
log.logger.Debugf(format, args...)
}
// Info is a wrapper of the logrus Info function.
@@ -55,7 +52,7 @@ func Info(message string) {
// Infof is a wrapper of the logrus Infof function.
func Infof(format string, args ...interface{}) {
log.logger.Infof(format, args)
log.logger.Infof(format, args...)
}
// Warn is a wrapper of the logrus Warn function.
@@ -63,6 +60,11 @@ func Warn(message string) {
log.logger.Warn(message)
}
// Warnf is wrapper of the logrus Warnf function.
func Warnf(format string, args ...interface{}) {
log.logger.Warnf(format, args...)
}
// Error is a wrapper of the logrus Error function.
func Error(err error) {
log.logger.Error(err.Error())

17
main.go
View File

@@ -17,7 +17,6 @@ import (
"github.com/thecodingmachine/gotenberg/app"
"github.com/thecodingmachine/gotenberg/app/config"
"github.com/thecodingmachine/gotenberg/app/converter/process"
"github.com/thecodingmachine/gotenberg/app/logger"
"github.com/gorilla/mux"
@@ -33,7 +32,7 @@ const defaultConfigurationFilePath = "gotenberg.yml"
// main initializes the application, starts it, and handles
// graceful shutdown.
func main() {
c, err := config.NewAppConfig(defaultConfigurationFilePath)
err := config.ParseFile(defaultConfigurationFilePath)
if err != nil {
logger.SetLevel(logrus.InfoLevel)
logger.Fatal(err)
@@ -41,8 +40,8 @@ func main() {
}
// defines our application logging.
logger.SetLevel(c.Logs.Level)
logger.SetFormatter(c.Logs.Formatter)
logger.SetLevel(config.GetLogsLevel())
logger.SetFormatter(config.GetLogsFormatter())
// defines our application router.
r := mux.NewRouter()
@@ -50,13 +49,13 @@ func main() {
// defines our server.
s := &http.Server{
Addr: fmt.Sprintf(":%s", c.Port),
Addr: fmt.Sprintf(":%s", config.GetPort()),
Handler: r,
}
process.Load(c.CommandsConfig)
logger.Infof("Starting Gotenberg version %s", version)
logger.Infof("Listening on port %s", c.Port)
logger.Debugf("configuration loaded from file %s", defaultConfigurationFilePath)
logger.Infof("starting Gotenberg version %s", version)
logger.Infof("listening on port %s", config.GetPort())
// runs our server in a goroutine so that it doesn't block.
go func() {
@@ -85,6 +84,6 @@ func main() {
s.Shutdown(ctx)
logger.SetLevel(logrus.InfoLevel)
logger.Info("Bye!")
logger.Info("bye!")
os.Exit(0)
}

View File

@@ -1,7 +1,4 @@
payload:
- key: Version
value: snapshot
- key: Latest
value: 1.0.0
value: snapshot

View File

@@ -8,7 +8,6 @@ tasks:
- use: generate
short: Generates all files from blueprints
run:
- BRANCH="$(git symbolic-ref --short HEAD)"; orbit generate -f .blueprints/README.blueprint.md -o README.md -p "Branch,${BRANCH}"
- orbit generate -f .blueprints/Dockerfile.blueprint -o Dockerfile.ci -p "Image,CI"
- orbit generate -f .blueprints/Dockerfile.blueprint -o Dockerfile -p "Image,MAIN"

21
vendor/github.com/dustin/go-humanize/.travis.yml generated vendored Normal file
View File

@@ -0,0 +1,21 @@
sudo: false
language: go
go:
- 1.3.x
- 1.5.x
- 1.6.x
- 1.7.x
- 1.8.x
- 1.9.x
- master
matrix:
allow_failures:
- go: master
fast_finish: true
install:
- # Do nothing. This is needed to prevent default install action "go get -t -v ./..." from happening here (we want it to happen inside script step).
script:
- go get -t -v ./...
- diff -u <(echo -n) <(gofmt -d -s .)
- go tool vet .
- go test -v -race ./...

21
vendor/github.com/dustin/go-humanize/LICENSE generated vendored Normal file
View File

@@ -0,0 +1,21 @@
Copyright (c) 2005-2008 Dustin Sallings <dustin@spy.net>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
<http://www.opensource.org/licenses/mit-license.php>

124
vendor/github.com/dustin/go-humanize/README.markdown generated vendored Normal file
View File

@@ -0,0 +1,124 @@
# Humane Units [![Build Status](https://travis-ci.org/dustin/go-humanize.svg?branch=master)](https://travis-ci.org/dustin/go-humanize) [![GoDoc](https://godoc.org/github.com/dustin/go-humanize?status.svg)](https://godoc.org/github.com/dustin/go-humanize)
Just a few functions for helping humanize times and sizes.
`go get` it as `github.com/dustin/go-humanize`, import it as
`"github.com/dustin/go-humanize"`, use it as `humanize`.
See [godoc](https://godoc.org/github.com/dustin/go-humanize) for
complete documentation.
## Sizes
This lets you take numbers like `82854982` and convert them to useful
strings like, `83 MB` or `79 MiB` (whichever you prefer).
Example:
```go
fmt.Printf("That file is %s.", humanize.Bytes(82854982)) // That file is 83 MB.
```
## Times
This lets you take a `time.Time` and spit it out in relative terms.
For example, `12 seconds ago` or `3 days from now`.
Example:
```go
fmt.Printf("This was touched %s.", humanize.Time(someTimeInstance)) // This was touched 7 hours ago.
```
Thanks to Kyle Lemons for the time implementation from an IRC
conversation one day. It's pretty neat.
## Ordinals
From a [mailing list discussion][odisc] where a user wanted to be able
to label ordinals.
0 -> 0th
1 -> 1st
2 -> 2nd
3 -> 3rd
4 -> 4th
[...]
Example:
```go
fmt.Printf("You're my %s best friend.", humanize.Ordinal(193)) // You are my 193rd best friend.
```
## Commas
Want to shove commas into numbers? Be my guest.
0 -> 0
100 -> 100
1000 -> 1,000
1000000000 -> 1,000,000,000
-100000 -> -100,000
Example:
```go
fmt.Printf("You owe $%s.\n", humanize.Comma(6582491)) // You owe $6,582,491.
```
## Ftoa
Nicer float64 formatter that removes trailing zeros.
```go
fmt.Printf("%f", 2.24) // 2.240000
fmt.Printf("%s", humanize.Ftoa(2.24)) // 2.24
fmt.Printf("%f", 2.0) // 2.000000
fmt.Printf("%s", humanize.Ftoa(2.0)) // 2
```
## SI notation
Format numbers with [SI notation][sinotation].
Example:
```go
humanize.SI(0.00000000223, "M") // 2.23 nM
```
## English-specific functions
The following functions are in the `humanize/english` subpackage.
### Plurals
Simple English pluralization
```go
english.PluralWord(1, "object", "") // object
english.PluralWord(42, "object", "") // objects
english.PluralWord(2, "bus", "") // buses
english.PluralWord(99, "locus", "loci") // loci
english.Plural(1, "object", "") // 1 object
english.Plural(42, "object", "") // 42 objects
english.Plural(2, "bus", "") // 2 buses
english.Plural(99, "locus", "loci") // 99 loci
```
### Word series
Format comma-separated words lists with conjuctions:
```go
english.WordSeries([]string{"foo"}, "and") // foo
english.WordSeries([]string{"foo", "bar"}, "and") // foo and bar
english.WordSeries([]string{"foo", "bar", "baz"}, "and") // foo, bar and baz
english.OxfordWordSeries([]string{"foo", "bar", "baz"}, "and") // foo, bar, and baz
```
[odisc]: https://groups.google.com/d/topic/golang-nuts/l8NhI74jl-4/discussion
[sinotation]: http://en.wikipedia.org/wiki/Metric_prefix

31
vendor/github.com/dustin/go-humanize/big.go generated vendored Normal file
View File

@@ -0,0 +1,31 @@
package humanize
import (
"math/big"
)
// order of magnitude (to a max order)
func oomm(n, b *big.Int, maxmag int) (float64, int) {
mag := 0
m := &big.Int{}
for n.Cmp(b) >= 0 {
n.DivMod(n, b, m)
mag++
if mag == maxmag && maxmag >= 0 {
break
}
}
return float64(n.Int64()) + (float64(m.Int64()) / float64(b.Int64())), mag
}
// total order of magnitude
// (same as above, but with no upper limit)
func oom(n, b *big.Int) (float64, int) {
mag := 0
m := &big.Int{}
for n.Cmp(b) >= 0 {
n.DivMod(n, b, m)
mag++
}
return float64(n.Int64()) + (float64(m.Int64()) / float64(b.Int64())), mag
}

173
vendor/github.com/dustin/go-humanize/bigbytes.go generated vendored Normal file
View File

@@ -0,0 +1,173 @@
package humanize
import (
"fmt"
"math/big"
"strings"
"unicode"
)
var (
bigIECExp = big.NewInt(1024)
// BigByte is one byte in bit.Ints
BigByte = big.NewInt(1)
// BigKiByte is 1,024 bytes in bit.Ints
BigKiByte = (&big.Int{}).Mul(BigByte, bigIECExp)
// BigMiByte is 1,024 k bytes in bit.Ints
BigMiByte = (&big.Int{}).Mul(BigKiByte, bigIECExp)
// BigGiByte is 1,024 m bytes in bit.Ints
BigGiByte = (&big.Int{}).Mul(BigMiByte, bigIECExp)
// BigTiByte is 1,024 g bytes in bit.Ints
BigTiByte = (&big.Int{}).Mul(BigGiByte, bigIECExp)
// BigPiByte is 1,024 t bytes in bit.Ints
BigPiByte = (&big.Int{}).Mul(BigTiByte, bigIECExp)
// BigEiByte is 1,024 p bytes in bit.Ints
BigEiByte = (&big.Int{}).Mul(BigPiByte, bigIECExp)
// BigZiByte is 1,024 e bytes in bit.Ints
BigZiByte = (&big.Int{}).Mul(BigEiByte, bigIECExp)
// BigYiByte is 1,024 z bytes in bit.Ints
BigYiByte = (&big.Int{}).Mul(BigZiByte, bigIECExp)
)
var (
bigSIExp = big.NewInt(1000)
// BigSIByte is one SI byte in big.Ints
BigSIByte = big.NewInt(1)
// BigKByte is 1,000 SI bytes in big.Ints
BigKByte = (&big.Int{}).Mul(BigSIByte, bigSIExp)
// BigMByte is 1,000 SI k bytes in big.Ints
BigMByte = (&big.Int{}).Mul(BigKByte, bigSIExp)
// BigGByte is 1,000 SI m bytes in big.Ints
BigGByte = (&big.Int{}).Mul(BigMByte, bigSIExp)
// BigTByte is 1,000 SI g bytes in big.Ints
BigTByte = (&big.Int{}).Mul(BigGByte, bigSIExp)
// BigPByte is 1,000 SI t bytes in big.Ints
BigPByte = (&big.Int{}).Mul(BigTByte, bigSIExp)
// BigEByte is 1,000 SI p bytes in big.Ints
BigEByte = (&big.Int{}).Mul(BigPByte, bigSIExp)
// BigZByte is 1,000 SI e bytes in big.Ints
BigZByte = (&big.Int{}).Mul(BigEByte, bigSIExp)
// BigYByte is 1,000 SI z bytes in big.Ints
BigYByte = (&big.Int{}).Mul(BigZByte, bigSIExp)
)
var bigBytesSizeTable = map[string]*big.Int{
"b": BigByte,
"kib": BigKiByte,
"kb": BigKByte,
"mib": BigMiByte,
"mb": BigMByte,
"gib": BigGiByte,
"gb": BigGByte,
"tib": BigTiByte,
"tb": BigTByte,
"pib": BigPiByte,
"pb": BigPByte,
"eib": BigEiByte,
"eb": BigEByte,
"zib": BigZiByte,
"zb": BigZByte,
"yib": BigYiByte,
"yb": BigYByte,
// Without suffix
"": BigByte,
"ki": BigKiByte,
"k": BigKByte,
"mi": BigMiByte,
"m": BigMByte,
"gi": BigGiByte,
"g": BigGByte,
"ti": BigTiByte,
"t": BigTByte,
"pi": BigPiByte,
"p": BigPByte,
"ei": BigEiByte,
"e": BigEByte,
"z": BigZByte,
"zi": BigZiByte,
"y": BigYByte,
"yi": BigYiByte,
}
var ten = big.NewInt(10)
func humanateBigBytes(s, base *big.Int, sizes []string) string {
if s.Cmp(ten) < 0 {
return fmt.Sprintf("%d B", s)
}
c := (&big.Int{}).Set(s)
val, mag := oomm(c, base, len(sizes)-1)
suffix := sizes[mag]
f := "%.0f %s"
if val < 10 {
f = "%.1f %s"
}
return fmt.Sprintf(f, val, suffix)
}
// BigBytes produces a human readable representation of an SI size.
//
// See also: ParseBigBytes.
//
// BigBytes(82854982) -> 83 MB
func BigBytes(s *big.Int) string {
sizes := []string{"B", "kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"}
return humanateBigBytes(s, bigSIExp, sizes)
}
// BigIBytes produces a human readable representation of an IEC size.
//
// See also: ParseBigBytes.
//
// BigIBytes(82854982) -> 79 MiB
func BigIBytes(s *big.Int) string {
sizes := []string{"B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"}
return humanateBigBytes(s, bigIECExp, sizes)
}
// ParseBigBytes parses a string representation of bytes into the number
// of bytes it represents.
//
// See also: BigBytes, BigIBytes.
//
// ParseBigBytes("42 MB") -> 42000000, nil
// ParseBigBytes("42 mib") -> 44040192, nil
func ParseBigBytes(s string) (*big.Int, error) {
lastDigit := 0
hasComma := false
for _, r := range s {
if !(unicode.IsDigit(r) || r == '.' || r == ',') {
break
}
if r == ',' {
hasComma = true
}
lastDigit++
}
num := s[:lastDigit]
if hasComma {
num = strings.Replace(num, ",", "", -1)
}
val := &big.Rat{}
_, err := fmt.Sscanf(num, "%f", val)
if err != nil {
return nil, err
}
extra := strings.ToLower(strings.TrimSpace(s[lastDigit:]))
if m, ok := bigBytesSizeTable[extra]; ok {
mv := (&big.Rat{}).SetInt(m)
val.Mul(val, mv)
rv := &big.Int{}
rv.Div(val.Num(), val.Denom())
return rv, nil
}
return nil, fmt.Errorf("unhandled size name: %v", extra)
}

143
vendor/github.com/dustin/go-humanize/bytes.go generated vendored Normal file
View File

@@ -0,0 +1,143 @@
package humanize
import (
"fmt"
"math"
"strconv"
"strings"
"unicode"
)
// IEC Sizes.
// kibis of bits
const (
Byte = 1 << (iota * 10)
KiByte
MiByte
GiByte
TiByte
PiByte
EiByte
)
// SI Sizes.
const (
IByte = 1
KByte = IByte * 1000
MByte = KByte * 1000
GByte = MByte * 1000
TByte = GByte * 1000
PByte = TByte * 1000
EByte = PByte * 1000
)
var bytesSizeTable = map[string]uint64{
"b": Byte,
"kib": KiByte,
"kb": KByte,
"mib": MiByte,
"mb": MByte,
"gib": GiByte,
"gb": GByte,
"tib": TiByte,
"tb": TByte,
"pib": PiByte,
"pb": PByte,
"eib": EiByte,
"eb": EByte,
// Without suffix
"": Byte,
"ki": KiByte,
"k": KByte,
"mi": MiByte,
"m": MByte,
"gi": GiByte,
"g": GByte,
"ti": TiByte,
"t": TByte,
"pi": PiByte,
"p": PByte,
"ei": EiByte,
"e": EByte,
}
func logn(n, b float64) float64 {
return math.Log(n) / math.Log(b)
}
func humanateBytes(s uint64, base float64, sizes []string) string {
if s < 10 {
return fmt.Sprintf("%d B", s)
}
e := math.Floor(logn(float64(s), base))
suffix := sizes[int(e)]
val := math.Floor(float64(s)/math.Pow(base, e)*10+0.5) / 10
f := "%.0f %s"
if val < 10 {
f = "%.1f %s"
}
return fmt.Sprintf(f, val, suffix)
}
// Bytes produces a human readable representation of an SI size.
//
// See also: ParseBytes.
//
// Bytes(82854982) -> 83 MB
func Bytes(s uint64) string {
sizes := []string{"B", "kB", "MB", "GB", "TB", "PB", "EB"}
return humanateBytes(s, 1000, sizes)
}
// IBytes produces a human readable representation of an IEC size.
//
// See also: ParseBytes.
//
// IBytes(82854982) -> 79 MiB
func IBytes(s uint64) string {
sizes := []string{"B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"}
return humanateBytes(s, 1024, sizes)
}
// ParseBytes parses a string representation of bytes into the number
// of bytes it represents.
//
// See Also: Bytes, IBytes.
//
// ParseBytes("42 MB") -> 42000000, nil
// ParseBytes("42 mib") -> 44040192, nil
func ParseBytes(s string) (uint64, error) {
lastDigit := 0
hasComma := false
for _, r := range s {
if !(unicode.IsDigit(r) || r == '.' || r == ',') {
break
}
if r == ',' {
hasComma = true
}
lastDigit++
}
num := s[:lastDigit]
if hasComma {
num = strings.Replace(num, ",", "", -1)
}
f, err := strconv.ParseFloat(num, 64)
if err != nil {
return 0, err
}
extra := strings.ToLower(strings.TrimSpace(s[lastDigit:]))
if m, ok := bytesSizeTable[extra]; ok {
f *= float64(m)
if f >= math.MaxUint64 {
return 0, fmt.Errorf("too large: %v", s)
}
return uint64(f), nil
}
return 0, fmt.Errorf("unhandled size name: %v", extra)
}

116
vendor/github.com/dustin/go-humanize/comma.go generated vendored Normal file
View File

@@ -0,0 +1,116 @@
package humanize
import (
"bytes"
"math"
"math/big"
"strconv"
"strings"
)
// Comma produces a string form of the given number in base 10 with
// commas after every three orders of magnitude.
//
// e.g. Comma(834142) -> 834,142
func Comma(v int64) string {
sign := ""
// Min int64 can't be negated to a usable value, so it has to be special cased.
if v == math.MinInt64 {
return "-9,223,372,036,854,775,808"
}
if v < 0 {
sign = "-"
v = 0 - v
}
parts := []string{"", "", "", "", "", "", ""}
j := len(parts) - 1
for v > 999 {
parts[j] = strconv.FormatInt(v%1000, 10)
switch len(parts[j]) {
case 2:
parts[j] = "0" + parts[j]
case 1:
parts[j] = "00" + parts[j]
}
v = v / 1000
j--
}
parts[j] = strconv.Itoa(int(v))
return sign + strings.Join(parts[j:], ",")
}
// Commaf produces a string form of the given number in base 10 with
// commas after every three orders of magnitude.
//
// e.g. Commaf(834142.32) -> 834,142.32
func Commaf(v float64) string {
buf := &bytes.Buffer{}
if v < 0 {
buf.Write([]byte{'-'})
v = 0 - v
}
comma := []byte{','}
parts := strings.Split(strconv.FormatFloat(v, 'f', -1, 64), ".")
pos := 0
if len(parts[0])%3 != 0 {
pos += len(parts[0]) % 3
buf.WriteString(parts[0][:pos])
buf.Write(comma)
}
for ; pos < len(parts[0]); pos += 3 {
buf.WriteString(parts[0][pos : pos+3])
buf.Write(comma)
}
buf.Truncate(buf.Len() - 1)
if len(parts) > 1 {
buf.Write([]byte{'.'})
buf.WriteString(parts[1])
}
return buf.String()
}
// CommafWithDigits works like the Commaf but limits the resulting
// string to the given number of decimal places.
//
// e.g. CommafWithDigits(834142.32, 1) -> 834,142.3
func CommafWithDigits(f float64, decimals int) string {
return stripTrailingDigits(Commaf(f), decimals)
}
// BigComma produces a string form of the given big.Int in base 10
// with commas after every three orders of magnitude.
func BigComma(b *big.Int) string {
sign := ""
if b.Sign() < 0 {
sign = "-"
b.Abs(b)
}
athousand := big.NewInt(1000)
c := (&big.Int{}).Set(b)
_, m := oom(c, athousand)
parts := make([]string, m+1)
j := len(parts) - 1
mod := &big.Int{}
for b.Cmp(athousand) >= 0 {
b.DivMod(b, athousand, mod)
parts[j] = strconv.FormatInt(mod.Int64(), 10)
switch len(parts[j]) {
case 2:
parts[j] = "0" + parts[j]
case 1:
parts[j] = "00" + parts[j]
}
j--
}
parts[j] = strconv.Itoa(int(b.Int64()))
return sign + strings.Join(parts[j:], ",")
}

40
vendor/github.com/dustin/go-humanize/commaf.go generated vendored Normal file
View File

@@ -0,0 +1,40 @@
// +build go1.6
package humanize
import (
"bytes"
"math/big"
"strings"
)
// BigCommaf produces a string form of the given big.Float in base 10
// with commas after every three orders of magnitude.
func BigCommaf(v *big.Float) string {
buf := &bytes.Buffer{}
if v.Sign() < 0 {
buf.Write([]byte{'-'})
v.Abs(v)
}
comma := []byte{','}
parts := strings.Split(v.Text('f', -1), ".")
pos := 0
if len(parts[0])%3 != 0 {
pos += len(parts[0]) % 3
buf.WriteString(parts[0][:pos])
buf.Write(comma)
}
for ; pos < len(parts[0]); pos += 3 {
buf.WriteString(parts[0][pos : pos+3])
buf.Write(comma)
}
buf.Truncate(buf.Len() - 1)
if len(parts) > 1 {
buf.Write([]byte{'.'})
buf.WriteString(parts[1])
}
return buf.String()
}

46
vendor/github.com/dustin/go-humanize/ftoa.go generated vendored Normal file
View File

@@ -0,0 +1,46 @@
package humanize
import (
"strconv"
"strings"
)
func stripTrailingZeros(s string) string {
offset := len(s) - 1
for offset > 0 {
if s[offset] == '.' {
offset--
break
}
if s[offset] != '0' {
break
}
offset--
}
return s[:offset+1]
}
func stripTrailingDigits(s string, digits int) string {
if i := strings.Index(s, "."); i >= 0 {
if digits <= 0 {
return s[:i]
}
i++
if i+digits >= len(s) {
return s
}
return s[:i+digits]
}
return s
}
// Ftoa converts a float to a string with no trailing zeros.
func Ftoa(num float64) string {
return stripTrailingZeros(strconv.FormatFloat(num, 'f', 6, 64))
}
// FtoaWithDigits converts a float to a string but limits the resulting string
// to the given number of decimal places, and no trailing zeros.
func FtoaWithDigits(num float64, digits int) string {
return stripTrailingZeros(stripTrailingDigits(strconv.FormatFloat(num, 'f', 6, 64), digits))
}

8
vendor/github.com/dustin/go-humanize/humanize.go generated vendored Normal file
View File

@@ -0,0 +1,8 @@
/*
Package humanize converts boring ugly numbers to human-friendly strings and back.
Durations can be turned into strings such as "3 days ago", numbers
representing sizes like 82854982 into useful strings like, "83 MB" or
"79 MiB" (whichever you prefer).
*/
package humanize

192
vendor/github.com/dustin/go-humanize/number.go generated vendored Normal file
View File

@@ -0,0 +1,192 @@
package humanize
/*
Slightly adapted from the source to fit go-humanize.
Author: https://github.com/gorhill
Source: https://gist.github.com/gorhill/5285193
*/
import (
"math"
"strconv"
)
var (
renderFloatPrecisionMultipliers = [...]float64{
1,
10,
100,
1000,
10000,
100000,
1000000,
10000000,
100000000,
1000000000,
}
renderFloatPrecisionRounders = [...]float64{
0.5,
0.05,
0.005,
0.0005,
0.00005,
0.000005,
0.0000005,
0.00000005,
0.000000005,
0.0000000005,
}
)
// FormatFloat produces a formatted number as string based on the following user-specified criteria:
// * thousands separator
// * decimal separator
// * decimal precision
//
// Usage: s := RenderFloat(format, n)
// The format parameter tells how to render the number n.
//
// See examples: http://play.golang.org/p/LXc1Ddm1lJ
//
// Examples of format strings, given n = 12345.6789:
// "#,###.##" => "12,345.67"
// "#,###." => "12,345"
// "#,###" => "12345,678"
// "#\u202F###,##" => "12345,68"
// "#.###,###### => 12.345,678900
// "" (aka default format) => 12,345.67
//
// The highest precision allowed is 9 digits after the decimal symbol.
// There is also a version for integer number, FormatInteger(),
// which is convenient for calls within template.
func FormatFloat(format string, n float64) string {
// Special cases:
// NaN = "NaN"
// +Inf = "+Infinity"
// -Inf = "-Infinity"
if math.IsNaN(n) {
return "NaN"
}
if n > math.MaxFloat64 {
return "Infinity"
}
if n < -math.MaxFloat64 {
return "-Infinity"
}
// default format
precision := 2
decimalStr := "."
thousandStr := ","
positiveStr := ""
negativeStr := "-"
if len(format) > 0 {
format := []rune(format)
// If there is an explicit format directive,
// then default values are these:
precision = 9
thousandStr = ""
// collect indices of meaningful formatting directives
formatIndx := []int{}
for i, char := range format {
if char != '#' && char != '0' {
formatIndx = append(formatIndx, i)
}
}
if len(formatIndx) > 0 {
// Directive at index 0:
// Must be a '+'
// Raise an error if not the case
// index: 0123456789
// +0.000,000
// +000,000.0
// +0000.00
// +0000
if formatIndx[0] == 0 {
if format[formatIndx[0]] != '+' {
panic("RenderFloat(): invalid positive sign directive")
}
positiveStr = "+"
formatIndx = formatIndx[1:]
}
// Two directives:
// First is thousands separator
// Raise an error if not followed by 3-digit
// 0123456789
// 0.000,000
// 000,000.00
if len(formatIndx) == 2 {
if (formatIndx[1] - formatIndx[0]) != 4 {
panic("RenderFloat(): thousands separator directive must be followed by 3 digit-specifiers")
}
thousandStr = string(format[formatIndx[0]])
formatIndx = formatIndx[1:]
}
// One directive:
// Directive is decimal separator
// The number of digit-specifier following the separator indicates wanted precision
// 0123456789
// 0.00
// 000,0000
if len(formatIndx) == 1 {
decimalStr = string(format[formatIndx[0]])
precision = len(format) - formatIndx[0] - 1
}
}
}
// generate sign part
var signStr string
if n >= 0.000000001 {
signStr = positiveStr
} else if n <= -0.000000001 {
signStr = negativeStr
n = -n
} else {
signStr = ""
n = 0.0
}
// split number into integer and fractional parts
intf, fracf := math.Modf(n + renderFloatPrecisionRounders[precision])
// generate integer part string
intStr := strconv.FormatInt(int64(intf), 10)
// add thousand separator if required
if len(thousandStr) > 0 {
for i := len(intStr); i > 3; {
i -= 3
intStr = intStr[:i] + thousandStr + intStr[i:]
}
}
// no fractional part, we can leave now
if precision == 0 {
return signStr + intStr
}
// generate fractional part
fracStr := strconv.Itoa(int(fracf * renderFloatPrecisionMultipliers[precision]))
// may need padding
if len(fracStr) < precision {
fracStr = "000000000000000"[:precision-len(fracStr)] + fracStr
}
return signStr + intStr + decimalStr + fracStr
}
// FormatInteger produces a formatted number as string.
// See FormatFloat.
func FormatInteger(format string, n int) string {
return FormatFloat(format, float64(n))
}

25
vendor/github.com/dustin/go-humanize/ordinals.go generated vendored Normal file
View File

@@ -0,0 +1,25 @@
package humanize
import "strconv"
// Ordinal gives you the input number in a rank/ordinal format.
//
// Ordinal(3) -> 3rd
func Ordinal(x int) string {
suffix := "th"
switch x % 10 {
case 1:
if x%100 != 11 {
suffix = "st"
}
case 2:
if x%100 != 12 {
suffix = "nd"
}
case 3:
if x%100 != 13 {
suffix = "rd"
}
}
return strconv.Itoa(x) + suffix
}

123
vendor/github.com/dustin/go-humanize/si.go generated vendored Normal file
View File

@@ -0,0 +1,123 @@
package humanize
import (
"errors"
"math"
"regexp"
"strconv"
)
var siPrefixTable = map[float64]string{
-24: "y", // yocto
-21: "z", // zepto
-18: "a", // atto
-15: "f", // femto
-12: "p", // pico
-9: "n", // nano
-6: "µ", // micro
-3: "m", // milli
0: "",
3: "k", // kilo
6: "M", // mega
9: "G", // giga
12: "T", // tera
15: "P", // peta
18: "E", // exa
21: "Z", // zetta
24: "Y", // yotta
}
var revSIPrefixTable = revfmap(siPrefixTable)
// revfmap reverses the map and precomputes the power multiplier
func revfmap(in map[float64]string) map[string]float64 {
rv := map[string]float64{}
for k, v := range in {
rv[v] = math.Pow(10, k)
}
return rv
}
var riParseRegex *regexp.Regexp
func init() {
ri := `^([\-0-9.]+)\s?([`
for _, v := range siPrefixTable {
ri += v
}
ri += `]?)(.*)`
riParseRegex = regexp.MustCompile(ri)
}
// ComputeSI finds the most appropriate SI prefix for the given number
// and returns the prefix along with the value adjusted to be within
// that prefix.
//
// See also: SI, ParseSI.
//
// e.g. ComputeSI(2.2345e-12) -> (2.2345, "p")
func ComputeSI(input float64) (float64, string) {
if input == 0 {
return 0, ""
}
mag := math.Abs(input)
exponent := math.Floor(logn(mag, 10))
exponent = math.Floor(exponent/3) * 3
value := mag / math.Pow(10, exponent)
// Handle special case where value is exactly 1000.0
// Should return 1 M instead of 1000 k
if value == 1000.0 {
exponent += 3
value = mag / math.Pow(10, exponent)
}
value = math.Copysign(value, input)
prefix := siPrefixTable[exponent]
return value, prefix
}
// SI returns a string with default formatting.
//
// SI uses Ftoa to format float value, removing trailing zeros.
//
// See also: ComputeSI, ParseSI.
//
// e.g. SI(1000000, "B") -> 1 MB
// e.g. SI(2.2345e-12, "F") -> 2.2345 pF
func SI(input float64, unit string) string {
value, prefix := ComputeSI(input)
return Ftoa(value) + " " + prefix + unit
}
// SIWithDigits works like SI but limits the resulting string to the
// given number of decimal places.
//
// e.g. SIWithDigits(1000000, 0, "B") -> 1 MB
// e.g. SIWithDigits(2.2345e-12, 2, "F") -> 2.23 pF
func SIWithDigits(input float64, decimals int, unit string) string {
value, prefix := ComputeSI(input)
return FtoaWithDigits(value, decimals) + " " + prefix + unit
}
var errInvalid = errors.New("invalid input")
// ParseSI parses an SI string back into the number and unit.
//
// See also: SI, ComputeSI.
//
// e.g. ParseSI("2.2345 pF") -> (2.2345e-12, "F", nil)
func ParseSI(input string) (float64, string, error) {
found := riParseRegex.FindStringSubmatch(input)
if len(found) != 4 {
return 0, "", errInvalid
}
mag := revSIPrefixTable[found[2]]
unit := found[3]
base, err := strconv.ParseFloat(found[1], 64)
return base * mag, unit, err
}

117
vendor/github.com/dustin/go-humanize/times.go generated vendored Normal file
View File

@@ -0,0 +1,117 @@
package humanize
import (
"fmt"
"math"
"sort"
"time"
)
// Seconds-based time units
const (
Day = 24 * time.Hour
Week = 7 * Day
Month = 30 * Day
Year = 12 * Month
LongTime = 37 * Year
)
// Time formats a time into a relative string.
//
// Time(someT) -> "3 weeks ago"
func Time(then time.Time) string {
return RelTime(then, time.Now(), "ago", "from now")
}
// A RelTimeMagnitude struct contains a relative time point at which
// the relative format of time will switch to a new format string. A
// slice of these in ascending order by their "D" field is passed to
// CustomRelTime to format durations.
//
// The Format field is a string that may contain a "%s" which will be
// replaced with the appropriate signed label (e.g. "ago" or "from
// now") and a "%d" that will be replaced by the quantity.
//
// The DivBy field is the amount of time the time difference must be
// divided by in order to display correctly.
//
// e.g. if D is 2*time.Minute and you want to display "%d minutes %s"
// DivBy should be time.Minute so whatever the duration is will be
// expressed in minutes.
type RelTimeMagnitude struct {
D time.Duration
Format string
DivBy time.Duration
}
var defaultMagnitudes = []RelTimeMagnitude{
{time.Second, "now", time.Second},
{2 * time.Second, "1 second %s", 1},
{time.Minute, "%d seconds %s", time.Second},
{2 * time.Minute, "1 minute %s", 1},
{time.Hour, "%d minutes %s", time.Minute},
{2 * time.Hour, "1 hour %s", 1},
{Day, "%d hours %s", time.Hour},
{2 * Day, "1 day %s", 1},
{Week, "%d days %s", Day},
{2 * Week, "1 week %s", 1},
{Month, "%d weeks %s", Week},
{2 * Month, "1 month %s", 1},
{Year, "%d months %s", Month},
{18 * Month, "1 year %s", 1},
{2 * Year, "2 years %s", 1},
{LongTime, "%d years %s", Year},
{math.MaxInt64, "a long while %s", 1},
}
// RelTime formats a time into a relative string.
//
// It takes two times and two labels. In addition to the generic time
// delta string (e.g. 5 minutes), the labels are used applied so that
// the label corresponding to the smaller time is applied.
//
// RelTime(timeInPast, timeInFuture, "earlier", "later") -> "3 weeks earlier"
func RelTime(a, b time.Time, albl, blbl string) string {
return CustomRelTime(a, b, albl, blbl, defaultMagnitudes)
}
// CustomRelTime formats a time into a relative string.
//
// It takes two times two labels and a table of relative time formats.
// In addition to the generic time delta string (e.g. 5 minutes), the
// labels are used applied so that the label corresponding to the
// smaller time is applied.
func CustomRelTime(a, b time.Time, albl, blbl string, magnitudes []RelTimeMagnitude) string {
lbl := albl
diff := b.Sub(a)
if a.After(b) {
lbl = blbl
diff = a.Sub(b)
}
n := sort.Search(len(magnitudes), func(i int) bool {
return magnitudes[i].D > diff
})
if n >= len(magnitudes) {
n = len(magnitudes) - 1
}
mag := magnitudes[n]
args := []interface{}{}
escaped := false
for _, ch := range mag.Format {
if escaped {
switch ch {
case 's':
args = append(args, lbl)
case 'd':
args = append(args, diff/mag.DivBy)
}
escaped = false
} else {
escaped = ch == '%'
}
}
return fmt.Sprintf(mag.Format, args...)
}