From 2429fde4406e5c1358a47e064a9e31412685c397 Mon Sep 17 00:00:00 2001 From: smallchungus Date: Sat, 18 Apr 2026 09:14:36 -0400 Subject: [PATCH 1/2] feat(pdf): add Gotenberg client with RenderHTML --- internal/pdf/client.go | 59 ++++++++++++++++++++ internal/pdf/client_integration_test.go | 72 +++++++++++++++++++++++++ 2 files changed, 131 insertions(+) create mode 100644 internal/pdf/client.go create mode 100644 internal/pdf/client_integration_test.go diff --git a/internal/pdf/client.go b/internal/pdf/client.go new file mode 100644 index 0000000..2621737 --- /dev/null +++ b/internal/pdf/client.go @@ -0,0 +1,59 @@ +package pdf + +import ( + "bytes" + "context" + "fmt" + "io" + "mime/multipart" + "net/http" + "time" +) + +type Client struct { + endpoint string + http *http.Client +} + +func New(endpoint string) *Client { + return &Client{endpoint: endpoint, http: &http.Client{Timeout: 60 * time.Second}} +} + +func (c *Client) RenderHTML(ctx context.Context, html []byte) ([]byte, error) { + body := &bytes.Buffer{} + w := multipart.NewWriter(body) + + part, err := w.CreateFormFile("files", "index.html") + if err != nil { + return nil, fmt.Errorf("form file: %w", err) + } + if _, err := part.Write(html); err != nil { + return nil, fmt.Errorf("write html: %w", err) + } + if err := w.Close(); err != nil { + return nil, fmt.Errorf("close writer: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint+"/forms/chromium/convert/html", body) + if err != nil { + return nil, fmt.Errorf("new request: %w", err) + } + req.Header.Set("Content-Type", w.FormDataContentType()) + + resp, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("post: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode/100 != 2 { + errBody, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("gotenberg %d: %s", resp.StatusCode, string(errBody)) + } + + pdf, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read body: %w", err) + } + return pdf, nil +} diff --git a/internal/pdf/client_integration_test.go b/internal/pdf/client_integration_test.go new file mode 100644 index 0000000..22852e1 --- /dev/null +++ b/internal/pdf/client_integration_test.go @@ -0,0 +1,72 @@ +//go:build integration + +package pdf_test + +import ( + "context" + "io" + "mime" + "mime/multipart" + "net/http" + "net/http/httptest" + "testing" + + "github.com/smallchungus/disttaskqueue/internal/pdf" +) + +func TestRenderHTML_PostsMultipartAndReturnsPDF(t *testing.T) { + const fakePDF = "%PDF-1.7\nfake\n%%EOF" + var gotForm map[string]string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/forms/chromium/convert/html" { + t.Errorf("path: %s", r.URL.Path) + } + _, params, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + if err != nil { + t.Fatalf("ct: %v", err) + } + mr := multipart.NewReader(r.Body, params["boundary"]) + gotForm = map[string]string{} + for { + p, err := mr.NextPart() + if err == io.EOF { + break + } + if err != nil { + t.Fatal(err) + } + b, _ := io.ReadAll(p) + gotForm[p.FileName()] = string(b) + } + w.Header().Set("Content-Type", "application/pdf") + _, _ = w.Write([]byte(fakePDF)) + })) + defer srv.Close() + + c := pdf.New(srv.URL) + got, err := c.RenderHTML(context.Background(), []byte("hi")) + if err != nil { + t.Fatalf("render: %v", err) + } + if string(got) != fakePDF { + t.Fatalf("got %q, want %q", got, fakePDF) + } + if v := gotForm["index.html"]; v != "hi" { + t.Fatalf("index.html: %q", v) + } +} + +func TestRenderHTML_ReturnsErrorOnNon2xx(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte("bad html")) + })) + defer srv.Close() + + c := pdf.New(srv.URL) + _, err := c.RenderHTML(context.Background(), []byte("garbage")) + if err == nil { + t.Fatal("expected error") + } +} From 7b7513740228285e063107d85603651eb1eca29b Mon Sep 17 00:00:00 2001 From: smallchungus Date: Sat, 18 Apr 2026 09:15:42 -0400 Subject: [PATCH 2/2] chore(compose): add gotenberg PDF render sidecar --- docker-compose.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docker-compose.yaml b/docker-compose.yaml index e105332..bd1cee4 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -27,6 +27,11 @@ services: timeout: 2s retries: 10 + gotenberg: + image: gotenberg/gotenberg:8 + ports: + - "3000:3000" + api: build: context: .