feat(webhook) add support for sync upload with webhook url (#1233)

* Add support for having sync upload with webhook url

* Update tests

* Make SendOutputFileParams interface private

* Make interface params private
This commit is contained in:
Yash Narwal
2025-08-08 17:25:46 +10:00
committed by GitHub
parent f299f3ed20
commit fc11f557fa
2 changed files with 136 additions and 61 deletions

View File

@@ -20,11 +20,74 @@ import (
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
)
type sendOutputFileParams struct {
ctx *api.Context
outputPath string
extraHttpHeaders map[string]string
traceHeader string
trace string
client *client
handleError func(error)
}
func webhookMiddleware(w *Webhook) api.Middleware {
return api.Middleware{
Stack: api.MultipartStack,
Handler: func() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
sendOutputFile := func(params sendOutputFileParams) {
outputFile, err := os.Open(params.outputPath)
if err != nil {
params.ctx.Log().Error(fmt.Sprintf("open output file: %s", err))
params.handleError(err)
return
}
defer func() {
err := outputFile.Close()
if err != nil {
params.ctx.Log().Error(fmt.Sprintf("close output file: %s", err))
}
}()
fileHeader := make([]byte, 512)
_, err = outputFile.Read(fileHeader)
if err != nil {
params.ctx.Log().Error(fmt.Sprintf("read header of output file: %s", err))
params.handleError(err)
return
}
fileStat, err := outputFile.Stat()
if err != nil {
params.ctx.Log().Error(fmt.Sprintf("get stat from output file: %s", err))
params.handleError(err)
return
}
_, err = outputFile.Seek(0, 0)
if err != nil {
params.ctx.Log().Error(fmt.Sprintf("reset output file reader: %s", err))
params.handleError(err)
return
}
headers := map[string]string{
echo.HeaderContentType: http.DetectContentType(fileHeader),
echo.HeaderContentLength: strconv.FormatInt(fileStat.Size(), 10),
params.traceHeader: params.trace,
}
_, ok := params.extraHttpHeaders[echo.HeaderContentDisposition]
if !ok {
headers[echo.HeaderContentDisposition] = fmt.Sprintf("attachment; filename=%q", params.ctx.OutputFilename(params.outputPath))
}
err = params.client.send(bufio.NewReader(outputFile), headers, false)
if err != nil {
params.ctx.Log().Error(fmt.Sprintf("send output file to webhook: %s", err))
params.handleError(err)
}
}
return func(c echo.Context) error {
webhookUrl := c.Request().Header.Get("Gotenberg-Webhook-Url")
if webhookUrl == "" {
@@ -144,7 +207,7 @@ func webhookMiddleware(w *Webhook) api.Middleware {
// This method parses an "asynchronous" error and sends a
// request to the webhook error URL with a JSON body
// containing the status and the error message.
handleAsyncError := func(err error) {
handleError := func(err error) {
status, message := api.ParseError(err)
body := struct {
@@ -158,7 +221,6 @@ func webhookMiddleware(w *Webhook) api.Middleware {
b, err := json.Marshal(body)
if err != nil {
ctx.Log().Error(fmt.Sprintf("marshal JSON: %s", err.Error()))
return
}
@@ -173,10 +235,57 @@ func webhookMiddleware(w *Webhook) api.Middleware {
}
}
w.asyncCount.Add(1)
webhookSyncHeader := c.Request().Header.Get("Gotenberg-Webhook-Sync")
webhookSync := false
if webhookSyncHeader != "" {
var err error
webhookSync, err = strconv.ParseBool(webhookSyncHeader)
if err != nil {
return fmt.Errorf("parse webhook sync header: %w", err)
}
}
if webhookSync {
// Synchronous mode has been requested.
err := next(c)
if err != nil {
if errors.Is(err, api.ErrNoOutputFile) {
errNoOutputFile := fmt.Errorf("%w - the webhook middleware cannot handle the result of this route", err)
handleError(api.WrapError(
errNoOutputFile,
api.NewSentinelHttpError(
http.StatusBadRequest,
"The webhook middleware can only work with multipart/form-data routes that results in output files",
),
))
return nil
}
ctx.Log().Error(err.Error())
handleError(err)
return nil
}
outputPath, err := ctx.BuildOutputFile()
if err != nil {
ctx.Log().Error(fmt.Sprintf("build output file: %s", err))
handleError(err)
return nil
}
// No error, let's send the output file to the webhook URL.
sendOutputFile(sendOutputFileParams{
ctx: ctx,
outputPath: outputPath,
extraHttpHeaders: extraHttpHeaders,
traceHeader: traceHeader,
trace: trace,
client: client,
handleError: handleError,
})
return c.NoContent(http.StatusNoContent)
}
// As a webhook URL has been given, we handle the request in a
// goroutine and return immediately.
w.asyncCount.Add(1)
go func() {
defer cancel()
defer w.asyncCount.Add(-1)
@@ -186,7 +295,7 @@ func webhookMiddleware(w *Webhook) api.Middleware {
if err != nil {
if errors.Is(err, api.ErrNoOutputFile) {
errNoOutputFile := fmt.Errorf("%w - the webhook middleware cannot handle the result of this route", err)
handleAsyncError(api.WrapError(
handleError(api.WrapError(
errNoOutputFile,
api.NewSentinelHttpError(
http.StatusBadRequest,
@@ -198,7 +307,7 @@ func webhookMiddleware(w *Webhook) api.Middleware {
// The process failed for whatever reason. Let's send the
// details to the webhook.
ctx.Log().Error(err.Error())
handleAsyncError(err)
handleError(err)
return
}
@@ -206,65 +315,19 @@ func webhookMiddleware(w *Webhook) api.Middleware {
outputPath, err := ctx.BuildOutputFile()
if err != nil {
ctx.Log().Error(fmt.Sprintf("build output file: %s", err))
handleAsyncError(err)
handleError(err)
return
}
outputFile, err := os.Open(outputPath)
if err != nil {
ctx.Log().Error(fmt.Sprintf("open output file: %s", err))
handleAsyncError(err)
return
}
defer func() {
err := outputFile.Close()
if err != nil {
ctx.Log().Error(fmt.Sprintf("close output file: %s", err))
}
}()
fileHeader := make([]byte, 512)
_, err = outputFile.Read(fileHeader)
if err != nil {
ctx.Log().Error(fmt.Sprintf("read header of output file: %s", err))
handleAsyncError(err)
return
}
fileStat, err := outputFile.Stat()
if err != nil {
ctx.Log().Error(fmt.Sprintf("get stat from output file: %s", err))
handleAsyncError(err)
return
}
_, err = outputFile.Seek(0, 0)
if err != nil {
ctx.Log().Error(fmt.Sprintf("reset output file reader: %s", err))
handleAsyncError(err)
return
}
headers := map[string]string{
echo.HeaderContentType: http.DetectContentType(fileHeader),
echo.HeaderContentLength: strconv.FormatInt(fileStat.Size(), 10),
traceHeader: trace,
}
// Allow for custom Content-Disposition header.
// See https://github.com/gotenberg/gotenberg/issues/1165.
_, ok := extraHttpHeaders[echo.HeaderContentDisposition]
if !ok {
headers[echo.HeaderContentDisposition] = fmt.Sprintf("attachment; filename=%q", ctx.OutputFilename(outputPath))
}
// Send the output file to the webhook.
err = client.send(bufio.NewReader(outputFile), headers, false)
if err != nil {
ctx.Log().Error(fmt.Sprintf("send output file to webhook: %s", err))
handleAsyncError(err)
}
sendOutputFile(sendOutputFileParams{
ctx: ctx,
outputPath: outputPath,
extraHttpHeaders: extraHttpHeaders,
traceHeader: traceHeader,
trace: trace,
client: client,
handleError: handleError,
})
}()
return api.ErrAsyncProcess

View File

@@ -31,3 +31,15 @@ Feature: Webhook
# https://github.com/gotenberg/gotenberg/issues/1165
Then the webhook request header "Content-Disposition" should be "inline"
Then there should be 1 PDF(s) in the webhook request
Scenario: Synchronous webhook processing with Gotenberg-Webhook-Sync header
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-Sync | true | 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