mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-08-16 20:32:13 +01:00
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:
@@ -20,11 +20,74 @@ import (
|
|||||||
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
|
"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 {
|
func webhookMiddleware(w *Webhook) api.Middleware {
|
||||||
return api.Middleware{
|
return api.Middleware{
|
||||||
Stack: api.MultipartStack,
|
Stack: api.MultipartStack,
|
||||||
Handler: func() echo.MiddlewareFunc {
|
Handler: func() echo.MiddlewareFunc {
|
||||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
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 {
|
return func(c echo.Context) error {
|
||||||
webhookUrl := c.Request().Header.Get("Gotenberg-Webhook-Url")
|
webhookUrl := c.Request().Header.Get("Gotenberg-Webhook-Url")
|
||||||
if webhookUrl == "" {
|
if webhookUrl == "" {
|
||||||
@@ -144,7 +207,7 @@ func webhookMiddleware(w *Webhook) api.Middleware {
|
|||||||
// This method parses an "asynchronous" error and sends a
|
// This method parses an "asynchronous" error and sends a
|
||||||
// request to the webhook error URL with a JSON body
|
// request to the webhook error URL with a JSON body
|
||||||
// containing the status and the error message.
|
// containing the status and the error message.
|
||||||
handleAsyncError := func(err error) {
|
handleError := func(err error) {
|
||||||
status, message := api.ParseError(err)
|
status, message := api.ParseError(err)
|
||||||
|
|
||||||
body := struct {
|
body := struct {
|
||||||
@@ -158,7 +221,6 @@ func webhookMiddleware(w *Webhook) api.Middleware {
|
|||||||
b, err := json.Marshal(body)
|
b, err := json.Marshal(body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.Log().Error(fmt.Sprintf("marshal JSON: %s", err.Error()))
|
ctx.Log().Error(fmt.Sprintf("marshal JSON: %s", err.Error()))
|
||||||
|
|
||||||
return
|
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
|
// As a webhook URL has been given, we handle the request in a
|
||||||
// goroutine and return immediately.
|
// goroutine and return immediately.
|
||||||
|
w.asyncCount.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer cancel()
|
defer cancel()
|
||||||
defer w.asyncCount.Add(-1)
|
defer w.asyncCount.Add(-1)
|
||||||
@@ -186,7 +295,7 @@ func webhookMiddleware(w *Webhook) api.Middleware {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, api.ErrNoOutputFile) {
|
if errors.Is(err, api.ErrNoOutputFile) {
|
||||||
errNoOutputFile := fmt.Errorf("%w - the webhook middleware cannot handle the result of this route", err)
|
errNoOutputFile := fmt.Errorf("%w - the webhook middleware cannot handle the result of this route", err)
|
||||||
handleAsyncError(api.WrapError(
|
handleError(api.WrapError(
|
||||||
errNoOutputFile,
|
errNoOutputFile,
|
||||||
api.NewSentinelHttpError(
|
api.NewSentinelHttpError(
|
||||||
http.StatusBadRequest,
|
http.StatusBadRequest,
|
||||||
@@ -198,7 +307,7 @@ func webhookMiddleware(w *Webhook) api.Middleware {
|
|||||||
// The process failed for whatever reason. Let's send the
|
// The process failed for whatever reason. Let's send the
|
||||||
// details to the webhook.
|
// details to the webhook.
|
||||||
ctx.Log().Error(err.Error())
|
ctx.Log().Error(err.Error())
|
||||||
handleAsyncError(err)
|
handleError(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,65 +315,19 @@ func webhookMiddleware(w *Webhook) api.Middleware {
|
|||||||
outputPath, err := ctx.BuildOutputFile()
|
outputPath, err := ctx.BuildOutputFile()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.Log().Error(fmt.Sprintf("build output file: %s", err))
|
ctx.Log().Error(fmt.Sprintf("build output file: %s", err))
|
||||||
handleAsyncError(err)
|
handleError(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
outputFile, err := os.Open(outputPath)
|
sendOutputFile(sendOutputFileParams{
|
||||||
if err != nil {
|
ctx: ctx,
|
||||||
ctx.Log().Error(fmt.Sprintf("open output file: %s", err))
|
outputPath: outputPath,
|
||||||
handleAsyncError(err)
|
extraHttpHeaders: extraHttpHeaders,
|
||||||
return
|
traceHeader: traceHeader,
|
||||||
}
|
trace: trace,
|
||||||
|
client: client,
|
||||||
defer func() {
|
handleError: handleError,
|
||||||
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)
|
|
||||||
}
|
|
||||||
}()
|
}()
|
||||||
|
|
||||||
return api.ErrAsyncProcess
|
return api.ErrAsyncProcess
|
||||||
|
|||||||
@@ -31,3 +31,15 @@ Feature: Webhook
|
|||||||
# https://github.com/gotenberg/gotenberg/issues/1165
|
# https://github.com/gotenberg/gotenberg/issues/1165
|
||||||
Then the webhook request header "Content-Disposition" should be "inline"
|
Then the webhook request header "Content-Disposition" should be "inline"
|
||||||
Then there should be 1 PDF(s) in the webhook request
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user