diff --git a/app/common/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java b/app/common/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java index a154ed2a7a..e62d2f053d 100644 --- a/app/common/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java +++ b/app/common/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java @@ -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> 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(); diff --git a/app/common/src/main/java/stirling/software/SPDF/pdf/parser/TabulaTableParser.java b/app/common/src/main/java/stirling/software/SPDF/pdf/parser/TabulaTableParser.java index b85ddbb08e..ebeba73a50 100644 --- a/app/common/src/main/java/stirling/software/SPDF/pdf/parser/TabulaTableParser.java +++ b/app/common/src/main/java/stirling/software/SPDF/pdf/parser/TabulaTableParser.java @@ -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; *
  • Rotated tables (90°/270° pages) may produce incorrect bounds. * */ -@Service +@ApplicationScoped @Slf4j public class TabulaTableParser implements TableParser { diff --git a/app/common/src/main/java/stirling/software/common/annotations/AutoJobPostMapping.java b/app/common/src/main/java/stirling/software/common/annotations/AutoJobPostMapping.java index 7f2936f052..bb39927683 100644 --- a/app/common/src/main/java/stirling/software/common/annotations/AutoJobPostMapping.java +++ b/app/common/src/main/java/stirling/software/common/annotations/AutoJobPostMapping.java @@ -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. * + *

    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}. Controllers using {@code @AutoJobPostMapping} must additionally + * declare their own JAX-RS {@code @POST} + {@code @Path(value)} + {@code @Consumes(consumes)}. + * The {@link #value()}/{@link #consumes()} members are retained so a scanner/controller can read the + * intended routing. + * *

    Behaviour notes: * *

    * - *

    Unless stated otherwise an attribute only affects async execution. + *

    Unless stated otherwise an attribute only affects async 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 { * *

    Only honoured when {@code async=true}. */ + @Nonbinding long timeout() default -1; /** @@ -67,6 +66,7 @@ public @interface AutoJobPostMapping { * *

    Only honoured when {@code async=true}. */ + @Nonbinding int retryCount() default 1; /** @@ -74,6 +74,7 @@ public @interface AutoJobPostMapping { * *

    Only honoured when {@code async=true}. */ + @Nonbinding boolean trackProgress() default true; /** @@ -82,6 +83,7 @@ public @interface AutoJobPostMapping { * *

    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; } diff --git a/app/common/src/main/java/stirling/software/common/aop/AutoJobAspect.java b/app/common/src/main/java/stirling/software/common/aop/AutoJobAspect.java index 4b57b64f90..789d2beab3 100644 --- a/app/common/src/main/java/stirling/software/common/aop/AutoJobAspect.java +++ b/app/common/src/main/java/stirling/software/common/aop/AutoJobAspect.java @@ -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; diff --git a/app/common/src/main/java/stirling/software/common/configuration/YamlPropertySourceFactory.java b/app/common/src/main/java/stirling/software/common/configuration/YamlPropertySourceFactory.java deleted file mode 100644 index a8507816c6..0000000000 --- a/app/common/src/main/java/stirling/software/common/configuration/YamlPropertySourceFactory.java +++ /dev/null @@ -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); - } -} diff --git a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java index e8bad188ba..40be5cd943 100644 --- a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java +++ b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java @@ -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 diff --git a/app/common/src/main/java/stirling/software/common/model/MultipartFile.java b/app/common/src/main/java/stirling/software/common/model/MultipartFile.java index adbf8225db..8c8da1b4c2 100644 --- a/app/common/src/main/java/stirling/software/common/model/MultipartFile.java +++ b/app/common/src/main/java/stirling/software/common/model/MultipartFile.java @@ -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()); } diff --git a/app/common/src/main/java/stirling/software/common/model/multipart/ByteArrayMultipartFile.java b/app/common/src/main/java/stirling/software/common/model/multipart/ByteArrayMultipartFile.java index 2472a26482..d731773e91 100644 --- a/app/common/src/main/java/stirling/software/common/model/multipart/ByteArrayMultipartFile.java +++ b/app/common/src/main/java/stirling/software/common/model/multipart/ByteArrayMultipartFile.java @@ -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); + } } diff --git a/app/common/src/main/java/stirling/software/common/model/multipart/FileUploadMultipartFile.java b/app/common/src/main/java/stirling/software/common/model/multipart/FileUploadMultipartFile.java index 49c9509781..78043d51c3 100644 --- a/app/common/src/main/java/stirling/software/common/model/multipart/FileUploadMultipartFile.java +++ b/app/common/src/main/java/stirling/software/common/model/multipart/FileUploadMultipartFile.java @@ -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); diff --git a/app/common/src/main/java/stirling/software/common/service/InternalApiClient.java b/app/common/src/main/java/stirling/software/common/service/InternalApiClient.java index 0abfa6654b..c524860695 100644 --- a/app/common/src/main/java/stirling/software/common/service/InternalApiClient.java +++ b/app/common/src/main/java/stirling/software/common/service/InternalApiClient.java @@ -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) : ResponseEntity -// is called by, and its MultiValueMap argument / ResponseEntity result are consumed -// by, McpOperationExecutor and PolicyExecutor (app/proprietary) and asserted directly by -// InternalApiClientTest (which mocks RestTemplate.execute + ResponseExtractor). -// - ResponseEntity -> jakarta.ws.rs.core.Response, MultiValueMap -> a manual -// Map>/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. + * + *

    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} became {@code + * Map>} and {@code ResponseEntity} 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 post(String endpointPath, MultiValueMap body) { + public Response post(String endpointPath, Map> 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> 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 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> body, String boundary) { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try { + for (Map.Entry> 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"); } diff --git a/app/common/src/main/java/stirling/software/common/service/JobExecutorService.java b/app/common/src/main/java/stirling/software/common/service/JobExecutorService.java index 751c09eb73..79a7eebf27 100644 --- a/app/common/src/main/java/stirling/software/common/service/JobExecutorService.java +++ b/app/common/src/main/java/stirling/software/common/service/JobExecutorService.java @@ -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 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 result with fileId: {}", fileId); + log.debug("Stored Response result with fileId: {}", fileId); } else if (body instanceof Resource resource) { String filename = extractResponseFilename(response); String contentType = extractResponseContentType(response); diff --git a/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java b/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java index a1f2971a2d..e860d2e509 100644 --- a/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java @@ -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:

    /} and {@code + * classpath:/} (e.g. {@code *} or {@code *.woff2}). + * + *

    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. + * + *

    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 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]); } /** diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/UIDataController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/UIDataController.java index c1afe8c406..05ec90b134 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/UIDataController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/UIDataController.java @@ -221,7 +221,7 @@ public class UIDataController { private List getFontNamesFromLocation(String locationPattern) { try { Resource[] resources = - GeneralUtils.getResourcesFromLocationPattern(locationPattern, resourceLoader); + GeneralUtils.getResourcesFromLocationPattern(locationPattern); return Arrays.stream(resources) .map( resource -> {