Support multi-file async job results and ZIP extraction (#3922)

# Description of Changes

This PR introduces multi-file support for asynchronous jobs in the
Stirling PDF backend, enabling jobs to return and manage multiple result
files. Previously, job results were limited to a single file represented
by fileId, originalFileName, and contentType. This change replaces that
legacy structure with a new ResultFile abstraction and expands the
functionality throughout the core system.

ZIP File Support
If a job result is a ZIP file:
It is automatically unpacked using buffered streaming.
Each contained file is stored individually and recorded as a ResultFile.
The original ZIP is deleted after successful extraction.
If ZIP extraction fails, the job result is treated as a single file.


New and Updated API Endpoints

1. GET /api/v1/general/job/{jobId}/result

If the job has multiple files → returns a JSON metadata list.

If the job has a single file → streams the file directly.

Includes UTF-8-safe Content-Disposition headers for filename support.

2. GET /api/v1/general/job/{jobId}/result/files
New endpoint that returns:

```json
{
  "jobId": "123",
  "fileCount": 2,
  "files": [
    {
      "fileId": "abc",
      "fileName": "page1.pdf",
      "contentType": "application/pdf",
      "fileSize": 12345
    },
    ...
  ]
}
```


3. GET /api/v1/general/files/{fileId}/metadata
Returns metadata for a specific file:


4. GET /api/v1/general/files/{fileId}
Downloads a file by fileId, using metadata to determine filename and
content type.

---------

Co-authored-by: Copilot <[email protected]>
Co-authored-by: pixeebot[bot] <104101892+pixeebot[bot]@users.noreply.github.com>
This commit is contained in:
Anthony Stirling
2025-07-11 13:15:55 +01:00
committed by GitHub
co-authored by Copilot pixeebot[bot] <104101892+pixeebot[bot]@users.noreply.github.com>
parent d17d10b240
commit bbf5d5f6d4
8 changed files with 493 additions and 59 deletions
@@ -19,6 +19,7 @@ import jakarta.servlet.http.HttpSession;
import stirling.software.common.model.job.JobResult;
import stirling.software.common.model.job.JobStats;
import stirling.software.common.model.job.ResultFile;
import stirling.software.common.service.FileStorage;
import stirling.software.common.service.JobQueue;
import stirling.software.common.service.TaskManager;
@@ -138,7 +139,7 @@ class JobControllerTest {
JobResult mockResult = new JobResult();
mockResult.setJobId(jobId);
mockResult.completeWithFile(fileId, originalFileName, contentType);
mockResult.completeWithSingleFile(fileId, originalFileName, contentType, fileContent.length);
when(taskManager.getJobResult(jobId)).thenReturn(mockResult);
when(fileStorage.retrieveBytes(fileId)).thenReturn(fileContent);
@@ -215,7 +216,7 @@ class JobControllerTest {
JobResult mockResult = new JobResult();
mockResult.setJobId(jobId);
mockResult.completeWithFile(fileId, originalFileName, contentType);
mockResult.completeWithSingleFile(fileId, originalFileName, contentType, 1024L);
when(taskManager.getJobResult(jobId)).thenReturn(mockResult);
when(fileStorage.retrieveBytes(fileId)).thenThrow(new RuntimeException("File not found"));