diff --git a/pkg/modules/api/middlewares.go b/pkg/modules/api/middlewares.go index 1c5c6097..baa7df97 100644 --- a/pkg/modules/api/middlewares.go +++ b/pkg/modules/api/middlewares.go @@ -15,9 +15,15 @@ import ( "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" ) -// ErrAsyncProcess happens when a handler or middleware handles a request in an -// asynchronous fashion. -var ErrAsyncProcess = errors.New("async process") +var ( + // ErrAsyncProcess happens when a handler or middleware handles a request + // in an asynchronous fashion. + ErrAsyncProcess = errors.New("async process") + + // ErrNoOutputFile happens when a handler or middleware handles a request + // without sending any output file. + ErrNoOutputFile = errors.New("no output file") +) // ParseError parses an error and returns the corresponding HTTP status and // HTTP message. @@ -246,6 +252,13 @@ func contextMiddleware(fs *gotenberg.FileSystem, timeout time.Duration) echo.Mid defer cancel() + if errors.Is(err, ErrNoOutputFile) { + // A middleware/handler tells us that it's handling the process + // in an asynchronous fashion. Therefore, we must not cancel + // the context nor send an output file. + return nil + } + if err != nil { return err } diff --git a/pkg/modules/api/middlewares_test.go b/pkg/modules/api/middlewares_test.go index 1a9eb207..4490abc0 100644 --- a/pkg/modules/api/middlewares_test.go +++ b/pkg/modules/api/middlewares_test.go @@ -335,6 +335,15 @@ func TestContextMiddleware(t *testing.T) { }(), expectStatus: http.StatusNoContent, }, + { + request: buildMultipartFormDataRequest(), + next: func() echo.HandlerFunc { + return func(c echo.Context) error { + return ErrNoOutputFile + } + }(), + expectStatus: http.StatusOK, + }, { request: buildMultipartFormDataRequest(), next: func() echo.HandlerFunc { diff --git a/pkg/modules/exiftool/exiftool_test.go b/pkg/modules/exiftool/exiftool_test.go index 3bc4ba59..ebbc0865 100644 --- a/pkg/modules/exiftool/exiftool_test.go +++ b/pkg/modules/exiftool/exiftool_test.go @@ -282,14 +282,14 @@ func TestExiftool_WriteMetadata(t *testing.T) { t.Fatal("expected error but got none") } - if tc.expectError { - return - } - if tc.expectedError != nil && !errors.Is(err, tc.expectedError) { t.Fatalf("expected error %v but got: %v", tc.expectedError, err) } + if tc.expectError { + return + } + metadata, err := engine.ReadMetadata(context.Background(), zap.NewNop(), destinationPath) if err != nil { t.Fatalf("expected no error but got: %v", err) diff --git a/pkg/modules/pdfengines/pdfengines.go b/pkg/modules/pdfengines/pdfengines.go index cba3e797..e78931a3 100644 --- a/pkg/modules/pdfengines/pdfengines.go +++ b/pkg/modules/pdfengines/pdfengines.go @@ -168,6 +168,7 @@ func (mod *PdfEngines) Routes() ([]api.Route, error) { return []api.Route{ mergeRoute(engine), convertRoute(engine), + readMetadataRoute(engine), }, nil } diff --git a/pkg/modules/pdfengines/pdfengines_test.go b/pkg/modules/pdfengines/pdfengines_test.go index dccc2518..086ac645 100644 --- a/pkg/modules/pdfengines/pdfengines_test.go +++ b/pkg/modules/pdfengines/pdfengines_test.go @@ -312,7 +312,7 @@ func TestPdfEngines_Routes(t *testing.T) { }{ { scenario: "routes not disabled", - expectRoutes: 2, + expectRoutes: 3, disableRoutes: false, }, { diff --git a/pkg/modules/pdfengines/routes.go b/pkg/modules/pdfengines/routes.go index 041e040a..050a4fed 100644 --- a/pkg/modules/pdfengines/routes.go +++ b/pkg/modules/pdfengines/routes.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "net/http" + "path/filepath" "github.com/labstack/echo/v4" @@ -78,8 +79,8 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route { } } -// convertRoute returns an [api.Route] which can convert a PDF to a specific -// PDF format. +// convertRoute returns an [api.Route] which can convert PDFs to a specific ODF +// format. func convertRoute(engine gotenberg.PdfEngine) api.Route { return api.Route{ Method: http.MethodPost, @@ -152,3 +153,43 @@ func convertRoute(engine gotenberg.PdfEngine) api.Route { }, } } + +// readMetadataRoute returns an [api.Route] which returns the metadata of PDFs. +func readMetadataRoute(engine gotenberg.PdfEngine) api.Route { + return api.Route{ + Method: http.MethodPost, + Path: "/forms/pdfengines/metadata/read", + IsMultipart: true, + Handler: func(c echo.Context) error { + ctx := c.Get("context").(*api.Context) + + // Let's get the data from the form and validate them. + var inputPaths []string + + err := ctx.FormData(). + MandatoryPaths([]string{".pdf"}, &inputPaths). + Validate() + if err != nil { + return fmt.Errorf("validate form data: %w", err) + } + + // Alright, let's read the metadata. + res := make(map[string]map[string]interface{}, len(inputPaths)) + for _, inputPath := range inputPaths { + metadata, err := engine.ReadMetadata(ctx, ctx.Log(), inputPath) + if err != nil { + return fmt.Errorf("read metadata: %w", err) + } + + res[filepath.Base(inputPath)] = metadata + } + + err = c.JSON(http.StatusOK, res) + if err != nil { + return fmt.Errorf("return JSON response: %w", err) + } + + return api.ErrNoOutputFile + }, + } +} diff --git a/pkg/modules/pdfengines/routes_test.go b/pkg/modules/pdfengines/routes_test.go index ae843755..44bfd1f4 100644 --- a/pkg/modules/pdfengines/routes_test.go +++ b/pkg/modules/pdfengines/routes_test.go @@ -4,7 +4,9 @@ import ( "context" "errors" "net/http" + "net/http/httptest" "slices" + "strings" "testing" "github.com/labstack/echo/v4" @@ -401,3 +403,107 @@ func TestConvertHandler(t *testing.T) { }) } } + +func TestReadMetadataHandler(t *testing.T) { + for _, tc := range []struct { + scenario string + ctx *api.ContextMock + engine gotenberg.PdfEngine + expectError bool + expectedError error + expectHttpError bool + expectHttpStatus int + expectedJson string + }{ + { + scenario: "missing at least one mandatory file", + ctx: &api.ContextMock{Context: new(api.Context)}, + expectError: true, + expectHttpError: true, + expectHttpStatus: http.StatusBadRequest, + }, + { + scenario: "error from PDF engine", + ctx: func() *api.ContextMock { + ctx := &api.ContextMock{Context: new(api.Context)} + ctx.SetFiles(map[string]string{ + "file.pdf": "/file.pdf", + }) + return ctx + }(), + engine: &gotenberg.PdfEngineMock{ + ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) { + return nil, errors.New("foo") + }, + }, + expectError: true, + expectHttpError: false, + }, + { + scenario: "success", + ctx: func() *api.ContextMock { + ctx := &api.ContextMock{Context: new(api.Context)} + ctx.SetFiles(map[string]string{ + "file.pdf": "/file.pdf", + }) + return ctx + }(), + engine: &gotenberg.PdfEngineMock{ + ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) { + return map[string]interface{}{ + "foo": "bar", + "bar": "foo", + }, nil + }, + }, + expectError: true, + expectedError: api.ErrNoOutputFile, + expectHttpError: false, + expectedJson: `{"file.pdf":{"bar":"foo","foo":"bar"}}`, + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + tc.ctx.SetLogger(zap.NewNop()) + req := httptest.NewRequest(http.MethodPost, "/forms/pdfengines/metadata/read", nil) + rec := httptest.NewRecorder() + c := echo.New().NewContext(req, rec) + c.Set("context", tc.ctx.Context) + + err := readMetadataRoute(tc.engine).Handler(c) + + if tc.expectError && err == nil { + t.Fatal("expected error but got none", err) + } + + if !tc.expectError && err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + var httpErr api.HttpError + isHttpError := errors.As(err, &httpErr) + + if tc.expectHttpError && !isHttpError { + t.Errorf("expected an HTTP error but got: %v", err) + } + + if !tc.expectHttpError && isHttpError { + t.Errorf("expected no HTTP error but got one: %v", httpErr) + } + + if tc.expectedError != nil && !errors.Is(err, tc.expectedError) { + t.Fatalf("expected error %v but got: %v", tc.expectedError, err) + } + + if err != nil && tc.expectHttpError && isHttpError { + status, _ := httpErr.HttpError() + if status != tc.expectHttpStatus { + t.Errorf("expected %d as HTTP status code but got %d", tc.expectHttpStatus, status) + } + } + + if tc.expectedJson != "" && tc.expectedJson != strings.TrimSpace(rec.Body.String()) { + t.Errorf("expected '%s' as HTTP response but got '%s'", tc.expectedJson, strings.TrimSpace(rec.Body.String())) + } + }) + } +}