From 385cbe6590f18c0605fd460a87e1039300cb0676 Mon Sep 17 00:00:00 2001 From: Julien Neuhart Date: Sat, 28 Mar 2026 19:00:07 +0100 Subject: [PATCH] feat(webhook): add events --- .bruno/Chromium/Convert/HTML to PDF.bru | 1 + .bruno/Chromium/Convert/Markdown to PDF.bru | 1 + .bruno/Chromium/Convert/URL to PDF.bru | 1 + .../Chromium/Screenshot/HTML Screenshot.bru | 1 + .../Screenshot/Markdown Screenshot.bru | 1 + .bruno/Chromium/Screenshot/URL Screenshot.bru | 1 + .bruno/LibreOffice/Convert to PDF.bru | 1 + .../PDF Engines/Bookmarks/Read Bookmarks.bru | 1 + .../PDF Engines/Bookmarks/Write Bookmarks.bru | 1 + .bruno/PDF Engines/Convert/Convert PDF.bru | 1 + .bruno/PDF Engines/Embed/Embed Files.bru | 1 + .bruno/PDF Engines/Encrypt/Encrypt PDF.bru | 1 + .bruno/PDF Engines/Flatten/Flatten PDF.bru | 1 + .bruno/PDF Engines/Merge/Merge PDFs.bru | 1 + .bruno/PDF Engines/Metadata/Read Metadata.bru | 1 + .../PDF Engines/Metadata/Write Metadata.bru | 1 + .bruno/PDF Engines/Rotate/Rotate PDF.bru | 1 + .bruno/PDF Engines/Split/Split PDF.bru | 1 + .bruno/PDF Engines/Stamp/Stamp PDF.bru | 1 + .../PDF Engines/Watermark/Watermark PDF.bru | 1 + pkg/modules/webhook/client.go | 65 +++++++++++++++++++ pkg/modules/webhook/middleware.go | 27 ++++++++ test/integration/AGENTS.md | 1 + test/integration/features/webhook.feature | 42 ++++++++++++ test/integration/scenario/scenario.go | 45 ++++++++++++- test/integration/scenario/server.go | 29 +++++++-- 26 files changed, 224 insertions(+), 5 deletions(-) diff --git a/.bruno/Chromium/Convert/HTML to PDF.bru b/.bruno/Chromium/Convert/HTML to PDF.bru index a370bfb6..7d219d7b 100644 --- a/.bruno/Chromium/Convert/HTML to PDF.bru +++ b/.bruno/Chromium/Convert/HTML to PDF.bru @@ -66,6 +66,7 @@ headers { ~Gotenberg-Output-Filename: my-file ~Gotenberg-Webhook-Url: http://localhost:8080/webhook ~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error + ~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events ~Gotenberg-Webhook-Method: POST ~Gotenberg-Webhook-Error-Method: POST ~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"} diff --git a/.bruno/Chromium/Convert/Markdown to PDF.bru b/.bruno/Chromium/Convert/Markdown to PDF.bru index 7ecd91b8..ac850fc9 100644 --- a/.bruno/Chromium/Convert/Markdown to PDF.bru +++ b/.bruno/Chromium/Convert/Markdown to PDF.bru @@ -67,6 +67,7 @@ headers { ~Gotenberg-Output-Filename: my-file ~Gotenberg-Webhook-Url: http://localhost:8080/webhook ~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error + ~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events ~Gotenberg-Webhook-Method: POST ~Gotenberg-Webhook-Error-Method: POST ~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"} diff --git a/.bruno/Chromium/Convert/URL to PDF.bru b/.bruno/Chromium/Convert/URL to PDF.bru index d1ce9637..a40f75de 100644 --- a/.bruno/Chromium/Convert/URL to PDF.bru +++ b/.bruno/Chromium/Convert/URL to PDF.bru @@ -66,6 +66,7 @@ headers { ~Gotenberg-Output-Filename: my-file ~Gotenberg-Webhook-Url: http://localhost:8080/webhook ~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error + ~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events ~Gotenberg-Webhook-Method: POST ~Gotenberg-Webhook-Error-Method: POST ~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"} diff --git a/.bruno/Chromium/Screenshot/HTML Screenshot.bru b/.bruno/Chromium/Screenshot/HTML Screenshot.bru index 027856c1..b44b4bc4 100644 --- a/.bruno/Chromium/Screenshot/HTML Screenshot.bru +++ b/.bruno/Chromium/Screenshot/HTML Screenshot.bru @@ -40,6 +40,7 @@ headers { ~Gotenberg-Output-Filename: my-screenshot ~Gotenberg-Webhook-Url: http://localhost:8080/webhook ~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error + ~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events ~Gotenberg-Webhook-Method: POST ~Gotenberg-Webhook-Error-Method: POST ~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"} diff --git a/.bruno/Chromium/Screenshot/Markdown Screenshot.bru b/.bruno/Chromium/Screenshot/Markdown Screenshot.bru index a5ed077e..c5d5f1d4 100644 --- a/.bruno/Chromium/Screenshot/Markdown Screenshot.bru +++ b/.bruno/Chromium/Screenshot/Markdown Screenshot.bru @@ -41,6 +41,7 @@ headers { ~Gotenberg-Output-Filename: my-screenshot ~Gotenberg-Webhook-Url: http://localhost:8080/webhook ~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error + ~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events ~Gotenberg-Webhook-Method: POST ~Gotenberg-Webhook-Error-Method: POST ~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"} diff --git a/.bruno/Chromium/Screenshot/URL Screenshot.bru b/.bruno/Chromium/Screenshot/URL Screenshot.bru index fbd4c81b..76f65c5c 100644 --- a/.bruno/Chromium/Screenshot/URL Screenshot.bru +++ b/.bruno/Chromium/Screenshot/URL Screenshot.bru @@ -40,6 +40,7 @@ headers { ~Gotenberg-Output-Filename: my-screenshot ~Gotenberg-Webhook-Url: http://localhost:8080/webhook ~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error + ~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events ~Gotenberg-Webhook-Method: POST ~Gotenberg-Webhook-Error-Method: POST ~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"} diff --git a/.bruno/LibreOffice/Convert to PDF.bru b/.bruno/LibreOffice/Convert to PDF.bru index 2ca99670..90816587 100644 --- a/.bruno/LibreOffice/Convert to PDF.bru +++ b/.bruno/LibreOffice/Convert to PDF.bru @@ -83,6 +83,7 @@ headers { ~Gotenberg-Output-Filename: my-file ~Gotenberg-Webhook-Url: http://localhost:8080/webhook ~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error + ~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events ~Gotenberg-Webhook-Method: POST ~Gotenberg-Webhook-Error-Method: POST ~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"} diff --git a/.bruno/PDF Engines/Bookmarks/Read Bookmarks.bru b/.bruno/PDF Engines/Bookmarks/Read Bookmarks.bru index 22328ecd..b2e712c8 100644 --- a/.bruno/PDF Engines/Bookmarks/Read Bookmarks.bru +++ b/.bruno/PDF Engines/Bookmarks/Read Bookmarks.bru @@ -17,6 +17,7 @@ body:multipart-form { headers { ~Gotenberg-Webhook-Url: http://localhost:8080/webhook ~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error + ~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events ~Gotenberg-Webhook-Method: POST ~Gotenberg-Webhook-Error-Method: POST ~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"} diff --git a/.bruno/PDF Engines/Bookmarks/Write Bookmarks.bru b/.bruno/PDF Engines/Bookmarks/Write Bookmarks.bru index 0ec1b508..54fb0809 100644 --- a/.bruno/PDF Engines/Bookmarks/Write Bookmarks.bru +++ b/.bruno/PDF Engines/Bookmarks/Write Bookmarks.bru @@ -19,6 +19,7 @@ headers { ~Gotenberg-Output-Filename: with-bookmarks ~Gotenberg-Webhook-Url: http://localhost:8080/webhook ~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error + ~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events ~Gotenberg-Webhook-Method: POST ~Gotenberg-Webhook-Error-Method: POST ~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"} diff --git a/.bruno/PDF Engines/Convert/Convert PDF.bru b/.bruno/PDF Engines/Convert/Convert PDF.bru index 5de2769d..aa7b4981 100644 --- a/.bruno/PDF Engines/Convert/Convert PDF.bru +++ b/.bruno/PDF Engines/Convert/Convert PDF.bru @@ -20,6 +20,7 @@ headers { ~Gotenberg-Output-Filename: converted ~Gotenberg-Webhook-Url: http://localhost:8080/webhook ~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error + ~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events ~Gotenberg-Webhook-Method: POST ~Gotenberg-Webhook-Error-Method: POST ~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"} diff --git a/.bruno/PDF Engines/Embed/Embed Files.bru b/.bruno/PDF Engines/Embed/Embed Files.bru index 3af440bb..30bf3497 100644 --- a/.bruno/PDF Engines/Embed/Embed Files.bru +++ b/.bruno/PDF Engines/Embed/Embed Files.bru @@ -20,6 +20,7 @@ headers { ~Gotenberg-Output-Filename: with-embeds ~Gotenberg-Webhook-Url: http://localhost:8080/webhook ~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error + ~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events ~Gotenberg-Webhook-Method: POST ~Gotenberg-Webhook-Error-Method: POST ~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"} diff --git a/.bruno/PDF Engines/Encrypt/Encrypt PDF.bru b/.bruno/PDF Engines/Encrypt/Encrypt PDF.bru index c60c5c53..7ede2a7b 100644 --- a/.bruno/PDF Engines/Encrypt/Encrypt PDF.bru +++ b/.bruno/PDF Engines/Encrypt/Encrypt PDF.bru @@ -20,6 +20,7 @@ headers { ~Gotenberg-Output-Filename: encrypted ~Gotenberg-Webhook-Url: http://localhost:8080/webhook ~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error + ~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events ~Gotenberg-Webhook-Method: POST ~Gotenberg-Webhook-Error-Method: POST ~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"} diff --git a/.bruno/PDF Engines/Flatten/Flatten PDF.bru b/.bruno/PDF Engines/Flatten/Flatten PDF.bru index 114792ca..9260f667 100644 --- a/.bruno/PDF Engines/Flatten/Flatten PDF.bru +++ b/.bruno/PDF Engines/Flatten/Flatten PDF.bru @@ -18,6 +18,7 @@ headers { ~Gotenberg-Output-Filename: flattened ~Gotenberg-Webhook-Url: http://localhost:8080/webhook ~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error + ~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events ~Gotenberg-Webhook-Method: POST ~Gotenberg-Webhook-Error-Method: POST ~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"} diff --git a/.bruno/PDF Engines/Merge/Merge PDFs.bru b/.bruno/PDF Engines/Merge/Merge PDFs.bru index aeadad1a..62114aba 100644 --- a/.bruno/PDF Engines/Merge/Merge PDFs.bru +++ b/.bruno/PDF Engines/Merge/Merge PDFs.bru @@ -37,6 +37,7 @@ headers { ~Gotenberg-Output-Filename: merged ~Gotenberg-Webhook-Url: http://localhost:8080/webhook ~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error + ~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events ~Gotenberg-Webhook-Method: POST ~Gotenberg-Webhook-Error-Method: POST ~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"} diff --git a/.bruno/PDF Engines/Metadata/Read Metadata.bru b/.bruno/PDF Engines/Metadata/Read Metadata.bru index 3d8f9a53..6a9f027c 100644 --- a/.bruno/PDF Engines/Metadata/Read Metadata.bru +++ b/.bruno/PDF Engines/Metadata/Read Metadata.bru @@ -17,6 +17,7 @@ body:multipart-form { headers { ~Gotenberg-Webhook-Url: http://localhost:8080/webhook ~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error + ~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events ~Gotenberg-Webhook-Method: POST ~Gotenberg-Webhook-Error-Method: POST ~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"} diff --git a/.bruno/PDF Engines/Metadata/Write Metadata.bru b/.bruno/PDF Engines/Metadata/Write Metadata.bru index 6beff763..16fad817 100644 --- a/.bruno/PDF Engines/Metadata/Write Metadata.bru +++ b/.bruno/PDF Engines/Metadata/Write Metadata.bru @@ -19,6 +19,7 @@ headers { ~Gotenberg-Output-Filename: with-metadata ~Gotenberg-Webhook-Url: http://localhost:8080/webhook ~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error + ~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events ~Gotenberg-Webhook-Method: POST ~Gotenberg-Webhook-Error-Method: POST ~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"} diff --git a/.bruno/PDF Engines/Rotate/Rotate PDF.bru b/.bruno/PDF Engines/Rotate/Rotate PDF.bru index 3ec7c59e..bc32bd4e 100644 --- a/.bruno/PDF Engines/Rotate/Rotate PDF.bru +++ b/.bruno/PDF Engines/Rotate/Rotate PDF.bru @@ -20,6 +20,7 @@ headers { ~Gotenberg-Output-Filename: rotated ~Gotenberg-Webhook-Url: http://localhost:8080/webhook ~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error + ~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events ~Gotenberg-Webhook-Method: POST ~Gotenberg-Webhook-Error-Method: POST ~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"} diff --git a/.bruno/PDF Engines/Split/Split PDF.bru b/.bruno/PDF Engines/Split/Split PDF.bru index 130696f3..51cf3c97 100644 --- a/.bruno/PDF Engines/Split/Split PDF.bru +++ b/.bruno/PDF Engines/Split/Split PDF.bru @@ -37,6 +37,7 @@ headers { ~Gotenberg-Output-Filename: split ~Gotenberg-Webhook-Url: http://localhost:8080/webhook ~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error + ~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events ~Gotenberg-Webhook-Method: POST ~Gotenberg-Webhook-Error-Method: POST ~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"} diff --git a/.bruno/PDF Engines/Stamp/Stamp PDF.bru b/.bruno/PDF Engines/Stamp/Stamp PDF.bru index a1710ab2..5badb844 100644 --- a/.bruno/PDF Engines/Stamp/Stamp PDF.bru +++ b/.bruno/PDF Engines/Stamp/Stamp PDF.bru @@ -24,6 +24,7 @@ headers { ~Gotenberg-Output-Filename: stamped ~Gotenberg-Webhook-Url: http://localhost:8080/webhook ~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error + ~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events ~Gotenberg-Webhook-Method: POST ~Gotenberg-Webhook-Error-Method: POST ~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"} diff --git a/.bruno/PDF Engines/Watermark/Watermark PDF.bru b/.bruno/PDF Engines/Watermark/Watermark PDF.bru index 4ff99dd2..99b8e00f 100644 --- a/.bruno/PDF Engines/Watermark/Watermark PDF.bru +++ b/.bruno/PDF Engines/Watermark/Watermark PDF.bru @@ -24,6 +24,7 @@ headers { ~Gotenberg-Output-Filename: watermarked ~Gotenberg-Webhook-Url: http://localhost:8080/webhook ~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error + ~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events ~Gotenberg-Webhook-Method: POST ~Gotenberg-Webhook-Error-Method: POST ~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"} diff --git a/pkg/modules/webhook/client.go b/pkg/modules/webhook/client.go index b3d6e115..7fefab01 100644 --- a/pkg/modules/webhook/client.go +++ b/pkg/modules/webhook/client.go @@ -2,6 +2,7 @@ package webhook import ( "context" + "encoding/json" "fmt" "io" "log/slog" @@ -26,6 +27,7 @@ type client struct { method string errorUrl string errorMethod string + eventsUrl string extraHttpHeaders map[string]string startTime time.Time @@ -144,3 +146,66 @@ func (c client) send(ctx context.Context, body io.Reader, headers map[string]str return nil } + +// sendEvent sends a structured JSON event to the events URL. It is +// fire-and-forget: failures are logged but do not propagate. +func (c client) sendEvent(ctx context.Context, correlationIdHeader, correlationId string, event map[string]any) { + if c.eventsUrl == "" { + return + } + + b, err := json.Marshal(event) + if err != nil { + c.logger.ErrorContext(ctx, fmt.Sprintf("marshal webhook event: %s", err)) + return + } + + tracer := gotenberg.Tracer() + ctx, span := tracer.Start(ctx, "POST Webhook Event", + trace.WithSpanKind(trace.SpanKindClient), + trace.WithAttributes(semconv.ServerAddress(c.eventsUrl)), + ) + defer span.End() + + req, err := retryablehttp.NewRequest(http.MethodPost, c.eventsUrl, b) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + c.logger.ErrorContext(ctx, fmt.Sprintf("create webhook event request: %s", err)) + return + } + + otel.GetTextMapPropagator().Inject(ctx, propagation.HeaderCarrier(req.Header)) + + req.Header.Set("User-Agent", "Gotenberg") + for key, value := range c.extraHttpHeaders { + req.Header.Set(key, value) + } + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + req.Header.Set(correlationIdHeader, correlationId) + + resp, err := c.client.Do(req) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + c.logger.ErrorContext(ctx, fmt.Sprintf("send webhook event to '%s': %s", c.eventsUrl, err)) + return + } + + defer func() { + err := resp.Body.Close() + if err != nil { + c.logger.ErrorContext(ctx, fmt.Sprintf("close response body from '%s': %s", c.eventsUrl, err)) + } + }() + + if resp.StatusCode >= http.StatusBadRequest { + span.RecordError(fmt.Errorf("webhook event: got status '%s'", resp.Status)) + span.SetStatus(codes.Error, resp.Status) + c.logger.ErrorContext(ctx, fmt.Sprintf("send webhook event to '%s': got status '%s'", c.eventsUrl, resp.Status)) + return + } + + span.SetStatus(codes.Ok, "") + c.logger.InfoContext(ctx, fmt.Sprintf("webhook event sent to '%s'", c.eventsUrl)) +} diff --git a/pkg/modules/webhook/middleware.go b/pkg/modules/webhook/middleware.go index a6e2df18..482d10ed 100644 --- a/pkg/modules/webhook/middleware.go +++ b/pkg/modules/webhook/middleware.go @@ -85,7 +85,14 @@ func webhookMiddleware(w *Webhook) api.Middleware { if err != nil { params.ctx.Log().Error(fmt.Sprintf("send output file to webhook: %s", err)) params.handleError(err) + return } + + params.client.sendEvent(params.ctx, params.correlationIdHeader, params.correlationId, map[string]any{ + "event": "webhook.success", + "correlationId": params.correlationId, + "timestamp": time.Now().UTC().Format(time.RFC3339Nano), + }) } return func(c echo.Context) error { @@ -176,6 +183,15 @@ func webhookMiddleware(w *Webhook) api.Middleware { } } + // What about the events URL? + webhookEventsUrl := c.Request().Header.Get("Gotenberg-Webhook-Events-Url") + if webhookEventsUrl != "" { + err = gotenberg.FilterDeadline(w.allowList, w.denyList, webhookEventsUrl, deadline) + if err != nil { + return fmt.Errorf("filter webhook events URL: %w", err) + } + } + // Retrieve values from echo.Context before it gets recycled. // See https://github.com/gotenberg/gotenberg/issues/1000. startTime := c.Get("startTime").(time.Time) @@ -187,6 +203,7 @@ func webhookMiddleware(w *Webhook) api.Middleware { method: webhookMethod, errorUrl: webhookErrorUrl, errorMethod: webhookErrorMethod, + eventsUrl: webhookEventsUrl, extraHttpHeaders: extraHttpHeaders, startTime: startTime, @@ -233,6 +250,16 @@ func webhookMiddleware(w *Webhook) api.Middleware { if err != nil { ctx.Log().Error(fmt.Sprintf("send error response to webhook: %s", err.Error())) } + + client.sendEvent(ctx, correlationIdHeader, correlationId, map[string]any{ + "event": "webhook.error", + "correlationId": correlationId, + "timestamp": time.Now().UTC().Format(time.RFC3339Nano), + "error": map[string]any{ + "status": status, + "message": message, + }, + }) } if w.enableSyncMode { diff --git a/test/integration/AGENTS.md b/test/integration/AGENTS.md index c002dddb..bea4431c 100644 --- a/test/integration/AGENTS.md +++ b/test/integration/AGENTS.md @@ -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 PDF(s) in the (response|webhook request)` - `there should be the following file(s) in the (response|webhook request):` (table of filenames) - `the "" PDF should have page(s)` diff --git a/test/integration/features/webhook.feature b/test/integration/features/webhook.feature index 072800eb..9229ce19 100644 --- a/test/integration/features/webhook.feature +++ b/test/integration/features/webhook.feature @@ -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" + } + """ diff --git a/test/integration/scenario/scenario.go b/test/integration/scenario/scenario.go index 7ca8b31c..390aeebb 100644 --- a/test/integration/scenario/scenario.go +++ b/test/integration/scenario/scenario.go @@ -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) diff --git a/test/integration/scenario/server.go b/test/integration/scenario/server.go index 41b67298..ad9144f4 100644 --- a/test/integration/scenario/server.go +++ b/test/integration/scenario/server.go @@ -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")