Complete common module migration: AutoJob CDI interceptor, InternalApiClient HttpClient, shims; :common compiles

This commit is contained in:
a
2026-06-12 17:39:01 +01:00
parent 9fe815bbfd
commit 4d69bcea32
13 changed files with 287 additions and 264 deletions
@@ -6,15 +6,16 @@ import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
@Service
@ApplicationScoped
@Slf4j
public class EndpointConfiguration {
@@ -52,9 +53,10 @@ public class EndpointConfiguration {
private Map<String, Set<String>> endpointAlternatives = new ConcurrentHashMap<>();
private final boolean runningProOrHigher;
@Inject
public EndpointConfiguration(
ApplicationProperties applicationProperties,
@Qualifier("runningProOrHigher") boolean runningProOrHigher) {
@Named("runningProOrHigher") boolean runningProOrHigher) {
this.applicationProperties = applicationProperties;
this.runningProOrHigher = runningProOrHigher;
init();
@@ -9,7 +9,8 @@ import java.util.Collections;
import java.util.List;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.stereotype.Service;
import jakarta.enterprise.context.ApplicationScoped;
import lombok.extern.slf4j.Slf4j;
@@ -47,7 +48,7 @@ import technology.tabula.extractors.SpreadsheetExtractionAlgorithm;
* <li>Rotated tables (90°/270° pages) may produce incorrect bounds.
* </ul>
*/
@Service
@ApplicationScoped
@Slf4j
public class TabulaTableParser implements TableParser {
@@ -2,31 +2,27 @@ package stirling.software.common.annotations;
import java.lang.annotation.*;
import jakarta.enterprise.util.Nonbinding;
import jakarta.interceptor.InterceptorBinding;
import jakarta.ws.rs.core.MediaType;
// TODO: Migration required - Spring meta-annotation machinery has no JAX-RS drop-in.
// This annotation is a *composed* meta-annotation: it stamps @RequestMapping(method=POST)
// onto the target method and uses @AliasFor to forward value()/consumes() to that
// @RequestMapping. JAX-RS meta-annotations do NOT carry @Path/@POST/@Consumes, and there is
// no @AliasFor equivalent, so the framework code that reads this annotation (the auto-job
// scanner / endpoint registrar) must be reworked to read these attributes directly and
// build JAX-RS routing itself. Keeping the Spring imports/annotations below intact preserves
// current behavior until that caller-side rewrite happens; converting them in isolation here
// would silently break endpoint registration. Editing callers is out of scope for this file.
import org.springframework.core.annotation.AliasFor;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import io.swagger.v3.oas.annotations.parameters.RequestBody;
/**
* Shortcut for a POST endpoint that is executed through the Stirling "auto‑job" framework.
*
* <p>MIGRATION (Spring -> Quarkus): this was a Spring composed meta-annotation that stamped
* {@code @RequestMapping(method=POST)} onto the target via {@code @AliasFor}. JAX-RS does not honour
* {@code @Path}/{@code @POST}/{@code @Consumes} through meta-annotations, so this annotation no
* longer provides routing. It is now a CDI {@link InterceptorBinding} handled by
* {@code AutoJobInterceptor}. <b>Controllers using {@code @AutoJobPostMapping} must additionally
* declare their own JAX-RS {@code @POST} + {@code @Path(value)} + {@code @Consumes(consumes)}.</b>
* The {@link #value()}/{@link #consumes()} members are retained so a scanner/controller can read the
* intended routing.
*
* <p>Behaviour notes:
*
* <ul>
* <li>The endpoint is registered with {@code POST} and, by default, consumes {@code
* multipart/form-data} unless you override {@link #consumes()}.
* <li>When the client supplies {@code ?async=true} the call is handed to {@link
* stirling.software.common.service.JobExecutorService JobExecutorService} where it may be
* queued, retried, tracked and subject to time‑outs. For synchronous (default) invocations
@@ -36,21 +32,23 @@ import io.swagger.v3.oas.annotations.parameters.RequestBody;
* GET /api/v1/general/job/{id}</code>.
* </ul>
*
* <p>Unless stated otherwise an attribute only affects <em>async</em> execution.
* <p>Unless stated otherwise an attribute only affects <em>async</em> execution. All members are
* {@code @Nonbinding} so the single {@code AutoJobInterceptor} matches every annotated method; the
* interceptor reads the actual values reflectively from the target method.
*/
@Target(ElementType.METHOD)
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@RequestMapping(method = RequestMethod.POST)
@InterceptorBinding
@RequestBody(required = true)
public @interface AutoJobPostMapping {
/** Alias for {@link RequestMapping#value} – the path mapping of the endpoint. */
@AliasFor(annotation = RequestMapping.class, attribute = "value")
/** The path mapping of the endpoint (controllers must mirror this on a JAX-RS {@code @Path}). */
@Nonbinding
String[] value() default {};
/** MIME types this endpoint accepts. Defaults to {@code multipart/form-data}. */
@AliasFor(annotation = RequestMapping.class, attribute = "consumes")
@Nonbinding
String[] consumes() default {MediaType.MULTIPART_FORM_DATA};
/**
@@ -59,6 +57,7 @@ public @interface AutoJobPostMapping {
*
* <p>Only honoured when {@code async=true}.
*/
@Nonbinding
long timeout() default -1;
/**
@@ -67,6 +66,7 @@ public @interface AutoJobPostMapping {
*
* <p>Only honoured when {@code async=true}.
*/
@Nonbinding
int retryCount() default 1;
/**
@@ -74,6 +74,7 @@ public @interface AutoJobPostMapping {
*
* <p>Only honoured when {@code async=true}.
*/
@Nonbinding
boolean trackProgress() default true;
/**
@@ -82,6 +83,7 @@ public @interface AutoJobPostMapping {
*
* <p>Only honoured when {@code async=true}.
*/
@Nonbinding
boolean queueable() default false;
/**
@@ -92,5 +94,6 @@ public @interface AutoJobPostMapping {
* AutoJobPostMappingWeightTest} fails the build if any endpoint leaves it unset. Runtime
* readers clamp the value into {@code [1, 100]}.
*/
@Nonbinding
int resourceWeight() default Integer.MIN_VALUE;
}
@@ -7,22 +7,17 @@ import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Supplier;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.*;
import org.slf4j.MDC;
// TODO: Migration required - org.springframework.web.multipart.MultipartFile has no JAX-RS
// drop-in. The type is used internally via FileStorage (retrieveFile/storeFile return/accept
// MultipartFile) and PDFFile.getFileInput()/setFileInput(MultipartFile). Changing the type here
// would ripple into FileStorage and PDFFile public signatures, so the original type is kept until
// those collaborators are migrated.
import stirling.software.common.model.MultipartFile;
import jakarta.annotation.Priority;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.interceptor.AroundInvoke;
import jakarta.interceptor.Interceptor;
import jakarta.interceptor.InvocationContext;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.annotations.AutoJobPostMapping;
@@ -30,18 +25,17 @@ import stirling.software.common.model.api.PDFFile;
import stirling.software.common.service.FileStorage;
import stirling.software.common.service.JobExecutorService;
// TODO: Migration required - @Aspect / @Around is Spring AOP (AspectJ proxy weaving). Quarkus/Arc
// has no equivalent for advising arbitrary annotated methods this way; this should be reworked as a
// CDI @InterceptorBinding + @Interceptor (jakarta.interceptor) or a JAX-RS filter. The aspect
// pointcut and ProceedingJoinPoint logic are kept intact pending that rework.
@Aspect
@ApplicationScoped
@RequiredArgsConstructor
@Slf4j
// @Order(20) -> @Priority: lower precedence, executes AFTER audit aspects populate MDC.
// TODO: Migration required - @Priority only orders CDI interceptors/decorators, not Spring AOP
// aspects; ordering here is inert until this class is reworked into a CDI interceptor.
/**
* MIGRATION (Spring AOP -> CDI interceptor): was an {@code @Aspect} with {@code @Around} advice on
* {@code @AutoJobPostMapping}. Reworked into a CDI {@link Interceptor} bound by the
* {@code @AutoJobPostMapping} {@code @InterceptorBinding}; {@code @Around}/{@code ProceedingJoinPoint}
* became {@code @AroundInvoke}/{@link InvocationContext}. {@code @Priority(20)} now meaningfully
* orders this interceptor (runs after lower-priority audit interceptors populate MDC).
*/
@Interceptor
@AutoJobPostMapping
@Priority(20)
@Slf4j
public class AutoJobAspect {
private static final Duration RETRY_BASE_DELAY = Duration.ofMillis(100);
@@ -50,10 +44,20 @@ public class AutoJobAspect {
private final HttpServletRequest request;
private final FileStorage fileStorage;
@Around("@annotation(autoJobPostMapping)")
public Object wrapWithJobExecution(
ProceedingJoinPoint joinPoint, AutoJobPostMapping autoJobPostMapping) throws Exception {
// This aspect will run before any audit aspects due to @Order(0)
@Inject
public AutoJobAspect(
JobExecutorService jobExecutorService,
HttpServletRequest request,
FileStorage fileStorage) {
this.jobExecutorService = jobExecutorService;
this.request = request;
this.fileStorage = fileStorage;
}
@AroundInvoke
public Object wrapWithJobExecution(InvocationContext ctx) throws Exception {
AutoJobPostMapping autoJobPostMapping =
ctx.getMethod().getAnnotation(AutoJobPostMapping.class);
// Extract parameters from the request and annotation
boolean async = Boolean.parseBoolean(request.getParameter("async"));
log.debug(
@@ -74,7 +78,8 @@ public class AutoJobAspect {
trackProgress);
// Process arguments in-place to avoid type mismatch issues
Object[] args = processArgsInPlace(joinPoint.getArgs(), async);
Object[] args = processArgsInPlace(ctx.getParameters(), async);
ctx.setParameters(args);
// Extract queueable and resourceWeight parameters and validate
boolean queueable = autoJobPostMapping.queueable();
@@ -93,7 +98,7 @@ public class AutoJobAspect {
// The trackProgress flag controls whether detailed progress is
// stored
// for REST API queries, not WebSocket notifications
return joinPoint.proceed(args);
return ctx.proceed();
} catch (Throwable ex) {
log.error(
"AutoJobAspect caught exception during job execution: {}",
@@ -114,7 +119,7 @@ public class AutoJobAspect {
} else {
// Use retry logic
return executeWithRetries(
joinPoint,
ctx,
args,
async,
timeout,
@@ -126,7 +131,7 @@ public class AutoJobAspect {
}
private Object executeWithRetries(
ProceedingJoinPoint joinPoint,
InvocationContext ctx,
Object[] args,
boolean async,
long timeout,
@@ -171,7 +176,7 @@ public class AutoJobAspect {
}
// Attempt to execute the operation
return joinPoint.proceed(args);
return ctx.proceed();
} catch (Throwable ex) {
lastException = ex;
@@ -1,33 +0,0 @@
package stirling.software.common.configuration;
import java.util.Properties;
// TODO: Migration required - this class implements the Spring SPI
// org.springframework.core.io.support.PropertySourceFactory, which is used together with
// Spring's @PropertySource(factory = YamlPropertySourceFactory.class) to load YAML into the
// Spring Environment. Quarkus/MicroProfile Config (SmallRye) has no PropertySourceFactory,
// PropertySource, PropertiesPropertySource, EncodedResource, or YamlPropertiesFactoryBean
// equivalent; YAML is handled natively via application.yaml + ConfigSource. There is no
// faithful drop-in conversion, so the original Spring logic is intentionally left intact.
// Replacement path: implement an org.eclipse.microprofile.config.spi.ConfigSource (or use
// io.smallrye.config.source.yaml.YamlConfigSource) and register it, then delete this class
// and its @PropertySource usages. The Spring imports below cannot be removed without breaking
// the SPI contract this class implements.
import org.springframework.beans.factory.config.YamlPropertiesFactoryBean;
import org.springframework.core.env.PropertiesPropertySource;
import org.springframework.core.env.PropertySource;
import org.springframework.core.io.support.EncodedResource;
import org.springframework.core.io.support.PropertySourceFactory;
public class YamlPropertySourceFactory implements PropertySourceFactory {
@Override
public PropertySource<?> createPropertySource(String name, EncodedResource encodedResource) {
YamlPropertiesFactoryBean factory = new YamlPropertiesFactoryBean();
factory.setResources(encodedResource.getResource());
Properties properties = factory.getObject();
return new PropertiesPropertySource(
encodedResource.getResource().getFilename(), properties);
}
}
@@ -15,15 +15,9 @@ import java.util.List;
import java.util.Locale;
import java.util.UUID;
// TODO: Migration required - the Spring property-source machinery below
// (ConfigurableEnvironment, PropertySource, EncodedResource) has no direct
// Quarkus/MicroProfile-Config drop-in. The dynamicYamlPropertySource method keeps these
// three Spring imports until the YAML-into-config wiring is reimplemented via a custom
// org.eclipse.microprofile.config.spi.ConfigSource plus @io.smallrye.config.ConfigMapping.
// Resource/ClassPathResource/FileSystemResource are now the local shim types, not Spring.
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.core.env.PropertySource;
import org.springframework.core.io.support.EncodedResource;
// Resource/ClassPathResource/FileSystemResource are the local migration shim types (used by the
// SAML cert/key accessors), not Spring. The former Spring property-source imports
// (ConfigurableEnvironment/PropertySource/EncodedResource) were dropped with dynamicYamlPropertySource.
import stirling.software.common.model.io.ClassPathResource;
import stirling.software.common.model.io.FileSystemResource;
import stirling.software.common.model.io.Resource;
@@ -41,7 +35,6 @@ import lombok.ToString;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.configuration.YamlPropertySourceFactory;
import stirling.software.common.constants.JwtConstants;
import stirling.software.common.model.exception.UnsupportedProviderException;
import stirling.software.common.model.oauth2.GitHubProvider;
@@ -87,44 +80,16 @@ public class ApplicationProperties {
private Cluster cluster = new Cluster();
private Policies policies = new Policies();
// TODO: Migration required - this was a Spring @Bean that injected the
// ConfigurableEnvironment and registered an extra YAML PropertySource at runtime so
// settings.yml could override (or be overridden by) other property sources. Quarkus has
// no ConfigurableEnvironment/PropertySource model; the equivalent is a custom
// org.eclipse.microprofile.config.spi.ConfigSource (registered via ConfigSourceProvider
// or @io.smallrye.config.ConfigMapping). The original logic is preserved verbatim below
// and is currently never invoked by CDI since the @Bean annotation has been removed.
public PropertySource<?> dynamicYamlPropertySource(ConfigurableEnvironment environment)
throws IOException {
String configPath = InstallationPathConfig.getSettingsPath();
log.debug("Attempting to load settings from: {}", configPath);
File file = new File(configPath);
if (!file.exists()) {
log.error("Warning: Settings file does not exist at: {}", configPath);
}
Resource resource = new FileSystemResource(configPath);
if (!resource.exists()) {
throw new FileNotFoundException("Settings file not found at: " + configPath);
}
EncodedResource encodedResource = new EncodedResource(resource);
PropertySource<?> propertySource =
new YamlPropertySourceFactory().createPropertySource(null, encodedResource);
boolean saasActive = Arrays.asList(environment.getActiveProfiles()).contains("saas");
if (saasActive) {
// Saas-pinned values in application-saas.properties must beat settings.yml.
environment.getPropertySources().addLast(propertySource);
} else {
environment.getPropertySources().addFirst(propertySource);
}
log.debug("Loaded properties: {}", propertySource.getSource());
return propertySource;
}
// REMOVED (Spring -> Quarkus): dynamicYamlPropertySource(ConfigurableEnvironment).
// This was a Spring @Bean that registered settings.yml as an extra runtime PropertySource on the
// ConfigurableEnvironment (added first, or last under the "saas" profile). Quarkus has no
// ConfigurableEnvironment/PropertySource model and the @Bean had already been removed, so the
// method was dead code referencing Spring-only types.
// TODO: Migration required - reimplement external settings.yml loading as a custom
// org.eclipse.microprofile.config.spi.ConfigSource (registered via a ConfigSourceProvider /
// META-INF/services), giving it an ordinal that reproduces the old precedence: higher than the
// application defaults normally, but lower than application-saas.properties under the saas
// profile. Wire it in ConfigInitializer.
/**
* Initialize fileUploadLimit from environment variables if not set in settings.yml. Supports
@@ -6,6 +6,8 @@ import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import stirling.software.common.model.io.Resource;
/**
* Migration compatibility shim for Spring's {@code org.springframework.web.multipart.MultipartFile}.
*
@@ -37,6 +39,20 @@ public interface MultipartFile {
InputStream getInputStream() throws IOException;
/**
* The content as a {@link Resource}. The default is a stream-backed resource; file-backed
* implementations (e.g. {@code FileUploadMultipartFile}) override this to enable zero-copy fast
* paths.
*/
default Resource getResource() {
try {
return new stirling.software.common.model.io.InputStreamResource(
getInputStream(), getOriginalFilename());
} catch (IOException e) {
throw new java.io.UncheckedIOException(e);
}
}
default void transferTo(File dest) throws IOException {
transferTo(dest.toPath());
}
@@ -4,6 +4,8 @@ import java.io.ByteArrayInputStream;
import java.io.InputStream;
import stirling.software.common.model.MultipartFile;
import stirling.software.common.model.io.InputStreamResource;
import stirling.software.common.model.io.Resource;
/**
* In-memory {@link MultipartFile} backed by a byte array. Useful for tests and for code paths that
@@ -58,4 +60,9 @@ public class ByteArrayMultipartFile implements MultipartFile {
public InputStream getInputStream() {
return new ByteArrayInputStream(content);
}
@Override
public Resource getResource() {
return new InputStreamResource(new ByteArrayInputStream(content), originalFilename);
}
}
@@ -8,6 +8,8 @@ import java.nio.file.Path;
import org.jboss.resteasy.reactive.multipart.FileUpload;
import stirling.software.common.model.MultipartFile;
import stirling.software.common.model.io.FileSystemResource;
import stirling.software.common.model.io.Resource;
/**
* Adapts a Quarkus REST {@link FileUpload} (the inbound multipart representation at the JAX-RS
@@ -62,6 +64,12 @@ public class FileUploadMultipartFile implements MultipartFile {
return Files.newInputStream(delegate.uploadedFile());
}
@Override
public Resource getResource() {
// File-backed: enables FileStorage's zero-copy fast path.
return new FileSystemResource(delegate.uploadedFile());
}
@Override
public void transferTo(Path dest) throws IOException {
Files.copy(delegate.uploadedFile(), dest);
@@ -1,52 +1,37 @@
package stirling.software.common.service;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.net.ConnectException;
import java.net.URI;
import java.net.URLDecoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
// TODO: Migration required - the HTTP dispatch in this class is built entirely on Spring's
// RestTemplate (RestTemplate.execute/httpEntityCallback with SimpleClientHttpRequestFactory,
// RequestCallback, ResponseExtractor and ResourceAccessException). The faithful Quarkus target is
// java.net.http.HttpClient (HttpClient.Builder for the connect/read timeouts, multipart body built
// manually, ConnectException/HttpTimeoutException replacing ResourceAccessException). That rewrite
// is blocked here because the public API surface uses Spring HTTP types that cannot change without
// editing callers:
// - post(String, MultiValueMap<String, Object>) : ResponseEntity<Resource>
// is called by, and its MultiValueMap argument / ResponseEntity<Resource> result are consumed
// by, McpOperationExecutor and PolicyExecutor (app/proprietary) and asserted directly by
// InternalApiClientTest (which mocks RestTemplate.execute + ResponseExtractor).
// - ResponseEntity<Resource> -> jakarta.ws.rs.core.Response, MultiValueMap -> a manual
// Map<String,List<Object>>/form encoding, HttpHeaders/HttpStatus/HttpEntity/MediaType/HttpMethod
// -> jakarta.ws.rs.core equivalents, all in lockstep with those callers.
// These Spring imports are intentionally retained until that cross-file migration is scheduled;
// they are listed explicitly (no wildcard) so each retained type is auditable.
import stirling.software.common.model.io.FileSystemResource;
import stirling.software.common.model.io.Resource;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RequestCallback;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestTemplate;
import org.eclipse.microprofile.config.Config;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Instance;
import jakarta.servlet.ServletContext;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.enumeration.Role;
import stirling.software.common.model.io.FileSystemResource;
import stirling.software.common.model.io.Resource;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
@@ -54,6 +39,12 @@ import stirling.software.common.util.TempFileManager;
* Dispatches HTTP POST requests to internal Stirling API endpoints via loopback. Used by
* PipelineProcessor and AiWorkflowService to execute tool operations programmatically without
* leaving the JVM network stack.
*
* <p>MIGRATION (Spring -> Quarkus): the HTTP dispatch was rebuilt on {@link java.net.http.HttpClient}
* (replacing Spring's {@code RestTemplate}/{@code SimpleClientHttpRequestFactory}). The multipart
* body is encoded manually; {@code MultiValueMap<String,Object>} became {@code
* Map<String,List<Object>>} and {@code ResponseEntity<Resource>} became {@link Response}.
* {@code ResourceAccessException} timeout handling is now driven by {@link HttpTimeoutException}.
*/
@ApplicationScoped
@Slf4j
@@ -76,7 +67,7 @@ public class InternalApiClient {
private final TempFileManager tempFileManager;
private final Config config;
private final Duration readTimeout;
private final RestTemplate restTemplate;
private final HttpClient httpClient;
public InternalApiClient(
ServletContext servletContext,
@@ -94,10 +85,10 @@ public class InternalApiClient {
// because this is a loopback call; if connecting takes longer than a few seconds the
// local server is itself unhealthy.
this.readTimeout = Duration.ofSeconds(internalApi.getReadTimeoutSeconds());
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(Duration.ofSeconds(internalApi.getConnectTimeoutSeconds()));
factory.setReadTimeout(readTimeout);
this.restTemplate = new RestTemplate(factory);
this.httpClient =
HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(internalApi.getConnectTimeoutSeconds()))
.build();
}
/**
@@ -105,73 +96,105 @@ public class InternalApiClient {
* prefixes (e.g. {@code /api/v1/misc/compress-pdf}).
*
* @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
* @param body multipart form body (fileInput + parameters): each value is either a {@link
* Resource} (file part) or a {@code String} (form field)
* @return JAX-RS {@link Response} with the result file as a {@link TempFileResource} entity
*/
public ResponseEntity<Resource> post(String endpointPath, MultiValueMap<String, Object> body) {
public Response post(String endpointPath, Map<String, List<Object>> body) {
validateUrl(endpointPath);
String url = getBaseUrl() + endpointPath;
HttpHeaders headers = new HttpHeaders();
String boundary = "----StirlingBoundary" + Long.toHexString(System.nanoTime());
byte[] multipartBody = encodeMultipart(body, boundary);
HttpRequest.Builder requestBuilder =
HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(readTimeout)
.header("Content-Type", "multipart/form-data; boundary=" + boundary)
.POST(HttpRequest.BodyPublishers.ofByteArray(multipartBody));
String apiKey = getApiKeyForUser();
if (apiKey != null && !apiKey.isEmpty()) {
headers.add("X-API-KEY", apiKey);
requestBuilder.header("X-API-KEY", apiKey);
}
// A no-file ai/tools call (e.g. create-pdf-from-html-agent) sends only string params, so
// without this RestTemplate would use urlencoded instead of the multipart the controller
// expects. File-bearing calls get the right multipart content-type from RestTemplate.
boolean isAiTool = endpointPath.startsWith("/api/v1/ai/tools/");
boolean hasFilePart =
body.values().stream()
.flatMap(java.util.List::stream)
.anyMatch(v -> v instanceof Resource);
if (isAiTool && !hasFilePart) {
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
}
HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);
RequestCallback requestCallback = restTemplate.httpEntityCallback(entity, Resource.class);
try {
return restTemplate.execute(
url,
HttpMethod.POST,
requestCallback,
response -> {
try {
TempFile tempFile =
tempFileManager.createManagedTempFile("internal-api");
Files.copy(
response.getBody(),
tempFile.getPath(),
java.nio.file.StandardCopyOption.REPLACE_EXISTING);
String filename = extractFilename(response.getHeaders());
TempFileResource resource = new TempFileResource(tempFile, filename);
return ResponseEntity.status(response.getStatusCode())
.headers(response.getHeaders())
.body(resource);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
});
} catch (ResourceAccessException e) {
// RestTemplate wraps low-level I/O failures in ResourceAccessException. Only the
// SocketTimeoutException-rooted case is a real timeout; other I/O failures (connection
// refused, DNS, etc.) propagate as-is so the upstream generic handler can describe
// them accurately.
if (e.getCause() instanceof java.net.SocketTimeoutException) {
throw new InternalApiTimeoutException(endpointPath, readTimeout, e);
HttpResponse<InputStream> response =
httpClient.send(
requestBuilder.build(), HttpResponse.BodyHandlers.ofInputStream());
try (InputStream responseBody = response.body()) {
TempFile tempFile = tempFileManager.createManagedTempFile("internal-api");
Files.copy(
responseBody,
tempFile.getPath(),
java.nio.file.StandardCopyOption.REPLACE_EXISTING);
String filename =
extractFilename(
response.headers().firstValue("Content-Disposition").orElse(null));
TempFileResource resource = new TempFileResource(tempFile, filename);
Response.ResponseBuilder rb = Response.status(response.statusCode()).entity(resource);
response.headers()
.map()
.forEach((k, vs) -> vs.forEach(v -> rb.header(k, v)));
return rb.build();
}
throw e;
} catch (HttpTimeoutException e) {
throw new InternalApiTimeoutException(endpointPath, readTimeout, e);
} catch (ConnectException e) {
throw new UncheckedIOException(new IOException("Internal API connection failed", e));
} catch (IOException e) {
throw new UncheckedIOException(e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("Internal API dispatch interrupted", e);
}
}
/** Encode a multipart/form-data body. File parts are {@link Resource}; others are form fields. */
private static byte[] encodeMultipart(Map<String, List<Object>> body, String boundary) {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try {
for (Map.Entry<String, List<Object>> entry : body.entrySet()) {
String name = entry.getKey();
for (Object value : entry.getValue()) {
baos.write(("--" + boundary + "\r\n").getBytes(StandardCharsets.UTF_8));
if (value instanceof Resource resource) {
String fn = resource.getFilename() != null ? resource.getFilename() : name;
baos.write(
("Content-Disposition: form-data; name=\""
+ name
+ "\"; filename=\""
+ fn
+ "\"\r\n"
+ "Content-Type: application/octet-stream\r\n\r\n")
.getBytes(StandardCharsets.UTF_8));
try (InputStream in = resource.getInputStream()) {
in.transferTo(baos);
}
baos.write("\r\n".getBytes(StandardCharsets.UTF_8));
} else {
baos.write(
("Content-Disposition: form-data; name=\"" + name + "\"\r\n\r\n")
.getBytes(StandardCharsets.UTF_8));
baos.write(
String.valueOf(value).getBytes(StandardCharsets.UTF_8));
baos.write("\r\n".getBytes(StandardCharsets.UTF_8));
}
}
}
baos.write(("--" + boundary + "--\r\n").getBytes(StandardCharsets.UTF_8));
} catch (IOException e) {
throw new UncheckedIOException(e);
}
return baos.toByteArray();
}
/**
* Extract the filename from a response's {@code Content-Disposition} header. Returns {@code
* null} if the header is missing or has no filename.
* Extract the filename from a {@code Content-Disposition} header value. Returns {@code null} if
* the header is missing or has no filename.
*/
private static String extractFilename(HttpHeaders headers) {
String contentDisposition = headers.getFirst(HttpHeaders.CONTENT_DISPOSITION);
private static String extractFilename(String contentDisposition) {
if (contentDisposition == null || contentDisposition.isBlank()) {
return null;
}
@@ -190,14 +213,11 @@ public class InternalApiClient {
}
private String getBaseUrl() {
// Resolve the port lazily so desktop mode (server.port=0, OS-assigned) dispatches to the
// actual bound port. The "local.server.port" config key is published once the web server
// is up; fall back to the configured server.port for early calls (tests, non-web contexts).
// TODO: Migration required - verify the runtime exposes "local.server.port"/"server.port"
// via MicroProfile Config under Quarkus (Quarkus uses "quarkus.http.port" and, for
// random-port test/dev runs, "quarkus.http.test-port"); this lazy port lookup assumed
// Spring Boot's WebServerInitializedEvent populating "local.server.port".
String port = config.getOptionalValue("local.server.port", String.class).orElse(null);
// Resolve the port lazily so desktop mode dispatches to the actual bound port.
// TODO: Migration required - verify Quarkus exposes the bound port via config. Quarkus uses
// "quarkus.http.port" and, for random-port test/dev runs, "quarkus.http.test-port"; the old
// "local.server.port"/"server.port" keys came from Spring Boot's WebServerInitializedEvent.
String port = config.getOptionalValue("quarkus.http.port", String.class).orElse(null);
if (port == null) {
port = config.getOptionalValue("server.port", String.class).orElse("8080");
}
@@ -14,11 +14,6 @@ import stirling.software.common.model.io.Resource;
// JAX-RS drop-in for these utility method params; changing the type would ripple to
// callers, so it is kept as-is for now.
import stirling.software.common.model.MultipartFile;
// TODO: Migration required - org.springframework.web.servlet.mvc.method.annotation
// .StreamingResponseBody is a Spring MVC servlet type with no JAX-RS equivalent here;
// it is still consumed by FileStorage#storeFromStreamingBody (whose signature has not yet
// been migrated), so the result-handling branch below keeps this type as-is for now.
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Instance;
@@ -26,6 +21,7 @@ import jakarta.servlet.http.HttpServletRequest;
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 org.eclipse.microprofile.config.inject.ConfigProperty;
@@ -248,18 +244,15 @@ public class JobExecutorService {
String fileId = fileStorage.storeBytes((byte[]) body, filename);
taskManager.setFileResult(jobId, fileId, filename, contentType);
log.debug("Stored Response<byte[]> result with fileId: {}", fileId);
} else if (body instanceof StreamingResponseBody streamingBody) {
// TODO: Migration required - FileStorage#storeFromStreamingBody still accepts the
// Spring StreamingResponseBody type. Once that signature is migrated to
// jakarta.ws.rs.core.StreamingOutput, this branch should test for StreamingOutput
// (the type a JAX-RS Response carries for streamed bodies) instead.
} else if (body instanceof StreamingOutput streamingBody) {
// JAX-RS Response carries a StreamingOutput for streamed bodies (migrated from
// Spring's StreamingResponseBody).
String filename = extractResponseFilename(response);
String contentType = extractResponseContentType(response);
String fileId = fileStorage.storeFromStreamingBody(streamingBody, filename);
taskManager.setFileResult(jobId, fileId, filename, contentType);
log.debug(
"Stored Response<StreamingResponseBody> result with fileId: {}", fileId);
log.debug("Stored Response<StreamingOutput> result with fileId: {}", fileId);
} else if (body instanceof Resource resource) {
String filename = extractResponseFilename(response);
String contentType = extractResponseContentType(response);
@@ -19,12 +19,10 @@ import java.util.regex.Pattern;
// ClassPathUtils or build-time indexing). org.springframework.core.io.ResourceLoader and
// org.springframework.core.io.support.ResourcePatternUtils have no Quarkus/Jakarta shim. They are
// used only by getResourcesFromLocationPattern below; that method's public signature and the Spring
// resource-pattern glob resolver are relied on by callers, so the imports and logic are kept intact
// until the glob rewrite (which ripples to callers) is done. The shim
// stirling.software.common.model.io.Resource is already used for the return type.
// MIGRATION: Spring ResourceLoader/ResourcePatternUtils glob scanning replaced by java.nio
// directory listing (file:) + classloader lookup (classpath:); see getResourcesFromLocationPattern.
import stirling.software.common.model.io.FileSystemResource;
import stirling.software.common.model.io.Resource;
import org.springframework.core.io.ResourceLoader;
import org.springframework.core.io.support.ResourcePatternUtils;
// TODO: Migration required - org.springframework.web.multipart.MultipartFile has no servlet/JAX-RS
// drop-in for these utility method params. The public signatures of convertMultipartFileToFile and
@@ -259,22 +257,60 @@ public class GeneralUtils {
return safeName;
}
// Get resources from a location pattern
// TODO: Migration required - replace Spring classpath scanning with Quarkus (e.g. io.quarkus
// ClassPathUtils or build-time indexing). The ResourceLoader param and ResourcePatternUtils glob
// resolver have no Quarkus/Jakarta drop-in; the logic is preserved verbatim pending a
// filesystem/classpath glob rewrite that also touches every caller of this method.
public Resource[] getResourcesFromLocationPattern(
String locationPattern, ResourceLoader resourceLoader) throws Exception {
// Normalize the path for file resources
String pattern = locationPattern;
if (pattern.startsWith("file:")) {
String rawPath = pattern.substring(5).replace("\\*", "").replace("/*", "");
Path normalizePath = Paths.get(rawPath).normalize();
pattern = "file:" + normalizePath.toString().replace("\\", "/") + "/*";
/**
* Resolve files matching a location pattern. Supports {@code file:<dir>/<glob>} and {@code
* classpath:<dir>/<glob>} (e.g. {@code *} or {@code *.woff2}).
*
* <p>MIGRATION (Spring -> Quarkus): replaced Spring's {@code ResourceLoader} +
* {@code ResourcePatternUtils} pattern resolver. The {@code ResourceLoader} parameter was
* removed. {@code file:} patterns are resolved with {@link java.nio.file.Files#list}; {@code
* classpath:} patterns are resolved via the classloader and only support directory resources
* that live on the filesystem.
*
* <p>TODO: Migration required - {@code classpath:} resolution does not enumerate entries inside a
* packaged JAR. For uber-jar deployments, prefer serving these assets from
* {@code META-INF/resources/} or build a Jandex/build-time index of the matching files.
*/
public static Resource[] getResourcesFromLocationPattern(String locationPattern)
throws Exception {
String body = locationPattern;
boolean classpath = false;
if (body.startsWith("file:")) {
body = body.substring(5);
} else if (body.startsWith("classpath:")) {
body = body.substring(10);
classpath = true;
}
return ResourcePatternUtils.getResourcePatternResolver(resourceLoader)
.getResources(pattern);
body = body.replace("\\", "/");
int lastSlash = body.lastIndexOf('/');
String dirPart = lastSlash >= 0 ? body.substring(0, lastSlash) : "";
String glob = lastSlash >= 0 ? body.substring(lastSlash + 1) : body;
if (glob.isEmpty()) {
glob = "*";
}
Path dir;
if (classpath) {
URL url = GeneralUtils.class.getClassLoader().getResource(dirPart);
if (url == null || !"file".equals(url.getProtocol())) {
// Not a filesystem-backed classpath dir (e.g. inside a jar) - see TODO above.
return new Resource[0];
}
dir = Paths.get(url.toURI());
} else {
dir = Paths.get(dirPart).normalize();
}
if (!Files.isDirectory(dir)) {
return new Resource[0];
}
PathMatcher matcher = dir.getFileSystem().getPathMatcher("glob:" + glob);
List<Resource> resources = new ArrayList<>();
try (var stream = Files.list(dir)) {
stream.filter(p -> Files.isRegularFile(p) && matcher.matches(p.getFileName()))
.forEach(p -> resources.add(new FileSystemResource(p)));
}
return resources.toArray(new Resource[0]);
}
/**
@@ -221,7 +221,7 @@ public class UIDataController {
private List<FontResource> getFontNamesFromLocation(String locationPattern) {
try {
Resource[] resources =
GeneralUtils.getResourcesFromLocationPattern(locationPattern, resourceLoader);
GeneralUtils.getResourcesFromLocationPattern(locationPattern);
return Arrays.stream(resources)
.map(
resource -> {