Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ __pycache__/
.pytest_cache/
.tox/
/client/go/kwkhtmltopdf_client
/client/go/kwkhtmltoimage_client
/server/kwkhtmltopdf_server
29 changes: 20 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,26 +31,30 @@ the above server defined in the `KWKHTMLTOPDF_SERVER_URL` environment variable.

There are two clients:

* a go client (preferred)
* a python client, which only depends on the `requests` library.
- go clients (preferred):
- PDF: `client/go/pdf/kwkhtmltopdf_client.go`
- Image: `client/go/image/kwkhtmltoimage_client.go`
- a python client, which only depends on the `requests` library.
It should work with any python version supported by `requests`.

## Quick start

### Run the server

```
```sh
$ docker run --rm -p 8080:8080 ghcr.io/acsone/kwkhtmltopdf:0.12.6.1-latest
```

or

```
```sh
$ go run server/kwkhtmltopdf_server.go
```

The server should now listen on http://localhost:8080.

Available endpoints: `/` (PDF), `/pdf` (PDF), `/image` (Image), `/status` (Health check), `/metrics` (Prometheus).

#### Note for Apple Silicon users

The docker image is built for amd64. If you are on Apple Silicon,
Expand All @@ -63,22 +67,29 @@ Any of the following should generate a printout of the wkhtmltopdf home page to

#### Using the built binary

```
$ go build -o client/go/kwkhtmltopdf_client client/go/kwkhtmltopdf_client.go
```sh
$ go build -o client/go/kwkhtmltopdf_client client/go/pdf/kwkhtmltopdf_client.go
$ go build -o client/go/kwkhtmltoimage_client client/go/image/kwkhtmltoimage_client.go
$ env KWKHTMLTOPDF_SERVER_URL=http://localhost:8080 \
client/go/kwkhtmltopdf_client https://wkhtmltopdf.org /tmp/test.pdf

$ env KWKHTMLTOPDF_SERVER_URL=http://localhost:8080 \
client/go/kwkhtmltoimage_client https://wkhtmltopdf.org /tmp/test.png
```

#### Using the Go client

```
```sh
$ env KWKHTMLTOPDF_SERVER_URL=http://localhost:8080 \
go run client/go/kwkhtmltopdf_client.go https://wkhtmltopdf.org /tmp/test.pdf
go run client/go/pdf/kwkhtmltopdf_client.go https://wkhtmltopdf.org /tmp/test.pdf

$ env KWKHTMLTOPDF_SERVER_URL=http://localhost:8080 \
go run client/go/image/kwkhtmltoimage_client.go https://wkhtmltopdf.org /tmp/test.png
```

#### Using the Python client

```
```sh
$ env KWKHTMLTOPDF_SERVER_URL=http://localhost:8080 \
client/python/kwkhtmltopdf_client.py https://wkhtmltopdf.org /tmp/test.pdf
```
Expand Down
22 changes: 22 additions & 0 deletions client/go/image/kwkhtmltoimage_client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// Copyright (c) 2019 ACSONE SA/NV
// Distributed under the MIT License (http://opensource.org/licenses/MIT)

package main

import (
"os"

"github.com/acsone/kwkhtmltopdf/client/go/kwkhtmlclient"
)

func main() {
serverURL, err := kwkhtmlclient.ServerURLFromEnv()
if err == nil {
err = kwkhtmlclient.Run(serverURL, "/image", os.Args[1:], os.Stdout)
}
if err != nil {
os.Stderr.WriteString(err.Error())
os.Stderr.WriteString("\n")
os.Exit(-1)
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright (c) 2019 ACSONE SA/NV
// Distributed under the MIT License (http://opensource.org/licenses/MIT)

package main
package kwkhtmlclient

import (
"bytes"
Expand All @@ -15,6 +15,16 @@ import (

const chunkSize = 32 * 1024

var ErrServerURLNotSet = errors.New("KWKHTMLTOPDF_SERVER_URL not set")

func ServerURLFromEnv() (string, error) {
serverURL := os.Getenv("KWKHTMLTOPDF_SERVER_URL")
if serverURL == "" {
return "", ErrServerURLNotSet
}
return serverURL, nil
}

func addOption(w *multipart.Writer, option string) error {
return w.WriteField("option", option)
}
Expand All @@ -33,35 +43,36 @@ func addFile(w *multipart.Writer, filename string) error {
return err
}

func do() error {
var err error
var out *os.File

serverURL := os.Getenv("KWKHTMLTOPDF_SERVER_URL")
// Run performs a request against the given endpoint (e.g. "/pdf" or "/image")
// on the server at serverURL.
//
// The behavior matches the original single-file Go client:
// - if args is empty, "-h" is sent
// - if the last argument looks like an output file, it is created and used
// - file arguments are sent as multipart file parts
func Run(serverURL, endpointPath string, args []string, stdout io.Writer) error {
if serverURL == "" {
return errors.New("KWKHTMLTOPDF_SERVER_URL not set")
return ErrServerURLNotSet
}

// detect if last argument is output file, and create it
args := os.Args[1:]
if len(args) == 0 {
args = []string{"-h"}
}

out := stdout
if len(args) >= 2 && !strings.HasPrefix(args[len(args)-1], "-") && !strings.HasPrefix(args[len(args)-2], "-") {
out, err = os.Create(args[len(args)-1])
file, err := os.Create(args[len(args)-1])
if err != nil {
return err
}
defer out.Close()
defer file.Close()
out = file
args = args[:len(args)-1]
} else {
out = os.Stdout
}

// prepare request
var postBuf bytes.Buffer
w := multipart.NewWriter(&postBuf)
for _, arg := range args {
var err error
if arg == "-" {
return errors.New("stdin/stdout input is not implemented")
} else if strings.HasPrefix(arg, "-") {
Expand All @@ -72,7 +83,7 @@ func do() error {
err = addOption(w, arg)
} else if strings.HasPrefix(arg, "file://") {
err = addFile(w, arg[7:])
} else if _, err := os.Stat(arg); err == nil {
} else if _, statErr := os.Stat(arg); statErr == nil {
// TODO: better way to detect file arguments
err = addFile(w, arg)
} else {
Expand All @@ -82,10 +93,10 @@ func do() error {
return err
}
}
w.Close()
_ = w.Close()

// post request
resp, err := http.Post(serverURL, w.FormDataContentType(), &postBuf)
endpoint := serverURL + endpointPath
resp, err := http.Post(endpoint, w.FormDataContentType(), &postBuf)
if err != nil {
return err
}
Expand All @@ -94,7 +105,6 @@ func do() error {
return errors.New("server error, consult server log for details")
}

// read response
respBuf := make([]byte, chunkSize)
for {
nr, er := resp.Body.Read(respBuf)
Expand All @@ -114,12 +124,3 @@ func do() error {

return nil
}

func main() {
err := do()
if err != nil {
os.Stderr.WriteString(err.Error())
os.Stderr.WriteString("\n")
os.Exit(-1)
}
}
139 changes: 139 additions & 0 deletions client/go/kwkhtmlclient/client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package kwkhtmlclient

import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)

func TestRun_ServerURLNotSet(t *testing.T) {
var out bytes.Buffer
err := Run("", "/pdf", []string{"-h"}, &out)
if err != ErrServerURLNotSet {
t.Fatalf("expected ErrServerURLNotSet, got %v", err)
}
}

func TestRun_SendsOptionsAndWritesStdout(t *testing.T) {
var gotPath string
var gotOptions []string

ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
if err := r.ParseMultipartForm(10 << 20); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
gotOptions = append([]string(nil), r.MultipartForm.Value["option"]...)
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
}))
defer ts.Close()

var out bytes.Buffer
if err := Run(ts.URL, "/pdf", []string{"-h"}, &out); err != nil {
t.Fatalf("Run returned error: %v", err)
}
if gotPath != "/pdf" {
t.Fatalf("expected path /pdf, got %q", gotPath)
}
if out.String() != "ok" {
t.Fatalf("expected stdout %q, got %q", "ok", out.String())
}
if len(gotOptions) != 1 || gotOptions[0] != "-h" {
t.Fatalf("expected options [-h], got %v", gotOptions)
}
}

func TestRun_SendsFileArgumentAsMultipartFile(t *testing.T) {
inPath := filepath.Join(t.TempDir(), "input.html")
if err := os.WriteFile(inPath, []byte("hello"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}

var gotFilename string
var gotContent []byte

ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(10 << 20); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
files := r.MultipartForm.File["file"]
if len(files) != 1 {
http.Error(w, "expected one file", http.StatusBadRequest)
return
}
gotFilename = files[0].Filename
f, err := files[0].Open()
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
defer f.Close()
gotContent, _ = io.ReadAll(f)

w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
}))
defer ts.Close()

var out bytes.Buffer
if err := Run(ts.URL, "/pdf", []string{inPath}, &out); err != nil {
t.Fatalf("Run returned error: %v", err)
}
if out.String() != "ok" {
t.Fatalf("expected stdout %q, got %q", "ok", out.String())
}
if !strings.HasSuffix(gotFilename, filepath.Base(inPath)) {
t.Fatalf("expected filename to end with %q, got %q", filepath.Base(inPath), gotFilename)
}
if string(gotContent) != "hello" {
t.Fatalf("expected file content %q, got %q", "hello", string(gotContent))
}
}

func TestRun_WritesToOutputFile(t *testing.T) {
outPath := filepath.Join(t.TempDir(), "out.bin")

ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/image" {
http.Error(w, "wrong path", http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("data"))
}))
defer ts.Close()

// Two trailing non-dash args: last one is treated as output file.
args := []string{"https://example.invalid", outPath}
var stdout bytes.Buffer
if err := Run(ts.URL, "/image", args, &stdout); err != nil {
t.Fatalf("Run returned error: %v", err)
}
if stdout.Len() != 0 {
t.Fatalf("expected no stdout output, got %q", stdout.String())
}

b, err := os.ReadFile(outPath)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
if string(b) != "data" {
t.Fatalf("expected output file %q, got %q", "data", string(b))
}
}

func TestRun_StdinNotImplemented(t *testing.T) {
var stdout bytes.Buffer
err := Run("http://example.invalid", "/pdf", []string{"-"}, &stdout)
if err == nil || err.Error() != "stdin/stdout input is not implemented" {
t.Fatalf("expected stdin/stdout error, got %v", err)
}
}
22 changes: 22 additions & 0 deletions client/go/pdf/kwkhtmltopdf_client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// Copyright (c) 2019 ACSONE SA/NV
// Distributed under the MIT License (http://opensource.org/licenses/MIT)

package main

import (
"os"

"github.com/acsone/kwkhtmltopdf/client/go/kwkhtmlclient"
)

func main() {
serverURL, err := kwkhtmlclient.ServerURLFromEnv()
if err == nil {
err = kwkhtmlclient.Run(serverURL, "/pdf", os.Args[1:], os.Stdout)
}
if err != nil {
os.Stderr.WriteString(err.Error())
os.Stderr.WriteString("\n")
os.Exit(-1)
}
}
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module github.com/acsone/kwkhtmltopdf

go 1.18
Loading
Loading