Add Resource shim; convert WebResponseUtils to JAX-RS Response/StreamingOutput

This commit is contained in:
a
2026-06-12 17:20:53 +01:00
parent f24c9e0501
commit f0c7fbdac9
16 changed files with 278 additions and 194 deletions
@@ -24,9 +24,9 @@ import java.util.UUID;
// custom ConfigSource, and java.nio / ClassLoader#getResourceAsStream for resources).
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.core.env.PropertySource;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import stirling.software.common.model.io.ClassPathResource;
import stirling.software.common.model.io.FileSystemResource;
import stirling.software.common.model.io.Resource;
import org.springframework.core.io.support.EncodedResource;
import com.fasterxml.jackson.annotation.JsonIgnore;
@@ -0,0 +1,64 @@
package stirling.software.common.model.io;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
/** Classpath-backed {@link Resource} (migration shim for Spring's {@code ClassPathResource}). */
public class ClassPathResource implements Resource {
private final String path;
private final ClassLoader classLoader;
public ClassPathResource(String path) {
this(path, ClassPathResource.class.getClassLoader());
}
public ClassPathResource(String path, ClassLoader classLoader) {
this.path = path.startsWith("/") ? path.substring(1) : path;
this.classLoader = classLoader != null ? classLoader : ClassLoader.getSystemClassLoader();
}
@Override
public InputStream getInputStream() throws IOException {
InputStream is = classLoader.getResourceAsStream(path);
if (is == null) {
throw new IOException("class path resource [" + path + "] cannot be opened");
}
return is;
}
@Override
public boolean exists() {
return classLoader.getResource(path) != null;
}
@Override
public String getFilename() {
int sep = path.lastIndexOf('/');
return sep != -1 ? path.substring(sep + 1) : path;
}
@Override
public long contentLength() throws IOException {
try (InputStream is = getInputStream()) {
long count = 0;
byte[] buf = new byte[8192];
int read;
while ((read = is.read(buf)) != -1) {
count += read;
}
return count;
}
}
@Override
public File getFile() throws IOException {
URL url = classLoader.getResource(path);
if (url == null || !"file".equals(url.getProtocol())) {
throw new IOException("class path resource [" + path + "] is not a filesystem file");
}
return new File(url.getFile());
}
}
@@ -0,0 +1,56 @@
package stirling.software.common.model.io;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
/** File-backed {@link Resource} (migration shim for Spring's {@code FileSystemResource}). */
public class FileSystemResource implements Resource {
private final Path path;
public FileSystemResource(Path path) {
this.path = path;
}
public FileSystemResource(File file) {
this.path = file.toPath();
}
public FileSystemResource(String path) {
this.path = Path.of(path);
}
@Override
public InputStream getInputStream() throws IOException {
return Files.newInputStream(path);
}
@Override
public boolean exists() {
return Files.exists(path);
}
@Override
public String getFilename() {
Path name = path.getFileName();
return name == null ? null : name.toString();
}
@Override
public long contentLength() throws IOException {
return Files.size(path);
}
@Override
public boolean isFile() {
return true;
}
@Override
public File getFile() {
return path.toFile();
}
}
@@ -0,0 +1,50 @@
package stirling.software.common.model.io;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
/**
* Stream-backed {@link Resource} (migration shim for Spring's {@code InputStreamResource}). As with
* Spring, the stream can only be read once.
*/
public class InputStreamResource implements Resource {
private final InputStream inputStream;
private final String filename;
public InputStreamResource(InputStream inputStream) {
this(inputStream, null);
}
public InputStreamResource(InputStream inputStream, String filename) {
this.inputStream = inputStream;
this.filename = filename;
}
@Override
public InputStream getInputStream() {
return inputStream;
}
@Override
public boolean exists() {
return true;
}
@Override
public String getFilename() {
return filename;
}
@Override
public long contentLength() throws IOException {
// Spring's InputStreamResource also cannot report length without consuming the stream.
return -1;
}
@Override
public File getFile() throws IOException {
throw new IOException("InputStreamResource is not backed by a file");
}
}
@@ -0,0 +1,40 @@
package stirling.software.common.model.io;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
/**
* Migration compatibility shim for Spring's {@code org.springframework.core.io.Resource}.
*
* <p>Quarkus/Jakarta has no single {@code Resource} abstraction. Rather than rewrite the many
* public method signatures across the codebase that accept or return {@code Resource}, this
* interface mirrors the subset of Spring's API the codebase actually uses
* ({@code getInputStream/exists/getFile/getFilename/contentLength/isFile}) together with the
* {@link FileSystemResource}, {@link InputStreamResource} and {@link ClassPathResource}
* implementations. Converting a file is then just an import swap.
*
* <p>TODO: Migration required - longer term, prefer {@code java.nio.file.Path} / {@code InputStream}
* directly at the boundaries and retire this shim.
*/
public interface Resource {
InputStream getInputStream() throws IOException;
boolean exists();
String getFilename();
long contentLength() throws IOException;
/** Whether this resource is backed by a real file in the filesystem. */
default boolean isFile() {
return false;
}
/**
* @return the underlying file.
* @throws IOException if the resource is not file-backed.
*/
File getFile() throws IOException;
}
@@ -14,7 +14,7 @@ import jakarta.enterprise.context.ApplicationScoped;
// TODO: Migration required - org.springframework.core.io.Resource is used in public method
// signatures (storeFile, storeFromResource) and for behavior (isFile()/getFile()/getInputStream()).
// No drop-in JAX-RS/Jakarta equivalent; converting would ripple into callers. Keeping the type.
import org.springframework.core.io.Resource;
import stirling.software.common.model.io.Resource;
// TODO: Migration required - org.springframework.web.multipart.MultipartFile is used in public
// method signatures (storeFile, retrieveFile). No servlet/JAX-RS drop-in for utility method
// params; changing it would ripple widely into callers. Keeping the type.
@@ -16,8 +16,8 @@ import java.util.regex.Pattern;
// non-trivial and would ripple to callers (PipelineProcessor, AiWorkflowService) that pass
// MultiValueMap bodies and consume ResponseEntity<Resource>. These Spring imports are
// intentionally retained until that cross-file migration is scheduled.
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import stirling.software.common.model.io.FileSystemResource;
import stirling.software.common.model.io.Resource;
import org.springframework.http.*;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.util.MultiValueMap;
@@ -17,7 +17,7 @@ import java.util.function.Supplier;
// because they are consumed via ResponseEntity#getHeaders()/getBody() and built
// via ResponseEntity builders. These imports are intentionally retained until the
// callers are migrated as a unit.
import org.springframework.core.io.Resource;
import stirling.software.common.model.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
@@ -21,7 +21,7 @@ import java.util.regex.Pattern;
// resource API. Resource/ResourceLoader/ResourcePatternUtils remain in getResourcesFromLocationPattern
// because that method's public signature and the Spring resource-pattern resolver are used by
// callers; converting it requires a filesystem/classpath glob rewrite that ripples to callers.
import org.springframework.core.io.Resource;
import stirling.software.common.model.io.Resource;
import org.springframework.core.io.ResourceLoader;
import org.springframework.core.io.support.ResourcePatternUtils;
@@ -27,7 +27,7 @@ import org.apache.commons.io.IOUtils;
// org.springframework.http.MediaType -> jakarta.ws.rs.core.MediaType,
// HttpStatus.BAD_REQUEST -> Response.Status.BAD_REQUEST,
// MultipartFile -> byte[]/InputStream or a JAX-RS multipart type.
import org.springframework.core.io.Resource;
import stirling.software.common.model.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
@@ -11,27 +11,12 @@ import java.nio.file.Path;
import org.apache.pdfbox.pdmodel.PDDocument;
// TODO: Migration required - org.springframework.core.io.Resource/FileSystemResource have no
// JAX-RS drop-in. The file-backed responses below (pdfFileToWebResponse / zipFileToWebResponse /
// fileToWebResponse and the ManagedTempFileResource inner class) rely on Spring's
// ResourceHttpMessageConverter calling Resource#getInputStream() once and closing it after writing
// the body, which is what triggers TempFile deletion. Under Quarkus/JAX-RS the equivalent is to
// return a StreamingOutput (or InputStream) and delete the TempFile after the stream is fully
// written. The Spring Resource imports are retained until those methods are reworked, because
// removing them would require changing the public ResponseEntity<Resource> signatures and the
// converter-driven lifecycle they depend on.
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.ResponseEntity;
// TODO: Migration required - org.springframework.web.multipart.MultipartFile has no servlet/JAX-RS
// drop-in for a utility method parameter. Converting multiPartFileToWebResponse to accept
// byte[]/InputStream would ripple through every caller, so the Spring type and its import are kept
// here intentionally.
import stirling.software.common.model.MultipartFile;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.StreamingOutput;
import io.github.pixee.security.Filenames;
@@ -91,16 +76,15 @@ public class WebResponseUtils {
}
/**
* Save a {@link PDDocument} to a managed temp file and return it as a file-backed {@code
* ResponseEntity<Resource>}.
* Save a {@link PDDocument} to a managed temp file and return it as a streamed web response.
*
* <p>The returned {@link Resource} owns the supplied {@link TempFile} — the file is deleted
* when Spring closes the response {@link InputStream} after writing the body. This is a
* synchronous equivalent of the previous {@code StreamingResponseBody} pattern and avoids the
* async-dispatch hazards (response-committed races, filter incompatibility, silent write
* failures) that {@code StreamingResponseBody} introduced.
* <p>MIGRATION (Spring -> JAX-RS): previously returned {@code ResponseEntity<Resource>} backed
* by a {@code ManagedTempFileResource}, relying on Spring's {@code ResourceHttpMessageConverter}
* to call {@code Resource#getInputStream()} and close it after writing (the hook that deleted
* the {@link TempFile}). The JAX-RS equivalent is a {@link StreamingOutput} that copies the file
* to the response and deletes the temp file in a {@code finally} block once writing completes.
*/
public static ResponseEntity<Resource> pdfDocToWebResponse(
public static Response pdfDocToWebResponse(
PDDocument document, String docName, TempFileManager tempFileManager)
throws IOException {
TempFile tempFile = tempFileManager.createManagedTempFile(".pdf");
@@ -113,71 +97,63 @@ public class WebResponseUtils {
return pdfFileToWebResponse(tempFile, docName);
}
/**
* Convert a {@link TempFile} holding a PDF into a web response.
*
* <p>The temp file is deleted when Spring closes the response body stream.
*
* @param outputTempFile The temporary file to be sent as a response.
* @param docName The name of the document.
* @return A ResponseEntity whose body streams the file, deleting it on close.
*/
public static ResponseEntity<Resource> pdfFileToWebResponse(
TempFile outputTempFile, String docName) throws IOException {
/** Convert a {@link TempFile} holding a PDF into a streamed web response (deletes on close). */
public static Response pdfFileToWebResponse(TempFile outputTempFile, String docName)
throws IOException {
return fileToWebResponse(outputTempFile, docName, APPLICATION_PDF);
}
/**
* Convert a {@link TempFile} holding a ZIP archive into a web response.
*
* <p>The temp file is deleted when Spring closes the response body stream.
*
* @param outputTempFile The temporary file to be sent as a response.
* @param docName The name of the document.
* @return A ResponseEntity whose body streams the file, deleting it on close.
*/
public static ResponseEntity<Resource> zipFileToWebResponse(
TempFile outputTempFile, String docName) throws IOException {
/** Convert a {@link TempFile} holding a ZIP into a streamed web response (deletes on close). */
public static Response zipFileToWebResponse(TempFile outputTempFile, String docName)
throws IOException {
return fileToWebResponse(
outputTempFile, docName, MediaType.valueOf(MediaType.APPLICATION_OCTET_STREAM));
}
/**
* Convert a {@link TempFile} into a web response with an explicit media type.
* Convert a {@link TempFile} into a streamed 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.
*
* @param outputTempFile The temporary file to be sent as a response.
* @param docName The name of the document.
* @param mediaType The content type to set on the response.
* @return A ResponseEntity whose body streams the file, deleting it on close.
* <p>The body is a {@link StreamingOutput} that copies the temp file to the client and deletes
* the backing {@link TempFile} once the transfer completes (or fails). This replaces the former
* Spring {@code ResponseEntity<Resource>} + {@code ResourceHttpMessageConverter} lifecycle.
* I/O errors during the copy are logged and propagated.
*/
// TODO: Migration required - ResponseEntity<Resource> + FileSystemResource depend on Spring's
// ResourceHttpMessageConverter to invoke Resource#getInputStream() and close it after writing,
// which is the hook that deletes the backing TempFile. There is no faithful JAX-RS drop-in for
// that lifecycle; rework to return jakarta.ws.rs.core.Response carrying a StreamingOutput that
// deletes the TempFile after writing. Left intact to preserve behaviour and the public
// signature until callers can be migrated together.
public static ResponseEntity<Resource> fileToWebResponse(
public static Response fileToWebResponse(
TempFile outputTempFile, String docName, MediaType mediaType) throws IOException {
try {
Path path = outputTempFile.getFile().toPath().normalize();
long len = Files.size(path);
org.springframework.http.HttpHeaders headers =
new org.springframework.http.HttpHeaders();
headers.setContentType(
org.springframework.http.MediaType.parseMediaType(mediaType.toString()));
headers.setContentLength(len);
headers.setContentDispositionFormData("attachment", encodeAttachmentName(docName));
Resource body = new ManagedTempFileResource(outputTempFile);
return new ResponseEntity<>(body, headers, org.springframework.http.HttpStatus.OK);
StreamingOutput body =
output -> {
try (InputStream in = Files.newInputStream(path)) {
in.transferTo(output);
} catch (IOException e) {
log.error(
"Failed to stream temp response body {} to client",
outputTempFile.getAbsolutePath(),
e);
throw e;
} finally {
try {
outputTempFile.close();
} catch (Exception closeEx) {
log.warn(
"Failed to clean up temp file {} after streaming response",
outputTempFile.getAbsolutePath(),
closeEx);
}
}
};
return Response.ok(body)
.type(mediaType)
.header(HttpHeaders.CONTENT_LENGTH, len)
.header(
"Content-Disposition",
"attachment; filename=\"" + encodeAttachmentName(docName) + "\"")
.build();
} catch (IOException | RuntimeException e) {
try {
outputTempFile.close();
@@ -195,110 +171,8 @@ public class WebResponseUtils {
.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.
*/
// TODO: Migration required - extends Spring's FileSystemResource and is consumed by Spring's
// ResourceHttpMessageConverter; no JAX-RS equivalent. Kept intact pending the Response +
// StreamingOutput rework described on fileToWebResponse.
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);
}
}
}
}
// REMOVED (Spring -> JAX-RS): ManagedTempFileResource (extended Spring FileSystemResource) and
// ClosingInputStream. Their job - delete the TempFile after the response body is written - is now
// done inline by the StreamingOutput's finally block in fileToWebResponse, which is the idiomatic
// JAX-RS lifecycle and removes the dependency on Spring's ResourceHttpMessageConverter.
}
@@ -16,8 +16,8 @@ import java.util.zip.ZipInputStream;
// (PipelineProcessor, AiWorkflowService). Replacing them with InputStream/java.nio would ripple
// across callers that this single-file migration is not permitted to edit. Keeping the Spring core
// io abstraction until callers are migrated together.
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import stirling.software.common.model.io.FileSystemResource;
import stirling.software.common.model.io.Resource;
import io.github.pixee.security.ZipSecurity;
@@ -11,7 +11,7 @@ import java.util.List;
// because the abstract superclass ReplaceAndInvertColorStrategy.replace() declares this return
// type. Migrating it (e.g. to java.io.InputStream) would change the public API of the parent
// class and all sibling strategies/callers, which is out of scope for this single-file migration.
import org.springframework.core.io.InputStreamResource;
import stirling.software.common.model.io.InputStreamResource;
// TODO: Migration required - org.springframework.web.multipart.MultipartFile is retained because
// the superclass ctor ReplaceAndInvertColorStrategy(MultipartFile, ReplaceAndInvert) requires it.
// No JAX-RS drop-in exists for this constructor param without rippling changes to the parent and
@@ -23,7 +23,7 @@ import org.apache.pdfbox.text.TextPosition;
// replace() method declared in the parent ReplaceAndInvertColorStrategy (not in scope for
// this migration). Changing it here would break the @Override and ripple to all callers, so
// the Spring type is retained until the parent and its callers are migrated together.
import org.springframework.core.io.InputStreamResource;
import stirling.software.common.model.io.InputStreamResource;
// TODO: Migration required - MultipartFile is the constructor parameter type that must match
// the parent ReplaceAndInvertColorStrategy(MultipartFile, ReplaceAndInvert) constructor (not in
// scope for this migration). There is no JAX-RS drop-in for this widely used public signature;
@@ -21,7 +21,7 @@ import org.apache.pdfbox.rendering.PDFRenderer;
// drop-in here. The abstract parent ReplaceAndInvertColorStrategy.replace() returns
// InputStreamResource and editing that signature would ripple to all subclasses and callers; per
// task rules, only this file may change, so the type is kept until the parent is migrated.
import org.springframework.core.io.InputStreamResource;
import stirling.software.common.model.io.InputStreamResource;
// TODO: Migration required - org.springframework.web.multipart.MultipartFile has no JAX-RS drop-in
// for these constructor params. The parent class ReplaceAndInvertColorStrategy declares the same
// MultipartFile constructor parameter; changing the public signature here would diverge from the
@@ -6,7 +6,7 @@ import java.io.IOException;
// type of the public abstract replace() method, implemented by subclasses and consumed by
// callers. Replacing it (e.g. with InputStream/StreamingOutput) would ripple across the
// strategy subclasses and their callers, so it is kept until they are migrated together.
import org.springframework.core.io.InputStreamResource;
import stirling.software.common.model.io.InputStreamResource;
// TODO: Migration required - org.springframework.web.multipart.MultipartFile is required by
// the inherited PDFFile.setFileInput(MultipartFile) (PDFFile keeps this type for the same
// reason); changing this constructor parameter would ripple to callers and the API binding