feat(webhook): add events

This commit is contained in:
Julien Neuhart
2026-03-28 19:00:07 +01:00
parent 043b1588de
commit 385cbe6590
26 changed files with 224 additions and 5 deletions

View File

@@ -63,6 +63,7 @@ make test-integration PLATFORM=linux/arm64 # Force a specific platform
- `the (response|webhook request) body should match string:` (docstring)
- `the (response|webhook request) body should contain string:` (docstring)
- `the (response|webhook request) body should match JSON:` (docstring — use `"ignore"` for dynamic values like timestamps)
- `the webhook event should match JSON:` (docstring — use `"ignore"` for dynamic values; polls for up to 5s)
- `there should be <N> PDF(s) in the (response|webhook request)`
- `there should be the following file(s) in the (response|webhook request):` (table of filenames)
- `the "<name>" PDF should have <N> page(s)`

View File

@@ -44,3 +44,45 @@ Feature: Webhook
Then the response status code should be 204
Then the webhook request header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the webhook request
Scenario: Webhook Events URL (Success)
Given I have a default Gotenberg container
Given I have a webhook server
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/flatten" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| Gotenberg-Webhook-Url | http://host.docker.internal:%d/webhook | header |
| Gotenberg-Webhook-Error-Url | http://host.docker.internal:%d/webhook/error | header |
| Gotenberg-Webhook-Events-Url | http://host.docker.internal:%d/webhook/events | header |
Then the response status code should be 204
When I wait for the asynchronous request to the webhook
Then the webhook request header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the webhook request
Then the webhook event should match JSON:
"""
{
"event": "webhook.success",
"correlationId": "ignore",
"timestamp": "ignore"
}
"""
Scenario: Webhook Events URL (Synchronous)
Given I have a Gotenberg container with the following environment variable(s):
| WEBHOOK_ENABLE_SYNC_MODE | true |
Given I have a webhook server
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/flatten" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| Gotenberg-Webhook-Url | http://host.docker.internal:%d/webhook | header |
| Gotenberg-Webhook-Error-Url | http://host.docker.internal:%d/webhook/error | header |
| Gotenberg-Webhook-Events-Url | http://host.docker.internal:%d/webhook/events | header |
Then the response status code should be 204
Then the webhook request header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the webhook request
Then the webhook event should match JSON:
"""
{
"event": "webhook.success",
"correlationId": "ignore",
"timestamp": "ignore"
}
"""

View File

@@ -181,7 +181,7 @@ func (s *scenario) iMakeARequestToGotenbergWithTheFollowingFormDataAndHeaders(ct
}
files[name] = append(files[name], value)
case "header":
if name == "Gotenberg-Webhook-Url" || name == "Gotenberg-Webhook-Error-Url" {
if name == "Gotenberg-Webhook-Url" || name == "Gotenberg-Webhook-Error-Url" || name == "Gotenberg-Webhook-Events-Url" {
headers[name] = fmt.Sprintf(value, s.hostPort)
continue
}
@@ -653,6 +653,48 @@ func (s *scenario) theBodyShouldMatchJSON(kind string, expectedDoc *godog.DocStr
return nil
}
func (s *scenario) theWebhookEventShouldMatchJSON(ctx context.Context, expectedDoc *godog.DocString) error {
if s.server == nil {
return errors.New("server not initialized")
}
// Poll briefly — the event fires right after the main webhook.
var body []byte
deadline := time.After(5 * time.Second)
for {
body = s.server.getEventBody()
if body != nil {
break
}
select {
case <-deadline:
return errors.New("timed out waiting for webhook event")
default:
time.Sleep(100 * time.Millisecond)
}
}
var expected, actual any
content := strings.ReplaceAll(expectedDoc.Content, "{version}", GotenbergVersion)
err := json.Unmarshal([]byte(content), &expected)
if err != nil {
return fmt.Errorf("unmarshal expected JSON: %w", err)
}
err = json.Unmarshal(body, &actual)
if err != nil {
return fmt.Errorf("unmarshal actual JSON: %w", err)
}
err = compareJson(expected, actual)
if err != nil {
return fmt.Errorf("expected matching webhook event JSON: %w", err)
}
return nil
}
func (s *scenario) thereShouldBePdfs(expected int, kind string) error {
dirPath := s.teststoreDir
@@ -1158,6 +1200,7 @@ func InitializeScenario(ctx *godog.ScenarioContext) {
ctx.Then(`^the (response|webhook request) body should match string:$`, s.theBodyShouldMatchString)
ctx.Then(`^the (response|webhook request) body should contain string:$`, s.theBodyShouldContainString)
ctx.Then(`^the (response|webhook request) body should match JSON:$`, s.theBodyShouldMatchJSON)
ctx.Then(`^the webhook event should match JSON:$`, s.theWebhookEventShouldMatchJSON)
ctx.Then(`^there should be (\d+) PDF\(s\) in the (response|webhook request)$`, s.thereShouldBePdfs)
ctx.Then(`^there should be the following file\(s\) in the (response|webhook request):$`, s.thereShouldBeTheFollowingFiles)
ctx.Then(`^the (response|webhook request) PDF\(s\) should be valid "([^"]*)" with a tolerance of (\d+) failed rule\(s\)$`, s.thePdfsShouldBeValidWithAToleranceOf)

View File

@@ -11,6 +11,7 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"github.com/cucumber/godog"
"github.com/google/uuid"
@@ -19,10 +20,12 @@ import (
)
type server struct {
srv *echo.Echo
req *http.Request
bodyCopy []byte
errChan chan error
srv *echo.Echo
req *http.Request
bodyCopy []byte
errChan chan error
eventBody []byte
eventMu sync.Mutex
}
func newServer(ctx context.Context, workdir string) (*server, error) {
@@ -144,6 +147,18 @@ func newServer(ctx context.Context, workdir string) (*server, error) {
srv.POST("/webhook/error", webhookErrorHandler)
srv.PATCH("/webhook/error", webhookErrorHandler)
srv.PUT("/webhook/error", webhookErrorHandler)
webhookEventsHandler := func(c echo.Context) error {
body, err := io.ReadAll(c.Request().Body)
if err != nil {
return c.String(http.StatusInternalServerError, err.Error())
}
s.eventMu.Lock()
s.eventBody = body
s.eventMu.Unlock()
return c.String(http.StatusOK, http.StatusText(http.StatusOK))
}
srv.POST("/webhook/events", webhookEventsHandler)
srv.GET("/static/:path", func(c echo.Context) error {
s.req = c.Request()
path := c.Param("path")
@@ -170,6 +185,12 @@ func newServer(ctx context.Context, workdir string) (*server, error) {
return s, nil
}
func (s *server) getEventBody() []byte {
s.eventMu.Lock()
defer s.eventMu.Unlock()
return s.eventBody
}
func (s *server) start(ctx context.Context) (int, error) {
// #nosec
ln, err := net.Listen("tcp", "0.0.0.0:0")