From d2b14582ee6e8dce8b959bde00e1e09209c43da8 Mon Sep 17 00:00:00 2001 From: Julien Neuhart Date: Sat, 23 Mar 2024 16:14:37 +0100 Subject: [PATCH] feat(pdfengines): add write metadata route --- pkg/modules/pdfengines/pdfengines.go | 1 + pkg/modules/pdfengines/pdfengines_test.go | 2 +- pkg/modules/pdfengines/routes.go | 53 +++++++ pkg/modules/pdfengines/routes_test.go | 169 ++++++++++++++++++++++ 4 files changed, 224 insertions(+), 1 deletion(-) diff --git a/pkg/modules/pdfengines/pdfengines.go b/pkg/modules/pdfengines/pdfengines.go index e78931a3..c71e9557 100644 --- a/pkg/modules/pdfengines/pdfengines.go +++ b/pkg/modules/pdfengines/pdfengines.go @@ -169,6 +169,7 @@ func (mod *PdfEngines) Routes() ([]api.Route, error) { mergeRoute(engine), convertRoute(engine), readMetadataRoute(engine), + writeMetadataRoute(engine), }, nil } diff --git a/pkg/modules/pdfengines/pdfengines_test.go b/pkg/modules/pdfengines/pdfengines_test.go index 086ac645..bf12c8a5 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: 3, + expectRoutes: 4, disableRoutes: false, }, { diff --git a/pkg/modules/pdfengines/routes.go b/pkg/modules/pdfengines/routes.go index 050a4fed..0b6d8449 100644 --- a/pkg/modules/pdfengines/routes.go +++ b/pkg/modules/pdfengines/routes.go @@ -1,6 +1,7 @@ package pdfengines import ( + "encoding/json" "errors" "fmt" "net/http" @@ -193,3 +194,55 @@ func readMetadataRoute(engine gotenberg.PdfEngine) api.Route { }, } } + +// writeMetadataRoute returns an [api.Route] which can write metadata into +// PDFs. +func writeMetadataRoute(engine gotenberg.PdfEngine) api.Route { + return api.Route{ + Method: http.MethodPost, + Path: "/forms/pdfengines/metadata/write", + 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 + metadata map[string]interface{} + ) + + err := ctx.FormData(). + MandatoryPaths([]string{".pdf"}, &inputPaths). + MandatoryCustom("metadata", func(value string) error { + if len(value) > 0 { + err := json.Unmarshal([]byte(value), &metadata) + if err != nil { + return fmt.Errorf("unmarshal metadata: %w", err) + } + } + return nil + }). + Validate() + if err != nil { + return fmt.Errorf("validate form data: %w", err) + } + + // Alright, let's convert the PDFs. + for _, inputPath := range inputPaths { + err = engine.WriteMetadata(ctx, ctx.Log(), metadata, inputPath) + if err != nil { + return fmt.Errorf("write metadata: %w", err) + } + } + + // Last but not least, add the output paths to the context so that + // the API is able to send them as a response to the client. + err = ctx.AddOutputPaths(inputPaths...) + if err != nil { + return fmt.Errorf("add output paths: %w", err) + } + + return nil + }, + } +} diff --git a/pkg/modules/pdfengines/routes_test.go b/pkg/modules/pdfengines/routes_test.go index 44bfd1f4..52799811 100644 --- a/pkg/modules/pdfengines/routes_test.go +++ b/pkg/modules/pdfengines/routes_test.go @@ -507,3 +507,172 @@ func TestReadMetadataHandler(t *testing.T) { }) } } + +func TestWriteMetadataHandler(t *testing.T) { + for _, tc := range []struct { + scenario string + ctx *api.ContextMock + engine gotenberg.PdfEngine + expectError bool + expectHttpError bool + expectHttpStatus int + expectOutputPathsCount int + expectOutputPaths []string + }{ + { + scenario: "missing at least one mandatory file", + ctx: &api.ContextMock{Context: new(api.Context)}, + expectError: true, + expectHttpError: true, + expectHttpStatus: http.StatusBadRequest, + expectOutputPathsCount: 0, + }, + { + scenario: "no metadata form field", + ctx: func() *api.ContextMock { + ctx := &api.ContextMock{Context: new(api.Context)} + ctx.SetFiles(map[string]string{ + "file.pdf": "/file.pdf", + }) + return ctx + }(), + expectError: true, + expectHttpError: true, + expectHttpStatus: http.StatusBadRequest, + expectOutputPathsCount: 0, + }, + { + scenario: "invalid metadata form field", + ctx: func() *api.ContextMock { + ctx := &api.ContextMock{Context: new(api.Context)} + ctx.SetFiles(map[string]string{ + "document.docx": "/document.docx", + }) + ctx.SetValues(map[string][]string{ + "metadata": { + "foo", + }, + }) + return ctx + }(), + expectError: true, + expectHttpError: true, + expectHttpStatus: http.StatusBadRequest, + expectOutputPathsCount: 0, + }, + { + 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", + }) + ctx.SetValues(map[string][]string{ + "metadata": { + "{\"Creator\": \"foo\", \"Producer\": \"bar\" }", + }, + }) + return ctx + }(), + engine: &gotenberg.PdfEngineMock{ + WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error { + return errors.New("foo") + }, + }, + expectError: true, + expectHttpError: false, + expectOutputPathsCount: 0, + }, + { + scenario: "cannot add output paths", + ctx: func() *api.ContextMock { + ctx := &api.ContextMock{Context: new(api.Context)} + ctx.SetFiles(map[string]string{ + "file.pdf": "/file.pdf", + }) + ctx.SetValues(map[string][]string{ + "metadata": { + "{\"Creator\": \"foo\", \"Producer\": \"bar\" }", + }, + }) + ctx.SetCancelled(true) + return ctx + }(), + engine: &gotenberg.PdfEngineMock{ + WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error { + return nil + }, + }, + expectError: true, + expectHttpError: false, + expectOutputPathsCount: 0, + }, + { + scenario: "success", + ctx: func() *api.ContextMock { + ctx := &api.ContextMock{Context: new(api.Context)} + ctx.SetFiles(map[string]string{ + "file.pdf": "/file.pdf", + }) + ctx.SetValues(map[string][]string{ + "metadata": { + "{\"Creator\": \"foo\", \"Producer\": \"bar\" }", + }, + }) + return ctx + }(), + engine: &gotenberg.PdfEngineMock{ + WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error { + return nil + }, + }, + expectError: false, + expectHttpError: false, + expectOutputPathsCount: 1, + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + tc.ctx.SetLogger(zap.NewNop()) + c := echo.New().NewContext(nil, nil) + c.Set("context", tc.ctx.Context) + + err := writeMetadataRoute(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 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.expectOutputPathsCount != len(tc.ctx.OutputPaths()) { + t.Errorf("expected %d output paths but got %d", tc.expectOutputPathsCount, len(tc.ctx.OutputPaths())) + } + + for _, path := range tc.expectOutputPaths { + if !slices.Contains(tc.ctx.OutputPaths(), path) { + t.Errorf("expected '%s' in output paths %v", path, tc.ctx.OutputPaths()) + } + } + }) + } +}