Compare commits

...
Author SHA1 Message Date
Anthony Stirling 9422d4de66 Merge remote-tracking branch 'origin/main' into work-6202
# Conflicts:
#	app/common/src/main/java/stirling/software/common/service/InternalApiClient.java
2026-05-26 19:16:06 +01:00
Anthony Stirling b4da7639e3 unify temp file backed resource wrappers 2026-04-23 19:18:10 +01:00
6 changed files with 219 additions and 164 deletions
@@ -10,7 +10,6 @@ import java.util.regex.Pattern;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.env.Environment;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.*;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
@@ -27,6 +26,7 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.enumeration.Role;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileBackedResource;
import stirling.software.common.util.TempFileManager;
/**
@@ -85,7 +85,10 @@ public class InternalApiClient {
*
* @param endpointPath API path (e.g. {@code /api/v1/general/rotate-pdf})
* @param body multipart form body (fileInput + parameters)
* @return response with the result file as a {@link TempFileResource} body
* @return response whose body is an unmanaged {@link TempFileBackedResource}. The backing temp
* file survives {@code InputStream#close()} so callers can read it multiple times (e.g.
* chain it into the next pipeline step). Cleanup is handled by the pipeline's registered
* temp-file tracker or the background registry sweep.
*/
public ResponseEntity<Resource> post(String endpointPath, MultiValueMap<String, Object> body) {
validateUrl(endpointPath);
@@ -114,7 +117,8 @@ public class InternalApiClient {
tempFile.getPath(),
java.nio.file.StandardCopyOption.REPLACE_EXISTING);
String filename = extractFilename(response.getHeaders());
TempFileResource resource = new TempFileResource(tempFile, filename);
TempFileBackedResource resource =
TempFileBackedResource.unmanaged(tempFile, filename);
return ResponseEntity.status(response.getStatusCode())
.headers(response.getHeaders())
.body(resource);
@@ -184,35 +188,4 @@ public class InternalApiClient {
"Internal API dispatch not permitted for endpoint: " + endpointPath);
}
}
/**
* A {@link FileSystemResource} that holds a reference to its backing {@link TempFile}.
*
* <p>If a display filename is supplied (typically parsed from the upstream response's {@code
* Content-Disposition} header), it is returned from {@link #getFilename()} instead of the
* underlying temp file's path-based name.
*/
public static class TempFileResource extends FileSystemResource {
private final TempFile tempFile;
private final String displayFilename;
public TempFileResource(TempFile tempFile) {
this(tempFile, null);
}
public TempFileResource(TempFile tempFile, String displayFilename) {
super(tempFile.getFile());
this.tempFile = tempFile;
this.displayFilename = displayFilename;
}
public TempFile getTempFile() {
return tempFile;
}
@Override
public String getFilename() {
return displayFilename != null ? displayFilename : super.getFilename();
}
}
}
@@ -0,0 +1,167 @@
package stirling.software.common.util;
import java.io.FilterInputStream;
import java.io.IOException;
import java.io.InputStream;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import lombok.extern.slf4j.Slf4j;
/**
* {@link Resource} backed by a {@link TempFile}, with an optional auto-delete-on-close lifecycle.
*
* <p>Two flavours are exposed via static factories:
*
* <ul>
* <li>{@link #managed(TempFile)} — single-use. {@link #getInputStream()} wraps the stream so the
* backing {@link TempFile} is deleted when the stream is closed. Intended for terminal
* consumers (e.g. Spring's {@code ResourceHttpMessageConverter} writing an HTTP response
* body). Callers that need to re-read the body must copy it first.
* <li>{@link #unmanaged(TempFile, String)} — multi-use. {@link #getInputStream()} returns the
* plain file stream without attaching cleanup. The backing file lives until an external owner
* (typically {@link TempFileRegistry}'s background sweep or an explicit pipeline tracker)
* deletes it. Intended for intermediate results that need to be read more than once — for
* example the body of an internal API call that will be streamed as input into the next
* pipeline step.
* </ul>
*
* <p>When a display filename is supplied (typically parsed from an upstream response's {@code
* Content-Disposition} header), it is returned from {@link #getFilename()} instead of the
* underlying temp file's path-based name.
*
* <p><b>Managed-mode failure handling:</b> if {@code super.getInputStream()} throws while opening
* the file, the backing {@link TempFile} is closed before the exception propagates so we never leak
* temp files along the error path. Read failures mid-body are logged and rethrown — they do not
* suppress the cleanup performed on {@link InputStream#close()}.
*/
@Slf4j
public class TempFileBackedResource extends FileSystemResource {
private final TempFile tempFile;
private final String displayFilename;
private final boolean autoDeleteOnClose;
/**
* Create a managed resource whose backing temp file is deleted when the returned input stream
* is closed.
*/
public static TempFileBackedResource managed(TempFile tempFile) {
return new TempFileBackedResource(tempFile, null, true);
}
/**
* Create an unmanaged resource. The returned input stream leaves the backing temp file in place
* on close; lifetime is the caller's responsibility.
*/
public static TempFileBackedResource unmanaged(TempFile tempFile) {
return new TempFileBackedResource(tempFile, null, false);
}
/**
* Create an unmanaged resource with a display filename returned from {@link #getFilename()}.
*/
public static TempFileBackedResource unmanaged(TempFile tempFile, String displayFilename) {
return new TempFileBackedResource(tempFile, displayFilename, false);
}
private TempFileBackedResource(
TempFile tempFile, String displayFilename, boolean autoDeleteOnClose) {
super(tempFile.getFile());
this.tempFile = tempFile;
this.displayFilename = displayFilename;
this.autoDeleteOnClose = autoDeleteOnClose;
}
public TempFile getTempFile() {
return tempFile;
}
@Override
public String getFilename() {
return displayFilename != null ? displayFilename : super.getFilename();
}
@Override
public InputStream getInputStream() throws IOException {
if (!autoDeleteOnClose) {
return super.getInputStream();
}
InputStream source;
try {
source = super.getInputStream();
} catch (IOException e) {
// Opening the input stream already failed; make sure we don't leak the temp file.
try {
tempFile.close();
} catch (Exception closeEx) {
e.addSuppressed(closeEx);
}
throw e;
}
return new ClosingInputStream(source, tempFile);
}
/**
* Stream wrapper that deletes its backing {@link TempFile} on close. Logs — but does not
* swallow — any IOException that happens while reading the body, so upstream handlers can
* surface the failure to the client.
*/
private static final class ClosingInputStream extends FilterInputStream {
private final TempFile tempFile;
private boolean closed;
ClosingInputStream(InputStream delegate, TempFile tempFile) {
super(delegate);
this.tempFile = tempFile;
}
@Override
public int read() throws IOException {
try {
return super.read();
} catch (IOException e) {
log.error(
"Failed to read temp response body {} while streaming to client",
tempFile.getAbsolutePath(),
e);
throw e;
}
}
@Override
public int read(byte[] b, int off, int len) throws IOException {
try {
return super.read(b, off, len);
} catch (IOException e) {
log.error(
"Failed to read temp response body {} while streaming to client",
tempFile.getAbsolutePath(),
e);
throw e;
}
}
@Override
public void close() throws IOException {
if (closed) {
return;
}
closed = true;
try {
super.close();
} finally {
try {
tempFile.close();
} catch (Exception closeEx) {
log.warn(
"Failed to clean up temp file {} after streaming response",
tempFile.getAbsolutePath(),
closeEx);
}
}
}
}
}
@@ -1,7 +1,6 @@
package stirling.software.common.util;
import java.io.ByteArrayOutputStream;
import java.io.FilterInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URLEncoder;
@@ -10,7 +9,6 @@ import java.nio.file.Files;
import java.nio.file.Path;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
@@ -20,9 +18,6 @@ import org.springframework.web.multipart.MultipartFile;
import io.github.pixee.security.Filenames;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class WebResponseUtils {
public static ResponseEntity<byte[]> baosToWebResponse(
@@ -125,12 +120,12 @@ public class WebResponseUtils {
/**
* Convert a {@link TempFile} into a web response with an explicit media type.
*
* <p>The returned {@link ResponseEntity} carries a {@link ManagedTempFileResource} as its body.
* Spring's {@code ResourceHttpMessageConverter} calls {@link Resource#getInputStream()} once
* and closes the returned stream after writing — at which point the underlying {@link TempFile}
* is deleted. Everything runs synchronously on the request thread, so write failures propagate
* through normal Spring error handling and are logged, rather than silently truncating the
* response.
* <p>The returned {@link ResponseEntity} carries a {@link TempFileBackedResource} (managed
* flavour) as its body. Spring's {@code ResourceHttpMessageConverter} calls {@link
* Resource#getInputStream()} once and closes the returned stream after writing — at which point
* the underlying {@link TempFile} is deleted. Everything runs synchronously on the request
* thread, so write failures propagate through normal Spring error handling and are logged,
* rather than silently truncating the response.
*
* @param outputTempFile The temporary file to be sent as a response.
* @param docName The name of the document.
@@ -148,7 +143,7 @@ public class WebResponseUtils {
headers.setContentLength(len);
headers.setContentDispositionFormData("attachment", encodeAttachmentName(docName));
Resource body = new ManagedTempFileResource(outputTempFile);
Resource body = TempFileBackedResource.managed(outputTempFile);
return new ResponseEntity<>(body, headers, HttpStatus.OK);
} catch (IOException | RuntimeException e) {
try {
@@ -166,108 +161,4 @@ public class WebResponseUtils {
.matcher(URLEncoder.encode(docName, StandardCharsets.UTF_8))
.replaceAll("%20");
}
/**
* {@link Resource} backed by a {@link TempFile}. The underlying temp file is deleted when the
* response {@code InputStream} is closed — i.e. after Spring has finished writing the body. Any
* {@link IOException} during the copy is logged via {@link ClosingInputStream} and propagates
* through Spring's normal error path. Since response headers are committed before the body
* transfer begins, a mid-body failure manifests as a server-side log entry plus an aborted
* connection rather than a silently-truncated success — which is the behaviour this class was
* added to restore.
*
* <p><b>Single-use contract:</b> {@link #getInputStream()} is intended to be called once by
* Spring's {@code ResourceHttpMessageConverter} on the normal write path. After the returned
* stream is closed the backing temp file is deleted, so subsequent {@code getInputStream()}
* calls will either see a deleted file (tests that mock {@link TempFile#close()} are an
* exception) or fail at read time. Callers that need to re-read the body must copy it first.
*/
public static final class ManagedTempFileResource extends FileSystemResource {
private final TempFile tempFile;
public ManagedTempFileResource(TempFile tempFile) {
super(tempFile.getFile());
this.tempFile = tempFile;
}
@Override
public InputStream getInputStream() throws IOException {
InputStream source;
try {
source = super.getInputStream();
} catch (IOException e) {
// Opening the input stream already failed; make sure we don't leak the temp file.
try {
tempFile.close();
} catch (Exception closeEx) {
e.addSuppressed(closeEx);
}
throw e;
}
return new ClosingInputStream(source, tempFile);
}
}
/**
* Stream wrapper that deletes its backing {@link TempFile} on close. Logs — but does not
* swallow — any IOException that happens while reading the body, so upstream handlers can
* surface the failure to the client.
*/
private static final class ClosingInputStream extends FilterInputStream {
private final TempFile tempFile;
private boolean closed;
ClosingInputStream(InputStream delegate, TempFile tempFile) {
super(delegate);
this.tempFile = tempFile;
}
@Override
public int read() throws IOException {
try {
return super.read();
} catch (IOException e) {
log.error(
"Failed to read temp response body {} while streaming to client",
tempFile.getAbsolutePath(),
e);
throw e;
}
}
@Override
public int read(byte[] b, int off, int len) throws IOException {
try {
return super.read(b, off, len);
} catch (IOException e) {
log.error(
"Failed to read temp response body {} while streaming to client",
tempFile.getAbsolutePath(),
e);
throw e;
}
}
@Override
public void close() throws IOException {
if (closed) {
return;
}
closed = true;
try {
super.close();
} finally {
try {
tempFile.close();
} catch (Exception closeEx) {
log.warn(
"Failed to clean up temp file {} after streaming response",
tempFile.getAbsolutePath(),
closeEx);
}
}
}
}
}
@@ -123,8 +123,7 @@ class WebResponseUtilsTest {
File backing = tempFile.getFile();
assertTrue(backing.exists(), "precondition: backing file should exist");
WebResponseUtils.ManagedTempFileResource resource =
new WebResponseUtils.ManagedTempFileResource(tempFile);
TempFileBackedResource resource = TempFileBackedResource.managed(tempFile);
byte[] readBack;
try (InputStream in = resource.getInputStream()) {
@@ -139,8 +138,7 @@ class WebResponseUtilsTest {
@Test
void closingInputStream_propagatesReadFailure() throws IOException {
// ManagedTempFileResource is final, so we can't swap in a mock underlying stream.
// Instead: open the resource's stream, close the inner FileInputStream early
// Open the resource's stream, close the inner FileInputStream early
// (by closing the outer stream once), then confirm reading the now-closed stream
// throws — exercising ClosingInputStream's read() catch/log/rethrow path. Finally
// confirm the temp file is still cleaned up on close().
@@ -149,8 +147,7 @@ class WebResponseUtilsTest {
File backing = tempFile.getFile();
assertTrue(backing.exists());
WebResponseUtils.ManagedTempFileResource resource =
new WebResponseUtils.ManagedTempFileResource(tempFile);
TempFileBackedResource resource = TempFileBackedResource.managed(tempFile);
InputStream in = resource.getInputStream();
// Pre-close the underlying stream to guarantee read() throws.
@@ -188,12 +185,39 @@ class WebResponseUtilsTest {
spying.getFile().delete(),
"precondition: delete backing so super.getInputStream() fails");
WebResponseUtils.ManagedTempFileResource resource =
new WebResponseUtils.ManagedTempFileResource(spying);
TempFileBackedResource resource = TempFileBackedResource.managed(spying);
assertThrows(IOException.class, resource::getInputStream);
assertTrue(
closed.get(),
"tempFile.close() must run when super.getInputStream() fails on open");
}
@Test
void unmanagedTempFileResource_leavesBackingFileIntactAfterClose() throws IOException {
TempFile tempFile = tempFileManager.createManagedTempFile(".bin");
byte[] payload = "intermediate-pipeline-result".getBytes(StandardCharsets.UTF_8);
Files.write(tempFile.getPath(), payload);
File backing = tempFile.getFile();
TempFileBackedResource resource = TempFileBackedResource.unmanaged(tempFile, "result.bin");
byte[] firstRead;
try (InputStream in = resource.getInputStream()) {
firstRead = in.readAllBytes();
}
assertArrayEquals(payload, firstRead);
assertTrue(backing.exists(), "unmanaged resource must not delete backing file on close");
// Second read — must still work because the unmanaged stream doesn't destroy state.
byte[] secondRead;
try (InputStream in = resource.getInputStream()) {
secondRead = in.readAllBytes();
}
assertArrayEquals(payload, secondRead);
assertTrue(backing.exists());
// displayFilename is returned from getFilename().
assertEquals("result.bin", resource.getFilename());
}
}
@@ -38,6 +38,7 @@ import stirling.software.common.model.api.PDFFile;
import stirling.software.common.service.JobOwnershipService;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileBackedResource;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
@@ -166,7 +167,7 @@ public class ConvertPdfJsonController {
.header("X-Job-Id", scopedJobKey)
.contentType(MediaType.APPLICATION_JSON)
.contentLength(Files.size(tempOut.getPath()))
.body(new WebResponseUtils.ManagedTempFileResource(tempOut));
.body(TempFileBackedResource.managed(tempOut));
} catch (IOException | RuntimeException e) {
tempOut.close();
throw e;
@@ -29,6 +29,7 @@ import stirling.software.SPDF.model.PipelineOperation;
import stirling.software.SPDF.model.PipelineResult;
import stirling.software.SPDF.service.ApiDocService;
import stirling.software.common.service.InternalApiClient;
import stirling.software.common.util.TempFileBackedResource;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.ZipExtractionUtils;
@@ -125,8 +126,7 @@ public class PipelineProcessor {
// this
// file
if (response.getBody()
instanceof
InternalApiClient.TempFileResource tempFileResource) {
instanceof TempFileBackedResource tempFileResource) {
result.addTempFile(tempFileResource.getTempFile());
}
@@ -204,8 +204,7 @@ public class PipelineProcessor {
}
}
ResponseEntity<Resource> response = internalApiClient.post(operation, body);
if (response.getBody()
instanceof InternalApiClient.TempFileResource tempFileResource) {
if (response.getBody() instanceof TempFileBackedResource tempFileResource) {
result.addTempFile(tempFileResource.getTempFile());
}
// Handle the response
@@ -284,7 +283,7 @@ public class PipelineProcessor {
response.getBody(), tempFileManager, result::addTempFile));
} else {
final Resource tempResource = response.getBody();
if (tempResource instanceof InternalApiClient.TempFileResource tfr) {
if (tempResource instanceof TempFileBackedResource tfr) {
result.addTempFile(tfr.getTempFile());
}
Resource outputResource =