From 2eff52877bf86d3e570a43a3b47d2202388c0c12 Mon Sep 17 00:00:00 2001 From: Jay Brown Date: Tue, 17 Mar 2026 17:06:57 +0000 Subject: [PATCH] Merged in feature/download-pdf (pull request #216) retrieve document by id and tests * working * missing files --- CLAUDE.MD | 7 +- api/queryAPI/api.gen.go | 621 ++++++++++---------- api/queryAPI/controllers.go | 28 +- api/queryAPI/documents.go | 17 + api/queryAPI/documents_test.go | 117 ++++ api/queryAPI/testutils_test.go | 29 +- cmd/queryAPI/main.go | 29 +- docs/ai.generated/03-api-documentation.md | 39 ++ docs/ai.generated/README.md | 3 +- docs/ai.generated/queryapi.summary.md | 7 + internal/document/download/download.go | 77 +++ internal/document/download/download_test.go | 185 ++++++ internal/document/download/service.go | 22 + pkg/queryAPI/api.gen.go | 141 +++++ scripts/manual_tests/test_zip_upload.sh | 131 ++++- serviceAPIs/queryAPI.yaml | 42 ++ 16 files changed, 1152 insertions(+), 343 deletions(-) create mode 100644 internal/document/download/download.go create mode 100644 internal/document/download/download_test.go create mode 100644 internal/document/download/service.go diff --git a/CLAUDE.MD b/CLAUDE.MD index 21018226..2daa0a02 100644 --- a/CLAUDE.MD +++ b/CLAUDE.MD @@ -4,9 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## linting all edits after every change -Always run `task lint` once you think you are done with edits to verify your changes have not caused other issues. -When running `task lint` output should always be clean. 0 errors. -Warnings about variable not set are ok. +Always run `task lint` once you think you are done with edits to verify your changes have not caused other issues output must always be clean. 0 errors. Lint output output must contain ‘Linting passed, A perfect score! well done!’ or you must fix an issue to resume. Use `task go:lint -- --fix` to fix go (file not formmatted) types of errors from lint. Always run this before running `task fullsuite:ci` @@ -45,6 +43,7 @@ Use `task go:lint -- --fix` to fix go (file not formmatted) types of errors from - Never use mocks unless its too difficult to use the real asset and then you must get Q permission first to mock. ## Architecture Overview +see docs/ai.generated/README.md for an index of all of the system documentation files in docs/ai.generated for more detailed information but this document here should suffice for most jobs. ### System Design @@ -54,7 +53,7 @@ This is a **document processing platform** with microservices architecture using - **RESTful API** (queryAPI on port 8080) for external interactions - **Runner services** (ports 8081-8089) as queue consumers - **PostgreSQL** with migration-driven schema management -- **AWS services** (S3, SQS, Textract, Cognito) for cloud functionality +- **AWS services** (S3, SQS, Cognito) for cloud functionality ### Document Processing Pipeline diff --git a/api/queryAPI/api.gen.go b/api/queryAPI/api.gen.go index f646511d..f726bf18 100644 --- a/api/queryAPI/api.gen.go +++ b/api/queryAPI/api.gen.go @@ -1645,6 +1645,9 @@ type ServerInterface interface { // Get document details by its id // (GET /document/{id}) GetDocument(ctx echo.Context, id DocumentID, params GetDocumentParams) error + // Download the original document file + // (GET /document/{id}/download) + DownloadDocument(ctx echo.Context, id DocumentID) error // Get all labels for a document // (GET /documents/{documentId}/labels) GetDocumentLabels(ctx echo.Context, documentId openapi_types.UUID) error @@ -2446,6 +2449,24 @@ func (w *ServerInterfaceWrapper) GetDocument(ctx echo.Context) error { return err } +// DownloadDocument converts echo context to params. +func (w *ServerInterfaceWrapper) DownloadDocument(ctx echo.Context) error { + var err error + // ------------- Path parameter "id" ------------- + var id DocumentID + + err = runtime.BindStyledParameterWithOptions("simple", "id", ctx.Param("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter id: %s", err)) + } + + ctx.Set(JwtAuthScopes, []string{}) + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.DownloadDocument(ctx, id) + return err +} + // GetDocumentLabels converts echo context to params. func (w *ServerInterfaceWrapper) GetDocumentLabels(ctx echo.Context) error { var err error @@ -2958,6 +2979,7 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL router.GET(baseURL+"/clients", wrapper.ListClients) router.DELETE(baseURL+"/document/:id", wrapper.DeleteDocument) router.GET(baseURL+"/document/:id", wrapper.GetDocument) + router.GET(baseURL+"/document/:id/download", wrapper.DownloadDocument) router.GET(baseURL+"/documents/:documentId/labels", wrapper.GetDocumentLabels) router.POST(baseURL+"/documents/:documentId/labels", wrapper.ApplyLabel) router.GET(baseURL+"/eula", wrapper.GetCurrentEula) @@ -2990,304 +3012,307 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", - "li7fPn37/wMAAP//PHTNMpLfAQA=", + "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", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/api/queryAPI/controllers.go b/api/queryAPI/controllers.go index 7ee72194..7ef5b9a8 100644 --- a/api/queryAPI/controllers.go +++ b/api/queryAPI/controllers.go @@ -8,6 +8,7 @@ import ( collectorset "queryorchestration/internal/collector/set" "queryorchestration/internal/document" documentbatch "queryorchestration/internal/document/batch" + documentdownload "queryorchestration/internal/document/download" documentupload "queryorchestration/internal/document/upload" "queryorchestration/internal/eula" "queryorchestration/internal/export" @@ -24,19 +25,20 @@ const Name = "queryAPI" // Services contains all service dependencies for the API controllers. // Query-related services have been removed - see remove_query_plan.md. type Services struct { - Export *export.Service - Collector *collector.Service - CollectorSet *collectorset.Service - Client *client.Service - ClientUpdate *clientupdate.Service - Document *document.Service - DocumentUpload *documentupload.Service - DocumentBatch *documentbatch.Service - UISettings *uisettings.Service - FieldExtraction *fieldextraction.Service - Folder *folder.Service - Label *label.Service - Eula *eula.Service + Export *export.Service + Collector *collector.Service + CollectorSet *collectorset.Service + Client *client.Service + ClientUpdate *clientupdate.Service + Document *document.Service + DocumentUpload *documentupload.Service + DocumentDownload *documentdownload.Service + DocumentBatch *documentbatch.Service + UISettings *uisettings.Service + FieldExtraction *fieldextraction.Service + Folder *folder.Service + Label *label.Service + Eula *eula.Service } // ConfigProvider combines auth, objectstore, and background runner for the Controllers. diff --git a/api/queryAPI/documents.go b/api/queryAPI/documents.go index fe3c50b3..d3ee1a98 100644 --- a/api/queryAPI/documents.go +++ b/api/queryAPI/documents.go @@ -15,6 +15,23 @@ import ( openapi_types "github.com/oapi-codegen/runtime/types" ) +// DownloadDocument streams the original uploaded document file to the client. +func (s *Controllers) DownloadDocument(ctx echo.Context, id DocumentID) error { + result, err := s.svc.DocumentDownload.GetDownload(ctx.Request().Context(), uuid.UUID(id)) + if err != nil { + slog.Error("unable to download document", "error", err, "document_id", id) + return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Unable to download document: %s", err)) + } + defer result.Body.Close() + + ctx.Response().Header().Set("Content-Disposition", + fmt.Sprintf(`attachment; filename="%s"`, result.Filename)) + ctx.Response().Header().Set("Content-Length", + fmt.Sprintf("%d", result.Size)) + + return ctx.Stream(http.StatusOK, result.ContentType, result.Body) +} + func (s *Controllers) UploadDocument(ctx echo.Context, clientId ClientID) error { form, err := ctx.MultipartForm() if err != nil { diff --git a/api/queryAPI/documents_test.go b/api/queryAPI/documents_test.go index 0ddf819f..ca721aed 100644 --- a/api/queryAPI/documents_test.go +++ b/api/queryAPI/documents_test.go @@ -759,6 +759,123 @@ func TestGetDocument_NilFileSize(t *testing.T) { assert.Nil(t, result.FileSizeBytes, "legacy document should have nil file size") } +func TestDownloadDocument_Handler(t *testing.T) { + cfg := &ControllerConfig{} + test.CreateDB(t, cfg) + initializeTestConfig(t, cfg) + test.CreateAWSResources(t, cfg) + + svc := createControllerServices(cfg) + cons := queryapi.NewControllers(svc, cfg) + + // Create test client with root folder + test.CreateTestClient(t, cfg, "download_client", "Download Client") + + // Create document with filename + filename := "test-report.pdf" + fileContent := []byte("test file content for download") + docId, err := cfg.GetDBQueries().CreateDocument(t.Context(), &repository.CreateDocumentParams{ + Clientid: "download_client", + Hash: "download_hash", + Filename: &filename, + }) + require.NoError(t, err) + + // Upload content to S3 + bucket := cfg.GetBucket() + s3Key := "test/download/" + docId.String() + _, err = cfg.GetStoreClient().PutObject(t.Context(), &s3.PutObjectInput{ + Bucket: &bucket, + Key: &s3Key, + Body: bytes.NewReader(fileContent), + }) + require.NoError(t, err) + + // Create document entry linking document to S3 object + err = cfg.GetDBQueries().AddDocumentEntry(t.Context(), &repository.AddDocumentEntryParams{ + Documentid: docId, + Bucket: bucket, + Key: s3Key, + }) + require.NoError(t, err) + + // Call handler + ctx, rec := createContext(t) + err = cons.DownloadDocument(ctx, docId) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "application/pdf", rec.Header().Get("Content-Type")) + assert.Contains(t, rec.Header().Get("Content-Disposition"), "test-report.pdf") + assert.Equal(t, fileContent, rec.Body.Bytes()) +} + +func TestDownloadDocument_Handler_NotFound(t *testing.T) { + cfg := &ControllerConfig{} + test.CreateDB(t, cfg) + initializeTestConfig(t, cfg) + test.CreateAWSResources(t, cfg) + + svc := createControllerServices(cfg) + cons := queryapi.NewControllers(svc, cfg) + + // Call handler with non-existent document ID + ctx, _ := createContext(t) + err := cons.DownloadDocument(ctx, uuid.New()) + require.Error(t, err) + httpErr, ok := err.(*echo.HTTPError) + require.True(t, ok) + assert.Equal(t, http.StatusNotFound, httpErr.Code) +} + +func TestDownloadDocument_Handler_ContentDisposition(t *testing.T) { + cfg := &ControllerConfig{} + test.CreateDB(t, cfg) + initializeTestConfig(t, cfg) + test.CreateAWSResources(t, cfg) + + svc := createControllerServices(cfg) + cons := queryapi.NewControllers(svc, cfg) + + // Create client + test.CreateTestClient(t, cfg, "dl_disposition_client", "Disposition Client") + + // Create document with specific filename + filename := "quarterly-financial-report.pdf" + fileContent := []byte("financial report content") + docId, err := cfg.GetDBQueries().CreateDocument(t.Context(), &repository.CreateDocumentParams{ + Clientid: "dl_disposition_client", + Hash: "disposition_hash", + Filename: &filename, + }) + require.NoError(t, err) + + bucket := cfg.GetBucket() + s3Key := "test/download/" + docId.String() + _, err = cfg.GetStoreClient().PutObject(t.Context(), &s3.PutObjectInput{ + Bucket: &bucket, + Key: &s3Key, + Body: bytes.NewReader(fileContent), + }) + require.NoError(t, err) + + err = cfg.GetDBQueries().AddDocumentEntry(t.Context(), &repository.AddDocumentEntryParams{ + Documentid: docId, + Bucket: bucket, + Key: s3Key, + }) + require.NoError(t, err) + + ctx, rec := createContext(t) + err = cons.DownloadDocument(ctx, docId) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, rec.Code) + + // Verify Content-Disposition header contains the original filename + contentDisposition := rec.Header().Get("Content-Disposition") + assert.Contains(t, contentDisposition, "attachment") + assert.Contains(t, contentDisposition, "quarterly-financial-report.pdf") +} + // Helper function to create test ZIP with multiple PDFs func createTestZIPFile(t *testing.T, pdfCount int) *bytes.Reader { var buf bytes.Buffer diff --git a/api/queryAPI/testutils_test.go b/api/queryAPI/testutils_test.go index a4f12d69..72c52795 100644 --- a/api/queryAPI/testutils_test.go +++ b/api/queryAPI/testutils_test.go @@ -9,6 +9,7 @@ import ( collectorset "queryorchestration/internal/collector/set" "queryorchestration/internal/document" documentbatch "queryorchestration/internal/document/batch" + documentdownload "queryorchestration/internal/document/download" documentupload "queryorchestration/internal/document/upload" "queryorchestration/internal/eula" "queryorchestration/internal/export" @@ -47,6 +48,7 @@ func createControllerServices(cfg *ControllerConfig) *queryapi.Services { }) doc := document.New(cfg) docup := documentupload.New(cfg) + docdownload := documentdownload.New(cfg) docbatch := documentbatch.New(cfg) fieldext := fieldextraction.New(cfg) fld := folder.New(cfg) @@ -55,18 +57,19 @@ func createControllerServices(cfg *ControllerConfig) *queryapi.Services { uiSvc := uisettings.New(cfg) return &queryapi.Services{ - Export: exp, - Collector: col, - CollectorSet: colupdate, - Client: cli, - ClientUpdate: cliUpdate, - Document: doc, - DocumentUpload: docup, - DocumentBatch: docbatch, - UISettings: uiSvc, - FieldExtraction: fieldext, - Folder: fld, - Label: lbl, - Eula: eul, + Export: exp, + Collector: col, + CollectorSet: colupdate, + Client: cli, + ClientUpdate: cliUpdate, + Document: doc, + DocumentUpload: docup, + DocumentDownload: docdownload, + DocumentBatch: docbatch, + UISettings: uiSvc, + FieldExtraction: fieldext, + Folder: fld, + Label: lbl, + Eula: eul, } } diff --git a/cmd/queryAPI/main.go b/cmd/queryAPI/main.go index 5c8777e3..d90c1b61 100644 --- a/cmd/queryAPI/main.go +++ b/cmd/queryAPI/main.go @@ -28,6 +28,7 @@ import ( collectorset "queryorchestration/internal/collector/set" "queryorchestration/internal/document" documentbatch "queryorchestration/internal/document/batch" + documentdownload "queryorchestration/internal/document/download" documentupload "queryorchestration/internal/document/upload" "queryorchestration/internal/eula" "queryorchestration/internal/export" @@ -75,6 +76,7 @@ func main() { }) doc := document.New(cfg) docup := documentupload.New(cfg) + docdownload := documentdownload.New(cfg) docbatch := documentbatch.New(cfg) fieldext := fieldextraction.New(cfg) fld := folder.New(cfg) @@ -83,19 +85,20 @@ func main() { uiSvc := uisettings.New(cfg) services := &queryapi.Services{ - Export: exp, - Collector: col, - CollectorSet: colupdate, - Client: cli, - ClientUpdate: cliUpdate, - Document: doc, - DocumentUpload: docup, - DocumentBatch: docbatch, - UISettings: uiSvc, - FieldExtraction: fieldext, - Folder: fld, - Label: lbl, - Eula: eul, + Export: exp, + Collector: col, + CollectorSet: colupdate, + Client: cli, + ClientUpdate: cliUpdate, + Document: doc, + DocumentUpload: docup, + DocumentDownload: docdownload, + DocumentBatch: docbatch, + UISettings: uiSvc, + FieldExtraction: fieldext, + Folder: fld, + Label: lbl, + Eula: eul, } cons := queryapi.NewControllers(services, cfg) diff --git a/docs/ai.generated/03-api-documentation.md b/docs/ai.generated/03-api-documentation.md index 422c3984..18979907 100644 --- a/docs/ai.generated/03-api-documentation.md +++ b/docs/ai.generated/03-api-documentation.md @@ -387,6 +387,44 @@ Retrieves enriched document details by ID, including metadata such as folder, fi } ``` +### `GET /document/{id}/download` + +Streams the original uploaded document file directly from S3. The response Content-Type is auto-detected from the stored filename extension. + +**Security**: Requires JWT authentication + +**Path Parameters**: + +- `id`: Document UUID (max 36 chars) + +**Response** (`200`): Raw file bytes (streamed) + +- `Content-Type`: Auto-detected from filename (e.g., `application/pdf`, `image/png`, `application/vnd.openxmlformats-officedocument.wordprocessingml.document`) +- `Content-Disposition`: `attachment; filename=""` +- `Content-Length`: File size in bytes + +**Error Responses**: + +- `400`: Invalid document ID format +- `401`: Missing or invalid JWT token +- `404`: Document not found or no S3 entry for the document +- `500`: S3 retrieval failure + +**Example**: + +```bash +curl -H "Authorization: Bearer " \ + -o report.pdf \ + http://localhost:8080/document/019580df-ef65-7676-8de9-94435a93337a/download +``` + +**Notes**: + +- No query parameters needed -- file type is automatically determined from the stored filename +- Response streams directly from S3, suitable for large files +- Returns 404 if the document has no stored file (e.g., legacy documents without S3 entries) +- Supports any file type without code changes (PDF, PNG, DOCX, CSV, etc.) + ### `DELETE /document/{id}` Permanently deletes a document and all dependent data: document entries, cleans (and their entries), text extractions (and their entries), field extractions (versions and array fields), labels, results, and result dependencies. Source documents in S3 are NOT deleted; their paths are logged and optionally returned. @@ -1874,6 +1912,7 @@ The following table shows what data is passed to Permit.io for each protected en | `POST` | `/client/{id}/document` | `client` | `post` | Upload document | | `GET` | `/client/{id}/document` | `client` | `get` | List client documents | | `GET` | `/document/{id}` | `document` | `get` | Get document details | +| `GET` | `/document/{id}/download` | `document` | `get` | Download document file | | `DELETE` | `/document/{id}` | `document` | `delete` | Hard-delete document (super_admin only) | | **Batch Document Operations** | | `POST` | `/client/{id}/document/batch` | `client` | `post` | Upload batch (ZIP) | diff --git a/docs/ai.generated/README.md b/docs/ai.generated/README.md index 4a979f84..08bd5f91 100644 --- a/docs/ai.generated/README.md +++ b/docs/ai.generated/README.md @@ -2,6 +2,7 @@ ## Overview + This documentation provides comprehensive coverage of the DoczyAI Document Processing Platform - a cloud-native document processing system designed to extract structured information from documents. ## Documentation Structure @@ -105,7 +106,7 @@ This documentation provides comprehensive coverage of the DoczyAI Document Proce - **Monitoring**: Prometheus + OpenTelemetry ### 📊 Key Capabilities -- **Document Processing**: PDF upload, initialization, sync-gating, and cleaning +- **Document Processing**: PDF upload, download, initialization, sync-gating, and cleaning - **Multi-tenant Support**: Isolated client environments - **Document Organization**: Folders and labels for document management - **Export Operations**: Structured data export in multiple formats diff --git a/docs/ai.generated/queryapi.summary.md b/docs/ai.generated/queryapi.summary.md index 412b784e..67e32394 100644 --- a/docs/ai.generated/queryapi.summary.md +++ b/docs/ai.generated/queryapi.summary.md @@ -25,6 +25,7 @@ This document provides a comprehensive summary of all REST API endpoints availab | /client/{id}/document/batch | GET, POST | DocumentsService | | /client/{id}/document/batch/{batch_id} | GET | DocumentsService | | /document/{id} | GET, DELETE | DocumentsService | +| /document/{id}/download | GET | DocumentsService | | /documents/{documentId}/labels | GET, POST | LabelService | | /labels/{labelName}/documents | GET | LabelService | | /field-extractions | GET, POST | FieldExtractionService | @@ -165,6 +166,12 @@ This document provides a comprehensive summary of all REST API endpoints availab - `textRecord` (optional, default: false) - When true, includes full text extraction record - Returns: `{id, client_id, hash, folder_id, filename, labels[], fields{}, text_record?}` +- **GET /document/{id}/download** - Download the original document file + - Streams file directly from S3 storage + - Content-Type auto-detected from stored filename extension + - Returns: Raw file bytes with `Content-Disposition: attachment` header + - Supports any file type (PDF, PNG, DOCX, CSV, etc.) + - **DELETE /document/{id}** - Hard-delete a document and all dependent data (super_admin only) - Query Parameters: - `verbose` (optional, default: false) - When true, returns S3 paths of orphaned source documents diff --git a/internal/document/download/download.go b/internal/document/download/download.go new file mode 100644 index 00000000..c730ae9c --- /dev/null +++ b/internal/document/download/download.go @@ -0,0 +1,77 @@ +package documentdownload + +import ( + "context" + "fmt" + "io" + "mime" + "path/filepath" + + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/google/uuid" +) + +// DownloadResult contains the data needed to stream a document file to the client. +type DownloadResult struct { + Body io.ReadCloser // S3 object body - caller must close + Filename string // Original filename (for Content-Disposition) + ContentType string // MIME type detected from filename extension + Size int64 // File size in bytes (for Content-Length) +} + +// GetDownload retrieves the document file bytes from S3. +// Returns a DownloadResult with a streaming body that the caller must close. +func (s *Service) GetDownload(ctx context.Context, id uuid.UUID) (*DownloadResult, error) { + // Get document metadata (filename) from DB + doc, err := s.cfg.GetDBQueries().GetDocumentEnriched(ctx, id) + if err != nil { + return nil, fmt.Errorf("document not found: %w", err) + } + + // Get S3 entry (bucket, key) from documentEntries + entry, err := s.cfg.GetDBQueries().GetDocumentEntry(ctx, id) + if err != nil { + return nil, fmt.Errorf("no S3 entry for document: %w", err) + } + + // GetObject from S3 + output, err := s.cfg.GetStoreClient().GetObject(ctx, &s3.GetObjectInput{ + Bucket: &entry.Bucket, + Key: &entry.Key, + }) + if err != nil { + return nil, fmt.Errorf("failed to retrieve file from S3: %w", err) + } + + // Detect MIME type from filename extension + filename := "" + if doc.Filename != nil { + filename = *doc.Filename + } + contentType := detectContentType(filename) + + // Get file size + var size int64 + if output.ContentLength != nil { + size = *output.ContentLength + } + + return &DownloadResult{ + Body: output.Body, + Filename: filename, + ContentType: contentType, + Size: size, + }, nil +} + +// detectContentType determines the MIME type based on file extension. +func detectContentType(filename string) string { + if filename == "" { + return "application/octet-stream" + } + contentType := mime.TypeByExtension(filepath.Ext(filename)) + if contentType == "" { + return "application/octet-stream" + } + return contentType +} diff --git a/internal/document/download/download_test.go b/internal/document/download/download_test.go new file mode 100644 index 00000000..578d418e --- /dev/null +++ b/internal/document/download/download_test.go @@ -0,0 +1,185 @@ +package documentdownload_test + +import ( + "bytes" + "io" + "testing" + + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "queryorchestration/internal/client" + "queryorchestration/internal/database/repository" + documentdownload "queryorchestration/internal/document/download" + "queryorchestration/internal/serviceconfig" + "queryorchestration/internal/serviceconfig/objectstore" + "queryorchestration/internal/test" +) + +type Config struct { + serviceconfig.BaseConfig + objectstore.ObjectStoreConfig +} + +// createTestClient creates a client using the client service, which automatically creates the root folder. +func createTestClient(t *testing.T, cfg *Config, clientID, clientName string) { + t.Helper() + clientSvc := client.New(cfg) + _, err := clientSvc.Create(t.Context(), client.CreateParams{ + ID: clientID, + Name: clientName, + }) + require.NoError(t, err) +} + +// setupTestDocument creates a document in DB, uploads content to S3, and creates the document entry. +// Returns the document ID. +func setupTestDocument(t *testing.T, cfg *Config, clientID, hash string, filename *string, content []byte) uuid.UUID { + t.Helper() + + docID, err := cfg.GetDBQueries().CreateDocument(t.Context(), &repository.CreateDocumentParams{ + Clientid: clientID, + Hash: hash, + Filename: filename, + }) + require.NoError(t, err) + + bucket := cfg.GetBucket() + s3Key := "test/download/" + docID.String() + + _, err = cfg.GetStoreClient().PutObject(t.Context(), &s3.PutObjectInput{ + Bucket: &bucket, + Key: &s3Key, + Body: bytes.NewReader(content), + }) + require.NoError(t, err) + + err = cfg.GetDBQueries().AddDocumentEntry(t.Context(), &repository.AddDocumentEntryParams{ + Documentid: docID, + Bucket: bucket, + Key: s3Key, + }) + require.NoError(t, err) + + return docID +} + +func TestDownloadDocument_Success(t *testing.T) { + t.Parallel() + cfg := &Config{} + test.CreateDB(t, cfg) + test.CreateAWSResources(t, cfg) + + createTestClient(t, cfg, "dl_success_client", "Download Success Client") + + filename := "test-report.pdf" + fileContent := []byte("test file content for download verification") + docID := setupTestDocument(t, cfg, "dl_success_client", "dl_success_hash", &filename, fileContent) + + svc := documentdownload.New(cfg) + result, err := svc.GetDownload(t.Context(), docID) + require.NoError(t, err) + defer result.Body.Close() + + body, err := io.ReadAll(result.Body) + require.NoError(t, err) + + assert.Equal(t, fileContent, body) + assert.Equal(t, "test-report.pdf", result.Filename) + assert.Equal(t, "application/pdf", result.ContentType) +} + +func TestDownloadDocument_NotFound(t *testing.T) { + t.Parallel() + cfg := &Config{} + test.CreateDB(t, cfg) + test.CreateAWSResources(t, cfg) + + svc := documentdownload.New(cfg) + _, err := svc.GetDownload(t.Context(), uuid.New()) + require.Error(t, err) + assert.Contains(t, err.Error(), "document not found") +} + +func TestDownloadDocument_NoS3Entry(t *testing.T) { + t.Parallel() + cfg := &Config{} + test.CreateDB(t, cfg) + test.CreateAWSResources(t, cfg) + + createTestClient(t, cfg, "dl_noentry_client", "Download NoEntry Client") + + // Create document but do NOT create a document entry or upload to S3 + docID, err := cfg.GetDBQueries().CreateDocument(t.Context(), &repository.CreateDocumentParams{ + Clientid: "dl_noentry_client", + Hash: "dl_noentry_hash", + }) + require.NoError(t, err) + + svc := documentdownload.New(cfg) + _, err = svc.GetDownload(t.Context(), docID) + require.Error(t, err) + assert.Contains(t, err.Error(), "no S3 entry") +} + +func TestDownloadDocument_MimeTypeDetection(t *testing.T) { + t.Parallel() + cfg := &Config{} + test.CreateDB(t, cfg) + test.CreateAWSResources(t, cfg) + + createTestClient(t, cfg, "dl_mime_client", "Download Mime Client") + + tests := []struct { + name string + filename *string + expectedMime string + }{ + { + name: "PDF file", + filename: strPtr("report.pdf"), + expectedMime: "application/pdf", + }, + { + name: "PNG image", + filename: strPtr("photo.png"), + expectedMime: "image/png", + }, + { + name: "CSV file", + filename: strPtr("data.csv"), + expectedMime: "text/csv; charset=utf-8", + }, + { + name: "nil filename defaults to octet-stream", + filename: nil, + expectedMime: "application/octet-stream", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + hash := "dl_mime_hash_" + uuid.New().String()[:8] + content := []byte("file content " + tc.name) + + docID := setupTestDocument(t, cfg, "dl_mime_client", hash, tc.filename, content) + + svc := documentdownload.New(cfg) + result, err := svc.GetDownload(t.Context(), docID) + require.NoError(t, err) + defer result.Body.Close() + + body, err := io.ReadAll(result.Body) + require.NoError(t, err) + + assert.Equal(t, tc.expectedMime, result.ContentType) + assert.Equal(t, content, body) + }) + } +} + +func strPtr(s string) *string { + return &s +} diff --git a/internal/document/download/service.go b/internal/document/download/service.go new file mode 100644 index 00000000..78451576 --- /dev/null +++ b/internal/document/download/service.go @@ -0,0 +1,22 @@ +package documentdownload + +import ( + "queryorchestration/internal/serviceconfig" + "queryorchestration/internal/serviceconfig/objectstore" +) + +// ConfigProvider combines database and S3 access for download operations. +type ConfigProvider interface { + serviceconfig.ConfigProvider + objectstore.ConfigProvider +} + +// Service handles downloading original document files from S3. +type Service struct { + cfg ConfigProvider +} + +// New creates a new download service. +func New(cfg ConfigProvider) *Service { + return &Service{cfg: cfg} +} diff --git a/pkg/queryAPI/api.gen.go b/pkg/queryAPI/api.gen.go index d503c769..1ff9d92e 100644 --- a/pkg/queryAPI/api.gen.go +++ b/pkg/queryAPI/api.gen.go @@ -1730,6 +1730,9 @@ type ClientInterface interface { // GetDocument request GetDocument(ctx context.Context, id DocumentID, params *GetDocumentParams, reqEditors ...RequestEditorFn) (*http.Response, error) + // DownloadDocument request + DownloadDocument(ctx context.Context, id DocumentID, reqEditors ...RequestEditorFn) (*http.Response, error) + // GetDocumentLabels request GetDocumentLabels(ctx context.Context, documentId openapi_types.UUID, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -2301,6 +2304,18 @@ func (c *Client) GetDocument(ctx context.Context, id DocumentID, params *GetDocu return c.Client.Do(req) } +func (c *Client) DownloadDocument(ctx context.Context, id DocumentID, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewDownloadDocumentRequest(c.Server, id) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Client) GetDocumentLabels(ctx context.Context, documentId openapi_types.UUID, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewGetDocumentLabelsRequest(c.Server, documentId) if err != nil { @@ -4268,6 +4283,40 @@ func NewGetDocumentRequest(server string, id DocumentID, params *GetDocumentPara return req, nil } +// NewDownloadDocumentRequest generates requests for DownloadDocument +func NewDownloadDocumentRequest(server string, id DocumentID) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "id", runtime.ParamLocationPath, id) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/document/%s/download", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + // NewGetDocumentLabelsRequest generates requests for GetDocumentLabels func NewGetDocumentLabelsRequest(server string, documentId openapi_types.UUID) (*http.Request, error) { var err error @@ -5476,6 +5525,9 @@ type ClientWithResponsesInterface interface { // GetDocumentWithResponse request GetDocumentWithResponse(ctx context.Context, id DocumentID, params *GetDocumentParams, reqEditors ...RequestEditorFn) (*GetDocumentResponse, error) + // DownloadDocumentWithResponse request + DownloadDocumentWithResponse(ctx context.Context, id DocumentID, reqEditors ...RequestEditorFn) (*DownloadDocumentResponse, error) + // GetDocumentLabelsWithResponse request GetDocumentLabelsWithResponse(ctx context.Context, documentId openapi_types.UUID, reqEditors ...RequestEditorFn) (*GetDocumentLabelsResponse, error) @@ -6430,6 +6482,32 @@ func (r GetDocumentResponse) StatusCode() int { return 0 } +type DownloadDocumentResponse struct { + Body []byte + HTTPResponse *http.Response + JSON400 *InvalidRequest + JSON401 *Unauthorized + JSON404 *NotFound + JSON429 *TooManyRequests + JSON500 *InternalError +} + +// Status returns HTTPResponse.Status +func (r DownloadDocumentResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r DownloadDocumentResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + type GetDocumentLabelsResponse struct { Body []byte HTTPResponse *http.Response @@ -7479,6 +7557,15 @@ func (c *ClientWithResponses) GetDocumentWithResponse(ctx context.Context, id Do return ParseGetDocumentResponse(rsp) } +// DownloadDocumentWithResponse request returning *DownloadDocumentResponse +func (c *ClientWithResponses) DownloadDocumentWithResponse(ctx context.Context, id DocumentID, reqEditors ...RequestEditorFn) (*DownloadDocumentResponse, error) { + rsp, err := c.DownloadDocument(ctx, id, reqEditors...) + if err != nil { + return nil, err + } + return ParseDownloadDocumentResponse(rsp) +} + // GetDocumentLabelsWithResponse request returning *GetDocumentLabelsResponse func (c *ClientWithResponses) GetDocumentLabelsWithResponse(ctx context.Context, documentId openapi_types.UUID, reqEditors ...RequestEditorFn) (*GetDocumentLabelsResponse, error) { rsp, err := c.GetDocumentLabels(ctx, documentId, reqEditors...) @@ -9706,6 +9793,60 @@ func ParseGetDocumentResponse(rsp *http.Response) (*GetDocumentResponse, error) return response, nil } +// ParseDownloadDocumentResponse parses an HTTP response from a DownloadDocumentWithResponse call +func ParseDownloadDocumentResponse(rsp *http.Response) (*DownloadDocumentResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &DownloadDocumentResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest InvalidRequest + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest Unauthorized + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest NotFound + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 429: + var dest TooManyRequests + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON429 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest InternalError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + // ParseGetDocumentLabelsResponse parses an HTTP response from a GetDocumentLabelsWithResponse call func ParseGetDocumentLabelsResponse(rsp *http.Response) (*GetDocumentLabelsResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) diff --git a/scripts/manual_tests/test_zip_upload.sh b/scripts/manual_tests/test_zip_upload.sh index e3de42fe..027c5b4f 100755 --- a/scripts/manual_tests/test_zip_upload.sh +++ b/scripts/manual_tests/test_zip_upload.sh @@ -27,6 +27,8 @@ # - GET /client/{CLIENT_ID}/document/batch - List all batches for client with pagination # - POST /client/{CLIENT_ID}/document/batch (invalid) - Test error handling with non-ZIP file # - DELETE /client/{CLIENT_ID}/document/batch/{BATCH_ID} - Cancel/delete batch processing +# - GET /document/{DOCUMENT_ID}/download - Download a processed document +# - GET /document/{FAKE_ID}/download - Verify 404 for non-existent document set -e # Exit on any error @@ -257,6 +259,124 @@ test_invalid_file() { fi } +# Wait for at least one document in the batch to have a document_id assigned. +# Polls the batch status endpoint until a document_id appears or timeout is reached. +wait_for_document_id() { + log_info "Waiting for documents to be processed (up to 60s)..." + + local max_wait=60 + local elapsed=0 + local interval=3 + + while [ $elapsed -lt $max_wait ]; do + response=$(curl -s "$BASE_URL/client/$CLIENT_ID/document/batch/$BATCH_ID" 2>/dev/null) + + # Extract first non-null document_id from document_outcomes array + DOCUMENT_ID=$(echo "$response" | python3 -c " +import sys, json +data = json.load(sys.stdin) +for outcome in data.get('document_outcomes', []): + doc_id = outcome.get('document_id') + if doc_id: + print(doc_id) + sys.exit(0) +sys.exit(1) +" 2>/dev/null) + + if [ $? -eq 0 ] && [ -n "$DOCUMENT_ID" ]; then + log_info "Document ready for download: $DOCUMENT_ID" + return 0 + fi + + sleep $interval + elapsed=$((elapsed + interval)) + log_info "Still waiting... ($elapsed/${max_wait}s)" + done + + log_error "Timed out waiting for documents to be processed" + return 1 +} + +# Test downloading a document via GET /document/{id}/download. +# Verifies HTTP 200, Content-Disposition header, Content-Type, non-empty body, and PDF magic bytes. +test_document_download() { + log_info "Testing document download: GET /document/$DOCUMENT_ID/download" + + local output_file="downloaded_document.pdf" + + # Download with headers captured + curl -s -D headers.txt -o "$output_file" \ + "$BASE_URL/document/$DOCUMENT_ID/download" 2>/dev/null + + http_code=$(grep -m1 "^HTTP/" headers.txt | awk '{print $2}') + + if [ "$http_code" != "200" ]; then + log_error "Download failed (HTTP $http_code)" + cat headers.txt 2>/dev/null + rm -f "$output_file" headers.txt + return 1 + fi + + log_info "Download returned HTTP 200" + + # Verify Content-Disposition header is present + if grep -qi "Content-Disposition" headers.txt; then + disposition=$(grep -i "Content-Disposition" headers.txt | tr -d '\r') + log_info "$disposition" + else + log_error "Missing Content-Disposition header" + rm -f "$output_file" headers.txt + return 1 + fi + + # Log Content-Type + content_type=$(grep -i "Content-Type" headers.txt | head -1 | tr -d '\r') + log_info "$content_type" + + # Verify file is non-empty + file_size=$(stat -f%z "$output_file" 2>/dev/null || stat -c%s "$output_file" 2>/dev/null || echo "0") + if [ "$file_size" -eq 0 ]; then + log_error "Downloaded file is empty" + rm -f "$output_file" headers.txt + return 1 + fi + log_info "Downloaded file size: $file_size bytes" + + # Verify file starts with PDF magic bytes (%PDF) + file_header=$(head -c 4 "$output_file") + if [ "$file_header" = "%PDF" ]; then + log_info "File is a valid PDF (starts with %%PDF header)" + else + log_warn "File does not start with %%PDF header (got: $file_header)" + fi + + # Cleanup + rm -f "$output_file" headers.txt + + log_info "Document download test PASSED" + return 0 +} + +# Test that downloading a non-existent document returns 404. +test_download_not_found() { + log_info "Testing download of non-existent document (should return 404)..." + + local fake_id="00000000-0000-0000-0000-000000000000" + + response=$(curl -s -w "\n%{http_code}" \ + "$BASE_URL/document/$fake_id/download" 2>/dev/null) + + http_code=$(echo "$response" | tail -n1) + + if [ "$http_code" = "404" ]; then + log_info "Non-existent document correctly returned 404" + return 0 + else + log_warn "Expected HTTP 404 for non-existent document, got HTTP $http_code" + return 1 + fi +} + # Cancel batch cancel_batch() { log_info "Canceling batch..." @@ -280,7 +400,7 @@ cancel_batch() { # Cleanup function cleanup() { log_info "Cleaning up..." - rm -f "$ZIP_FILE" invalid.txt + rm -f "$ZIP_FILE" invalid.txt downloaded_document.pdf headers.txt rm -rf temp_zip_structure log_info "Cleanup complete" } @@ -303,6 +423,15 @@ main() { sleep 1 # Brief pause to let the system process get_batch_status list_batches + + # Document download tests -- must run before cancel_batch + if wait_for_document_id; then + test_document_download + test_download_not_found + else + log_warn "Skipping download tests - no documents were processed in time" + fi + test_invalid_file cancel_batch diff --git a/serviceAPIs/queryAPI.yaml b/serviceAPIs/queryAPI.yaml index 97e5abba..76403855 100644 --- a/serviceAPIs/queryAPI.yaml +++ b/serviceAPIs/queryAPI.yaml @@ -645,6 +645,48 @@ paths: "500": $ref: "#/components/responses/InternalError" + /document/{id}/download: + parameters: + - $ref: "#/components/parameters/DocumentID" + get: + operationId: downloadDocument + tags: + - DocumentsService + summary: Download the original document file + description: | + Streams the original uploaded document file. The response Content-Type + is auto-detected from the stored filename extension. No query parameters + are required; the system resolves the file type automatically. + security: + - jwtAuth: [] + responses: + "200": + description: Document file bytes. + headers: + RateLimit: + $ref: "#/components/headers/RateLimit" + Content-Disposition: + schema: + type: string + maxLength: 4096 + description: 'Attachment header with original filename, e.g. attachment; filename="report.pdf"' + content: + application/octet-stream: + schema: + type: string + format: binary + maxLength: 107374182400 + "400": + $ref: "#/components/responses/InvalidRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + "429": + $ref: "#/components/responses/TooManyRequests" + "500": + $ref: "#/components/responses/InternalError" + /folders: post: operationId: createFolder