mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7874d18f32 | ||
|
|
1e968c79f6 | ||
|
|
e2b88cf8b4 | ||
|
|
da49b50d27 | ||
|
|
970679640d | ||
|
|
18f571291b | ||
|
|
0abcc0ccb8 | ||
|
|
a99c104053 | ||
|
|
95fbd4647d | ||
|
|
8d4f5f16cb | ||
|
|
2ee43b2070 | ||
|
|
02aad40aa0 | ||
|
|
0328333d81 |
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(mkdir:*)",
|
||||
"Bash(./gradlew:*)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(find:*)",
|
||||
"Bash(chmod:*)",
|
||||
"Bash(rm:*)",
|
||||
"Bash(cat:*)",
|
||||
"Bash(if [ -d logs ])",
|
||||
"Bash(then ls -la logs/)",
|
||||
"Bash(fi)",
|
||||
"Bash(ls:*)",
|
||||
"Bash(./testing/cucumber/features/environment.py -v)",
|
||||
"Bash(./testing/cucumber/features/autojob.feature -v)"
|
||||
],
|
||||
"deny": []
|
||||
}
|
||||
}
|
||||
@@ -56,9 +56,9 @@ jobs:
|
||||
build/reports/tests/
|
||||
build/test-results/
|
||||
build/reports/problems/
|
||||
/common/build/reports/tests/
|
||||
/common/build/test-results/
|
||||
/common/build/reports/problems/
|
||||
common/build/reports/tests/
|
||||
common/build/test-results/
|
||||
common/build/reports/problems/
|
||||
retention-days: 3
|
||||
|
||||
check-licence:
|
||||
|
||||
@@ -32,6 +32,9 @@ dependencyManagement {
|
||||
dependencies {
|
||||
implementation 'org.springframework.boot:spring-boot-starter-web'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-aop'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-websocket'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-test'
|
||||
implementation 'com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20240325.1'
|
||||
implementation 'com.fathzer:javaluator:3.0.6'
|
||||
implementation 'com.posthog.java:posthog:1.2.0'
|
||||
@@ -48,5 +51,14 @@ dependencies {
|
||||
annotationProcessor "org.projectlombok:lombok:$lombokVersion"
|
||||
|
||||
testImplementation "org.springframework.boot:spring-boot-starter-test"
|
||||
testImplementation 'org.mockito:mockito-core:5.18.0'
|
||||
testImplementation 'org.mockito:mockito-junit-jupiter:5.18.0'
|
||||
testRuntimeOnly 'org.mockito:mockito-inline:5.2.0'
|
||||
}
|
||||
|
||||
test {
|
||||
useJUnitPlatform()
|
||||
testLogging {
|
||||
events "passed", "skipped", "failed"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package stirling.software.common.annotations;
|
||||
|
||||
import java.lang.annotation.*;
|
||||
|
||||
import org.springframework.core.annotation.AliasFor;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMethod;
|
||||
|
||||
@Target(ElementType.METHOD)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Documented
|
||||
@RequestMapping(method = RequestMethod.POST)
|
||||
public @interface AutoJobPostMapping {
|
||||
@AliasFor(annotation = RequestMapping.class, attribute = "value")
|
||||
String[] value() default {};
|
||||
|
||||
@AliasFor(annotation = RequestMapping.class, attribute = "consumes")
|
||||
String[] consumes() default {"multipart/form-data"};
|
||||
|
||||
/**
|
||||
* Custom timeout in milliseconds for this specific job. If not specified, the default system
|
||||
* timeout will be used.
|
||||
*/
|
||||
long timeout() default -1;
|
||||
|
||||
/** Maximum number of times to retry the job on failure. Default is 1 (no retries). */
|
||||
int retryCount() default 1;
|
||||
|
||||
/**
|
||||
* Whether to track and report progress for this job. If enabled, the job will send progress
|
||||
* updates through WebSocket.
|
||||
*/
|
||||
boolean trackProgress() default true;
|
||||
|
||||
/**
|
||||
* Whether this job can be queued when system resources are limited. If enabled, jobs will be
|
||||
* queued instead of rejected when the system is under high load. The queue size is dynamically
|
||||
* adjusted based on available memory and CPU resources.
|
||||
*/
|
||||
boolean queueable() default false;
|
||||
|
||||
/**
|
||||
* Optional resource weight of this job (1-100). Higher values indicate more resource-intensive
|
||||
* jobs that may need stricter queuing. Default is 50 (medium weight).
|
||||
*/
|
||||
int resourceWeight() default 50;
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package stirling.software.common.aop;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import org.aspectj.lang.ProceedingJoinPoint;
|
||||
import org.aspectj.lang.annotation.*;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.controller.WebSocketProgressController;
|
||||
import stirling.software.common.model.api.PDFFile;
|
||||
import stirling.software.common.model.job.JobProgress;
|
||||
import stirling.software.common.service.FileOrUploadService;
|
||||
import stirling.software.common.service.FileStorage;
|
||||
import stirling.software.common.service.JobExecutorService;
|
||||
|
||||
@Aspect
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class AutoJobAspect {
|
||||
|
||||
private final JobExecutorService jobExecutorService;
|
||||
private final HttpServletRequest request;
|
||||
private final FileOrUploadService fileOrUploadService;
|
||||
private final FileStorage fileStorage;
|
||||
private final WebSocketProgressController webSocketSender;
|
||||
|
||||
@Around("@annotation(autoJobPostMapping)")
|
||||
public Object wrapWithJobExecution(
|
||||
ProceedingJoinPoint joinPoint, AutoJobPostMapping autoJobPostMapping) {
|
||||
// Extract parameters from the request and annotation
|
||||
boolean async = Boolean.parseBoolean(request.getParameter("async"));
|
||||
long timeout = autoJobPostMapping.timeout();
|
||||
int retryCount = autoJobPostMapping.retryCount();
|
||||
boolean trackProgress = autoJobPostMapping.trackProgress();
|
||||
|
||||
log.debug(
|
||||
"AutoJobPostMapping execution with async={}, timeout={}, retryCount={}, trackProgress={}",
|
||||
async,
|
||||
timeout > 0 ? timeout : "default",
|
||||
retryCount,
|
||||
trackProgress);
|
||||
|
||||
// Inspect and possibly mutate arguments
|
||||
Object[] args = joinPoint.getArgs();
|
||||
|
||||
for (int i = 0; i < args.length; i++) {
|
||||
Object arg = args[i];
|
||||
|
||||
if (arg instanceof PDFFile pdfFile) {
|
||||
// Case 1: fileId is provided but no fileInput
|
||||
if (pdfFile.getFileInput() == null && pdfFile.getFileId() != null) {
|
||||
try {
|
||||
log.debug("Using fileId {} to get file content", pdfFile.getFileId());
|
||||
MultipartFile file = fileStorage.retrieveFile(pdfFile.getFileId());
|
||||
pdfFile.setFileInput(file);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(
|
||||
"Failed to resolve file by ID: " + pdfFile.getFileId(), e);
|
||||
}
|
||||
}
|
||||
// Case 2: For async requests, we need to make a copy of the MultipartFile
|
||||
else if (async && pdfFile.getFileInput() != null) {
|
||||
try {
|
||||
log.debug("Making persistent copy of uploaded file for async processing");
|
||||
MultipartFile originalFile = pdfFile.getFileInput();
|
||||
String fileId = fileStorage.storeFile(originalFile);
|
||||
|
||||
// Store the fileId for later reference
|
||||
pdfFile.setFileId(fileId);
|
||||
|
||||
// Replace the original MultipartFile with our persistent copy
|
||||
MultipartFile persistentFile = fileStorage.retrieveFile(fileId);
|
||||
pdfFile.setFileInput(persistentFile);
|
||||
|
||||
log.debug("Created persistent file copy with fileId: {}", fileId);
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(
|
||||
"Failed to create persistent copy of uploaded file", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract queueable and resourceWeight parameters
|
||||
boolean queueable = autoJobPostMapping.queueable();
|
||||
int resourceWeight = autoJobPostMapping.resourceWeight();
|
||||
|
||||
// Integrate with the enhanced JobExecutorService
|
||||
if (retryCount <= 1) {
|
||||
// No retries needed, simple execution
|
||||
return jobExecutorService.runJobGeneric(
|
||||
async,
|
||||
() -> {
|
||||
try {
|
||||
if (trackProgress && async) {
|
||||
String jobId = (String) request.getAttribute("jobId");
|
||||
if (jobId != null) {
|
||||
webSocketSender.sendProgress(
|
||||
jobId,
|
||||
new JobProgress(
|
||||
jobId,
|
||||
"Processing",
|
||||
50,
|
||||
"Executing operation"));
|
||||
}
|
||||
}
|
||||
return joinPoint.proceed(args);
|
||||
} catch (Throwable ex) {
|
||||
log.error(
|
||||
"AutoJobAspect caught exception during job execution: {}",
|
||||
ex.getMessage(),
|
||||
ex);
|
||||
throw new RuntimeException(ex);
|
||||
}
|
||||
},
|
||||
timeout,
|
||||
queueable,
|
||||
resourceWeight);
|
||||
} else {
|
||||
// Use retry logic
|
||||
return executeWithRetries(
|
||||
joinPoint,
|
||||
args,
|
||||
async,
|
||||
timeout,
|
||||
retryCount,
|
||||
trackProgress,
|
||||
queueable,
|
||||
resourceWeight);
|
||||
}
|
||||
}
|
||||
|
||||
private Object executeWithRetries(
|
||||
ProceedingJoinPoint joinPoint,
|
||||
Object[] args,
|
||||
boolean async,
|
||||
long timeout,
|
||||
int maxRetries,
|
||||
boolean trackProgress,
|
||||
boolean queueable,
|
||||
int resourceWeight) {
|
||||
|
||||
AtomicInteger attempts = new AtomicInteger(0);
|
||||
// Use AtomicReference to make the jobId effectively final for lambda usage
|
||||
AtomicReference<String> jobIdRef = new AtomicReference<>();
|
||||
|
||||
return jobExecutorService.runJobGeneric(
|
||||
async,
|
||||
() -> {
|
||||
int currentAttempt = attempts.incrementAndGet();
|
||||
try {
|
||||
if (trackProgress && async) {
|
||||
// Only get the jobId once and store it in the AtomicReference
|
||||
if (jobIdRef.get() == null) {
|
||||
jobIdRef.set(getJobIdFromContext());
|
||||
}
|
||||
String jobId = jobIdRef.get();
|
||||
if (jobId != null) {
|
||||
webSocketSender.sendProgress(
|
||||
jobId,
|
||||
new JobProgress(
|
||||
jobId,
|
||||
"Started",
|
||||
0,
|
||||
"Executing (attempt "
|
||||
+ currentAttempt
|
||||
+ " of "
|
||||
+ Math.max(1, maxRetries)
|
||||
+ ")"));
|
||||
}
|
||||
}
|
||||
|
||||
return joinPoint.proceed(args);
|
||||
} catch (Throwable ex) {
|
||||
log.error(
|
||||
"AutoJobAspect caught exception during job execution (attempt {}/{}): {}",
|
||||
currentAttempt,
|
||||
Math.max(1, maxRetries),
|
||||
ex.getMessage(),
|
||||
ex);
|
||||
|
||||
// Check if we should retry
|
||||
if (currentAttempt < maxRetries) {
|
||||
log.info(
|
||||
"Retrying operation, attempt {}/{}",
|
||||
currentAttempt + 1,
|
||||
maxRetries);
|
||||
String jobId = jobIdRef.get();
|
||||
if (trackProgress && async && jobId != null) {
|
||||
webSocketSender.sendProgress(
|
||||
jobId,
|
||||
new JobProgress(
|
||||
jobId,
|
||||
"Retrying",
|
||||
(int) (currentAttempt * 100.0 / maxRetries),
|
||||
"Retry attempt "
|
||||
+ (currentAttempt + 1)
|
||||
+ " of "
|
||||
+ maxRetries));
|
||||
}
|
||||
|
||||
try {
|
||||
// Simple exponential backoff
|
||||
Thread.sleep(100 * currentAttempt);
|
||||
} catch (InterruptedException ie) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
|
||||
// Recursive call to retry
|
||||
return executeWithRetries(
|
||||
joinPoint,
|
||||
args,
|
||||
async,
|
||||
timeout,
|
||||
maxRetries,
|
||||
trackProgress,
|
||||
queueable,
|
||||
resourceWeight);
|
||||
}
|
||||
|
||||
// No more retries, throw the exception
|
||||
throw new RuntimeException("Job failed: " + ex.getMessage(), ex);
|
||||
}
|
||||
},
|
||||
timeout,
|
||||
queueable,
|
||||
resourceWeight);
|
||||
}
|
||||
|
||||
// Try to get the job ID from the context (if this is an async job)
|
||||
private String getJobIdFromContext() {
|
||||
try {
|
||||
return (String) request.getAttribute("jobId");
|
||||
} catch (Exception e) {
|
||||
log.debug("Could not retrieve job ID from context: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package stirling.software.common.config;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
|
||||
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
|
||||
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
|
||||
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSocketMessageBroker
|
||||
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
|
||||
|
||||
@Override
|
||||
public void configureMessageBroker(MessageBrokerRegistry config) {
|
||||
config.enableSimpleBroker("/topic");
|
||||
config.setApplicationDestinationPrefixes("/app");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerStompEndpoints(StompEndpointRegistry registry) {
|
||||
registry.addEndpoint("/ws").setAllowedOriginPatterns("*").withSockJS();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package stirling.software.common.controller;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.job.JobResult;
|
||||
import stirling.software.common.model.job.JobStats;
|
||||
import stirling.software.common.service.FileStorage;
|
||||
import stirling.software.common.service.TaskManager;
|
||||
|
||||
/** REST controller for job-related endpoints */
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class JobController {
|
||||
|
||||
private final TaskManager taskManager;
|
||||
private final FileStorage fileStorage;
|
||||
|
||||
/**
|
||||
* Get the status of a job
|
||||
*
|
||||
* @param jobId The job ID
|
||||
* @return The job result
|
||||
*/
|
||||
@GetMapping("/api/v1/general/job/{jobId}")
|
||||
public ResponseEntity<?> getJobStatus(@PathVariable("jobId") String jobId) {
|
||||
JobResult result = taskManager.getJobResult(jobId);
|
||||
if (result == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the result of a job
|
||||
*
|
||||
* @param jobId The job ID
|
||||
* @return The job result
|
||||
*/
|
||||
@GetMapping("/api/v1/general/job/{jobId}/result")
|
||||
public ResponseEntity<?> getJobResult(@PathVariable("jobId") String jobId) {
|
||||
JobResult result = taskManager.getJobResult(jobId);
|
||||
if (result == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
if (!result.isComplete()) {
|
||||
return ResponseEntity.badRequest().body("Job is not complete yet");
|
||||
}
|
||||
|
||||
if (result.getError() != null) {
|
||||
return ResponseEntity.badRequest().body("Job failed: " + result.getError());
|
||||
}
|
||||
|
||||
if (result.getFileId() != null) {
|
||||
try {
|
||||
byte[] fileContent = fileStorage.retrieveBytes(result.getFileId());
|
||||
return ResponseEntity.ok()
|
||||
.header("Content-Type", result.getContentType())
|
||||
.header(
|
||||
"Content-Disposition",
|
||||
"form-data; name=\"attachment\"; filename=\""
|
||||
+ result.getOriginalFileName()
|
||||
+ "\"")
|
||||
.body(fileContent);
|
||||
} catch (Exception e) {
|
||||
log.error("Error retrieving file for job {}: {}", jobId, e.getMessage(), e);
|
||||
return ResponseEntity.internalServerError()
|
||||
.body("Error retrieving file: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(result.getResult());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get statistics about jobs in the system
|
||||
*
|
||||
* @return Job statistics
|
||||
*/
|
||||
@GetMapping("/api/v1/general/job/stats")
|
||||
public ResponseEntity<JobStats> getJobStats() {
|
||||
JobStats stats = taskManager.getJobStats();
|
||||
return ResponseEntity.ok(stats);
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually trigger cleanup of old jobs
|
||||
*
|
||||
* @return A response indicating how many jobs were cleaned up
|
||||
*/
|
||||
@PostMapping("/api/v1/general/job/cleanup")
|
||||
public ResponseEntity<?> cleanupOldJobs() {
|
||||
int beforeCount = taskManager.getJobStats().getTotalJobs();
|
||||
taskManager.cleanupOldJobs();
|
||||
int afterCount = taskManager.getJobStats().getTotalJobs();
|
||||
int removedCount = beforeCount - afterCount;
|
||||
|
||||
return ResponseEntity.ok(
|
||||
Map.of(
|
||||
"message", "Cleanup complete",
|
||||
"removedJobs", removedCount,
|
||||
"remainingJobs", afterCount));
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package stirling.software.common.controller;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||
import org.springframework.stereotype.Controller;
|
||||
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import stirling.software.common.model.job.JobProgress;
|
||||
|
||||
@Controller
|
||||
@NoArgsConstructor
|
||||
public class WebSocketProgressController {
|
||||
|
||||
private SimpMessagingTemplate messagingTemplate;
|
||||
|
||||
@Autowired(required = false)
|
||||
public void setMessagingTemplate(SimpMessagingTemplate messagingTemplate) {
|
||||
this.messagingTemplate = messagingTemplate;
|
||||
}
|
||||
|
||||
public void sendProgress(String jobId, JobProgress progress) {
|
||||
if (messagingTemplate != null) {
|
||||
messagingTemplate.convertAndSend("/topic/progress/" + jobId, progress);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,12 @@ import lombok.NoArgsConstructor;
|
||||
public class PDFFile {
|
||||
@Schema(
|
||||
description = "The input PDF file",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED,
|
||||
contentMediaType = "application/pdf",
|
||||
format = "binary")
|
||||
private MultipartFile fileInput;
|
||||
|
||||
@Schema(
|
||||
description = "File ID for server-side files (can be used instead of fileInput)",
|
||||
example = "a1b2c3d4-5678-90ab-cdef-ghijklmnopqr")
|
||||
private String fileId;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package stirling.software.common.model.job;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class JobProgress {
|
||||
private String jobId;
|
||||
private String status;
|
||||
private int percentComplete;
|
||||
private String message;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package stirling.software.common.model.job;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class JobResponse<T> {
|
||||
private boolean async;
|
||||
private String jobId;
|
||||
private T result;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package stirling.software.common.model.job;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/** Represents the result of a job execution. Used by the TaskManager to store job results. */
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class JobResult {
|
||||
|
||||
/** The job ID */
|
||||
private String jobId;
|
||||
|
||||
/** Flag indicating if the job is complete */
|
||||
private boolean complete;
|
||||
|
||||
/** Error message if the job failed */
|
||||
private String error;
|
||||
|
||||
/** The file ID of the result file, if applicable */
|
||||
private String fileId;
|
||||
|
||||
/** Original file name, if applicable */
|
||||
private String originalFileName;
|
||||
|
||||
/** MIME type of the result, if applicable */
|
||||
private String contentType;
|
||||
|
||||
/** Time when the job was created */
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/** Time when the job was completed */
|
||||
private LocalDateTime completedAt;
|
||||
|
||||
/** The actual result object, if not a file */
|
||||
private Object result;
|
||||
|
||||
/**
|
||||
* Create a new JobResult with the given job ID
|
||||
*
|
||||
* @param jobId The job ID
|
||||
* @return A new JobResult
|
||||
*/
|
||||
public static JobResult createNew(String jobId) {
|
||||
return JobResult.builder()
|
||||
.jobId(jobId)
|
||||
.complete(false)
|
||||
.createdAt(LocalDateTime.now())
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark this job as complete with a file result
|
||||
*
|
||||
* @param fileId The file ID of the result
|
||||
* @param originalFileName The original file name
|
||||
* @param contentType The content type of the file
|
||||
*/
|
||||
public void completeWithFile(String fileId, String originalFileName, String contentType) {
|
||||
this.complete = true;
|
||||
this.fileId = fileId;
|
||||
this.originalFileName = originalFileName;
|
||||
this.contentType = contentType;
|
||||
this.completedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark this job as complete with a general result
|
||||
*
|
||||
* @param result The result object
|
||||
*/
|
||||
public void completeWithResult(Object result) {
|
||||
this.complete = true;
|
||||
this.result = result;
|
||||
this.completedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark this job as failed with an error message
|
||||
*
|
||||
* @param error The error message
|
||||
*/
|
||||
public void failWithError(String error) {
|
||||
this.complete = true;
|
||||
this.error = error;
|
||||
this.completedAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package stirling.software.common.model.job;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/** Represents statistics about jobs in the system */
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class JobStats {
|
||||
|
||||
/** Total number of jobs (active and completed) */
|
||||
private int totalJobs;
|
||||
|
||||
/** Number of active (incomplete) jobs */
|
||||
private int activeJobs;
|
||||
|
||||
/** Number of completed jobs */
|
||||
private int completedJobs;
|
||||
|
||||
/** Number of failed jobs */
|
||||
private int failedJobs;
|
||||
|
||||
/** Number of successful jobs */
|
||||
private int successfulJobs;
|
||||
|
||||
/** Number of jobs with file results */
|
||||
private int fileResultJobs;
|
||||
|
||||
/** The oldest active job's creation timestamp */
|
||||
private LocalDateTime oldestActiveJobTime;
|
||||
|
||||
/** The newest active job's creation timestamp */
|
||||
private LocalDateTime newestActiveJobTime;
|
||||
|
||||
/** The average processing time for completed jobs in milliseconds */
|
||||
private long averageProcessingTimeMs;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.*;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class FileOrUploadService {
|
||||
|
||||
@Value("${stirling.tempDir:/tmp/stirling-files}")
|
||||
private String tempDirPath;
|
||||
|
||||
public Path resolveFilePath(String fileId) {
|
||||
return Path.of(tempDirPath).resolve(fileId);
|
||||
}
|
||||
|
||||
public MultipartFile toMockMultipartFile(String name, byte[] data) throws IOException {
|
||||
return new CustomMultipartFile(name, data);
|
||||
}
|
||||
|
||||
// Custom implementation of MultipartFile
|
||||
private static class CustomMultipartFile implements MultipartFile {
|
||||
private final String name;
|
||||
private final byte[] content;
|
||||
|
||||
public CustomMultipartFile(String name, byte[] content) {
|
||||
this.name = name;
|
||||
this.content = content;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getOriginalFilename() {
|
||||
return name;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getContentType() {
|
||||
return "application/pdf";
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isEmpty() {
|
||||
return content == null || content.length == 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getSize() {
|
||||
return content.length;
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] getBytes() throws IOException {
|
||||
return content;
|
||||
}
|
||||
|
||||
@Override
|
||||
public java.io.InputStream getInputStream() throws IOException {
|
||||
return new ByteArrayInputStream(content);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void transferTo(java.io.File dest) throws IOException, IllegalStateException {
|
||||
Files.write(dest.toPath(), content);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Service for storing and retrieving files with unique file IDs. Used by the AutoJobPostMapping
|
||||
* system to handle file references.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class FileStorage {
|
||||
|
||||
@Value("${stirling.tempDir:/tmp/stirling-files}")
|
||||
private String tempDirPath;
|
||||
|
||||
private final FileOrUploadService fileOrUploadService;
|
||||
|
||||
/**
|
||||
* Store a file and return its unique ID
|
||||
*
|
||||
* @param file The file to store
|
||||
* @return The unique ID assigned to the file
|
||||
* @throws IOException If there is an error storing the file
|
||||
*/
|
||||
public String storeFile(MultipartFile file) throws IOException {
|
||||
String fileId = generateFileId();
|
||||
Path filePath = getFilePath(fileId);
|
||||
|
||||
// Ensure the directory exists
|
||||
Files.createDirectories(filePath.getParent());
|
||||
|
||||
// Transfer the file to the storage location
|
||||
file.transferTo(filePath.toFile());
|
||||
|
||||
log.debug("Stored file with ID: {}", fileId);
|
||||
return fileId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a byte array as a file and return its unique ID
|
||||
*
|
||||
* @param bytes The byte array to store
|
||||
* @param originalName The original name of the file (for extension)
|
||||
* @return The unique ID assigned to the file
|
||||
* @throws IOException If there is an error storing the file
|
||||
*/
|
||||
public String storeBytes(byte[] bytes, String originalName) throws IOException {
|
||||
String fileId = generateFileId();
|
||||
Path filePath = getFilePath(fileId);
|
||||
|
||||
// Ensure the directory exists
|
||||
Files.createDirectories(filePath.getParent());
|
||||
|
||||
// Write the bytes to the file
|
||||
Files.write(filePath, bytes);
|
||||
|
||||
log.debug("Stored byte array with ID: {}", fileId);
|
||||
return fileId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a file by its ID as a MultipartFile
|
||||
*
|
||||
* @param fileId The ID of the file to retrieve
|
||||
* @return The file as a MultipartFile
|
||||
* @throws IOException If the file doesn't exist or can't be read
|
||||
*/
|
||||
public MultipartFile retrieveFile(String fileId) throws IOException {
|
||||
Path filePath = getFilePath(fileId);
|
||||
|
||||
if (!Files.exists(filePath)) {
|
||||
throw new IOException("File not found with ID: " + fileId);
|
||||
}
|
||||
|
||||
byte[] fileData = Files.readAllBytes(filePath);
|
||||
return fileOrUploadService.toMockMultipartFile(fileId, fileData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a file by its ID as a byte array
|
||||
*
|
||||
* @param fileId The ID of the file to retrieve
|
||||
* @return The file as a byte array
|
||||
* @throws IOException If the file doesn't exist or can't be read
|
||||
*/
|
||||
public byte[] retrieveBytes(String fileId) throws IOException {
|
||||
Path filePath = getFilePath(fileId);
|
||||
|
||||
if (!Files.exists(filePath)) {
|
||||
throw new IOException("File not found with ID: " + fileId);
|
||||
}
|
||||
|
||||
return Files.readAllBytes(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a file by its ID
|
||||
*
|
||||
* @param fileId The ID of the file to delete
|
||||
* @return true if the file was deleted, false otherwise
|
||||
*/
|
||||
public boolean deleteFile(String fileId) {
|
||||
try {
|
||||
Path filePath = getFilePath(fileId);
|
||||
return Files.deleteIfExists(filePath);
|
||||
} catch (IOException e) {
|
||||
log.error("Error deleting file with ID: {}", fileId, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file exists by its ID
|
||||
*
|
||||
* @param fileId The ID of the file to check
|
||||
* @return true if the file exists, false otherwise
|
||||
*/
|
||||
public boolean fileExists(String fileId) {
|
||||
Path filePath = getFilePath(fileId);
|
||||
return Files.exists(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path for a file ID
|
||||
*
|
||||
* @param fileId The ID of the file
|
||||
* @return The path to the file
|
||||
*/
|
||||
private Path getFilePath(String fileId) {
|
||||
return Path.of(tempDirPath).resolve(fileId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique file ID
|
||||
*
|
||||
* @return A unique file ID
|
||||
*/
|
||||
private String generateFileId() {
|
||||
return UUID.randomUUID().toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.controller.WebSocketProgressController;
|
||||
import stirling.software.common.model.job.JobProgress;
|
||||
import stirling.software.common.model.job.JobResponse;
|
||||
import stirling.software.common.util.ExecutorFactory;
|
||||
|
||||
/** Service for executing jobs asynchronously or synchronously */
|
||||
@Service
|
||||
@Slf4j
|
||||
public class JobExecutorService {
|
||||
|
||||
private final TaskManager taskManager;
|
||||
private final WebSocketProgressController webSocketSender;
|
||||
private final FileStorage fileStorage;
|
||||
private final HttpServletRequest request;
|
||||
private final ResourceMonitor resourceMonitor;
|
||||
private final JobQueue jobQueue;
|
||||
private final ExecutorService executor = ExecutorFactory.newVirtualOrCachedThreadExecutor();
|
||||
private final long effectiveTimeoutMs;
|
||||
|
||||
public JobExecutorService(
|
||||
TaskManager taskManager,
|
||||
WebSocketProgressController webSocketSender,
|
||||
FileStorage fileStorage,
|
||||
HttpServletRequest request,
|
||||
ResourceMonitor resourceMonitor,
|
||||
JobQueue jobQueue,
|
||||
@Value("${spring.mvc.async.request-timeout:1200000}") long asyncRequestTimeoutMs,
|
||||
@Value("${server.servlet.session.timeout:30m}") String sessionTimeout) {
|
||||
this.taskManager = taskManager;
|
||||
this.webSocketSender = webSocketSender;
|
||||
this.fileStorage = fileStorage;
|
||||
this.request = request;
|
||||
this.resourceMonitor = resourceMonitor;
|
||||
this.jobQueue = jobQueue;
|
||||
|
||||
// Parse session timeout and calculate effective timeout once during initialization
|
||||
long sessionTimeoutMs = parseSessionTimeout(sessionTimeout);
|
||||
this.effectiveTimeoutMs = Math.min(asyncRequestTimeoutMs, sessionTimeoutMs);
|
||||
log.debug(
|
||||
"Job executor configured with effective timeout of {} ms", this.effectiveTimeoutMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a job either asynchronously or synchronously
|
||||
*
|
||||
* @param async Whether to run the job asynchronously
|
||||
* @param work The work to be done
|
||||
* @return The response
|
||||
*/
|
||||
public ResponseEntity<?> runJobGeneric(boolean async, Supplier<Object> work) {
|
||||
return runJobGeneric(async, work, -1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a job either asynchronously or synchronously with a custom timeout
|
||||
*
|
||||
* @param async Whether to run the job asynchronously
|
||||
* @param work The work to be done
|
||||
* @param customTimeoutMs Custom timeout in milliseconds, or -1 to use the default
|
||||
* @return The response
|
||||
*/
|
||||
public ResponseEntity<?> runJobGeneric(
|
||||
boolean async, Supplier<Object> work, long customTimeoutMs) {
|
||||
return runJobGeneric(async, work, customTimeoutMs, false, 50);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a job either asynchronously or synchronously with custom parameters
|
||||
*
|
||||
* @param async Whether to run the job asynchronously
|
||||
* @param work The work to be done
|
||||
* @param customTimeoutMs Custom timeout in milliseconds, or -1 to use the default
|
||||
* @param queueable Whether this job can be queued when system resources are limited
|
||||
* @param resourceWeight The resource weight of this job (1-100)
|
||||
* @return The response
|
||||
*/
|
||||
public ResponseEntity<?> runJobGeneric(
|
||||
boolean async,
|
||||
Supplier<Object> work,
|
||||
long customTimeoutMs,
|
||||
boolean queueable,
|
||||
int resourceWeight) {
|
||||
String jobId = UUID.randomUUID().toString();
|
||||
|
||||
// Store the job ID in the request for potential use by other components
|
||||
if (request != null) {
|
||||
request.setAttribute("jobId", jobId);
|
||||
}
|
||||
|
||||
// Determine which timeout to use
|
||||
long timeoutToUse = customTimeoutMs > 0 ? customTimeoutMs : effectiveTimeoutMs;
|
||||
|
||||
log.debug(
|
||||
"Running job with ID: {}, async: {}, timeout: {}ms, queueable: {}, weight: {}",
|
||||
jobId,
|
||||
async,
|
||||
timeoutToUse,
|
||||
queueable,
|
||||
resourceWeight);
|
||||
|
||||
// Check if we need to queue this job based on resource availability
|
||||
boolean shouldQueue =
|
||||
queueable
|
||||
&& async
|
||||
&& // Only async jobs can be queued
|
||||
resourceMonitor.shouldQueueJob(resourceWeight);
|
||||
|
||||
if (shouldQueue) {
|
||||
// Queue the job instead of executing immediately
|
||||
log.debug(
|
||||
"Queueing job {} due to resource constraints (weight: {})",
|
||||
jobId,
|
||||
resourceWeight);
|
||||
|
||||
taskManager.createTask(jobId);
|
||||
|
||||
// Create a specialized wrapper that updates the TaskManager
|
||||
Supplier<Object> wrappedWork =
|
||||
() -> {
|
||||
try {
|
||||
Object result = work.get();
|
||||
processJobResult(jobId, result);
|
||||
return result;
|
||||
} catch (Exception e) {
|
||||
log.error(
|
||||
"Error executing queued job {}: {}", jobId, e.getMessage(), e);
|
||||
taskManager.setError(jobId, e.getMessage());
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
// Queue the job and get the future
|
||||
CompletableFuture<ResponseEntity<?>> future =
|
||||
jobQueue.queueJob(jobId, resourceWeight, wrappedWork, timeoutToUse);
|
||||
|
||||
// Return immediately with job ID
|
||||
return ResponseEntity.ok().body(new JobResponse<>(true, jobId, null));
|
||||
} else if (async) {
|
||||
taskManager.createTask(jobId);
|
||||
webSocketSender.sendProgress(jobId, new JobProgress(jobId, "Started", 0, "Running"));
|
||||
|
||||
executor.execute(
|
||||
() -> {
|
||||
try {
|
||||
log.debug(
|
||||
"Running async job {} with timeout {} ms", jobId, timeoutToUse);
|
||||
|
||||
// Execute with timeout
|
||||
Object result = executeWithTimeout(() -> work.get(), timeoutToUse);
|
||||
processJobResult(jobId, result);
|
||||
webSocketSender.sendProgress(
|
||||
jobId, new JobProgress(jobId, "Done", 100, "Complete"));
|
||||
} catch (TimeoutException te) {
|
||||
log.error("Job {} timed out after {} ms", jobId, timeoutToUse);
|
||||
taskManager.setError(jobId, "Job timed out");
|
||||
webSocketSender.sendProgress(
|
||||
jobId, new JobProgress(jobId, "Error", 100, "Job timed out"));
|
||||
} catch (Exception e) {
|
||||
log.error("Error executing job {}: {}", jobId, e.getMessage(), e);
|
||||
taskManager.setError(jobId, e.getMessage());
|
||||
webSocketSender.sendProgress(
|
||||
jobId, new JobProgress(jobId, "Error", 100, e.getMessage()));
|
||||
}
|
||||
});
|
||||
|
||||
return ResponseEntity.ok().body(new JobResponse<>(true, jobId, null));
|
||||
} else {
|
||||
try {
|
||||
log.debug("Running sync job with timeout {} ms", timeoutToUse);
|
||||
|
||||
// Execute with timeout
|
||||
Object result = executeWithTimeout(() -> work.get(), timeoutToUse);
|
||||
|
||||
// If the result is already a ResponseEntity, return it directly
|
||||
if (result instanceof ResponseEntity) {
|
||||
return (ResponseEntity<?>) result;
|
||||
}
|
||||
|
||||
// Process different result types
|
||||
return handleResultForSyncJob(result);
|
||||
} catch (TimeoutException te) {
|
||||
log.error("Synchronous job timed out after {} ms", timeoutToUse);
|
||||
return ResponseEntity.internalServerError()
|
||||
.body(Map.of("error", "Job timed out after " + timeoutToUse + " ms"));
|
||||
} catch (Exception e) {
|
||||
log.error("Error executing synchronous job: {}", e.getMessage(), e);
|
||||
// Construct a JSON error response
|
||||
return ResponseEntity.internalServerError()
|
||||
.body(Map.of("error", "Job failed: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the result of an asynchronous job
|
||||
*
|
||||
* @param jobId The job ID
|
||||
* @param result The result
|
||||
*/
|
||||
private void processJobResult(String jobId, Object result) {
|
||||
try {
|
||||
if (result instanceof byte[]) {
|
||||
// Store byte array as a file
|
||||
String fileId = fileStorage.storeBytes((byte[]) result, "result.pdf");
|
||||
taskManager.setFileResult(jobId, fileId, "result.pdf", "application/pdf");
|
||||
log.debug("Stored byte[] result with fileId: {}", fileId);
|
||||
} else if (result instanceof ResponseEntity) {
|
||||
ResponseEntity<?> response = (ResponseEntity<?>) result;
|
||||
Object body = response.getBody();
|
||||
|
||||
if (body instanceof byte[]) {
|
||||
// Extract filename from content-disposition header if available
|
||||
String filename = "result.pdf";
|
||||
String contentType = "application/pdf";
|
||||
|
||||
if (response.getHeaders().getContentDisposition() != null) {
|
||||
String disposition =
|
||||
response.getHeaders().getContentDisposition().toString();
|
||||
if (disposition.contains("filename=")) {
|
||||
filename =
|
||||
disposition.substring(
|
||||
disposition.indexOf("filename=") + 9,
|
||||
disposition.lastIndexOf("\""));
|
||||
}
|
||||
}
|
||||
|
||||
if (response.getHeaders().getContentType() != null) {
|
||||
contentType = response.getHeaders().getContentType().toString();
|
||||
}
|
||||
|
||||
String fileId = fileStorage.storeBytes((byte[]) body, filename);
|
||||
taskManager.setFileResult(jobId, fileId, filename, contentType);
|
||||
log.debug("Stored ResponseEntity<byte[]> result with fileId: {}", fileId);
|
||||
} else {
|
||||
// Check if the response body contains a fileId
|
||||
if (body != null && body.toString().contains("fileId")) {
|
||||
try {
|
||||
// Try to extract fileId using reflection
|
||||
java.lang.reflect.Method getFileId =
|
||||
body.getClass().getMethod("getFileId");
|
||||
String fileId = (String) getFileId.invoke(body);
|
||||
|
||||
if (fileId != null && !fileId.isEmpty()) {
|
||||
// Try to get filename and content type
|
||||
String filename = "result.pdf";
|
||||
String contentType = "application/pdf";
|
||||
|
||||
try {
|
||||
java.lang.reflect.Method getOriginalFileName =
|
||||
body.getClass().getMethod("getOriginalFilename");
|
||||
String origName = (String) getOriginalFileName.invoke(body);
|
||||
if (origName != null && !origName.isEmpty()) {
|
||||
filename = origName;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug(
|
||||
"Could not get original filename: {}", e.getMessage());
|
||||
}
|
||||
|
||||
try {
|
||||
java.lang.reflect.Method getContentType =
|
||||
body.getClass().getMethod("getContentType");
|
||||
String ct = (String) getContentType.invoke(body);
|
||||
if (ct != null && !ct.isEmpty()) {
|
||||
contentType = ct;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("Could not get content type: {}", e.getMessage());
|
||||
}
|
||||
|
||||
taskManager.setFileResult(jobId, fileId, filename, contentType);
|
||||
log.debug("Extracted fileId from response body: {}", fileId);
|
||||
|
||||
taskManager.setComplete(jobId);
|
||||
return;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug(
|
||||
"Failed to extract fileId from response body: {}",
|
||||
e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Store generic result
|
||||
taskManager.setResult(jobId, body);
|
||||
}
|
||||
} else if (result instanceof MultipartFile) {
|
||||
MultipartFile file = (MultipartFile) result;
|
||||
String fileId = fileStorage.storeFile(file);
|
||||
taskManager.setFileResult(
|
||||
jobId, fileId, file.getOriginalFilename(), file.getContentType());
|
||||
log.debug("Stored MultipartFile result with fileId: {}", fileId);
|
||||
} else {
|
||||
// Check if result has a fileId field
|
||||
if (result != null) {
|
||||
try {
|
||||
// Try to extract fileId using reflection
|
||||
java.lang.reflect.Method getFileId =
|
||||
result.getClass().getMethod("getFileId");
|
||||
String fileId = (String) getFileId.invoke(result);
|
||||
|
||||
if (fileId != null && !fileId.isEmpty()) {
|
||||
// Try to get filename and content type
|
||||
String filename = "result.pdf";
|
||||
String contentType = "application/pdf";
|
||||
|
||||
try {
|
||||
java.lang.reflect.Method getOriginalFileName =
|
||||
result.getClass().getMethod("getOriginalFilename");
|
||||
String origName = (String) getOriginalFileName.invoke(result);
|
||||
if (origName != null && !origName.isEmpty()) {
|
||||
filename = origName;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("Could not get original filename: {}", e.getMessage());
|
||||
}
|
||||
|
||||
try {
|
||||
java.lang.reflect.Method getContentType =
|
||||
result.getClass().getMethod("getContentType");
|
||||
String ct = (String) getContentType.invoke(result);
|
||||
if (ct != null && !ct.isEmpty()) {
|
||||
contentType = ct;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("Could not get content type: {}", e.getMessage());
|
||||
}
|
||||
|
||||
taskManager.setFileResult(jobId, fileId, filename, contentType);
|
||||
log.debug("Extracted fileId from result object: {}", fileId);
|
||||
|
||||
taskManager.setComplete(jobId);
|
||||
return;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug(
|
||||
"Failed to extract fileId from result object: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Default case: store the result as is
|
||||
taskManager.setResult(jobId, result);
|
||||
}
|
||||
|
||||
taskManager.setComplete(jobId);
|
||||
} catch (Exception e) {
|
||||
log.error("Error processing job result: {}", e.getMessage(), e);
|
||||
taskManager.setError(jobId, "Error processing result: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle different result types for synchronous jobs
|
||||
*
|
||||
* @param result The result object
|
||||
* @return The appropriate ResponseEntity
|
||||
* @throws IOException If there is an error processing the result
|
||||
*/
|
||||
private ResponseEntity<?> handleResultForSyncJob(Object result) throws IOException {
|
||||
if (result instanceof byte[]) {
|
||||
// Return byte array as PDF
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.APPLICATION_PDF)
|
||||
.header(
|
||||
HttpHeaders.CONTENT_DISPOSITION,
|
||||
"form-data; name=\"attachment\"; filename=\"result.pdf\"")
|
||||
.body(result);
|
||||
} else if (result instanceof MultipartFile) {
|
||||
// Return MultipartFile content
|
||||
MultipartFile file = (MultipartFile) result;
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.parseMediaType(file.getContentType()))
|
||||
.header(
|
||||
HttpHeaders.CONTENT_DISPOSITION,
|
||||
"form-data; name=\"attachment\"; filename=\""
|
||||
+ file.getOriginalFilename()
|
||||
+ "\"")
|
||||
.body(file.getBytes());
|
||||
} else {
|
||||
// Default case: return as JSON
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse session timeout string (e.g., "30m", "1h") to milliseconds
|
||||
*
|
||||
* @param timeout The timeout string
|
||||
* @return The timeout in milliseconds
|
||||
*/
|
||||
private long parseSessionTimeout(String timeout) {
|
||||
if (timeout == null || timeout.isEmpty()) {
|
||||
return 30 * 60 * 1000; // Default: 30 minutes
|
||||
}
|
||||
|
||||
try {
|
||||
String value = timeout.replaceAll("[^\\d.]", "");
|
||||
String unit = timeout.replaceAll("[\\d.]", "");
|
||||
|
||||
double numericValue = Double.parseDouble(value);
|
||||
|
||||
return switch (unit.toLowerCase()) {
|
||||
case "s" -> (long) (numericValue * 1000);
|
||||
case "m" -> (long) (numericValue * 60 * 1000);
|
||||
case "h" -> (long) (numericValue * 60 * 60 * 1000);
|
||||
case "d" -> (long) (numericValue * 24 * 60 * 60 * 1000);
|
||||
default -> (long) (numericValue * 60 * 1000); // Default to minutes
|
||||
};
|
||||
} catch (Exception e) {
|
||||
log.warn("Could not parse session timeout '{}', using default", timeout);
|
||||
return 30 * 60 * 1000; // Default: 30 minutes
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a supplier with a timeout
|
||||
*
|
||||
* @param supplier The supplier to execute
|
||||
* @param timeoutMs The timeout in milliseconds
|
||||
* @return The result from the supplier
|
||||
* @throws TimeoutException If the execution times out
|
||||
* @throws Exception If the supplier throws an exception
|
||||
*/
|
||||
private <T> T executeWithTimeout(Supplier<T> supplier, long timeoutMs)
|
||||
throws TimeoutException, Exception {
|
||||
java.util.concurrent.CompletableFuture<T> future =
|
||||
java.util.concurrent.CompletableFuture.supplyAsync(supplier);
|
||||
|
||||
try {
|
||||
return future.get(timeoutMs, TimeUnit.MILLISECONDS);
|
||||
} catch (java.util.concurrent.TimeoutException e) {
|
||||
future.cancel(true);
|
||||
throw new TimeoutException("Execution timed out after " + timeoutMs + " ms");
|
||||
} catch (java.util.concurrent.ExecutionException e) {
|
||||
throw (Exception) e.getCause();
|
||||
} catch (java.util.concurrent.CancellationException e) {
|
||||
throw new Exception("Execution was cancelled", e);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new Exception("Execution was interrupted", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.Getter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.controller.WebSocketProgressController;
|
||||
import stirling.software.common.model.job.JobProgress;
|
||||
import stirling.software.common.util.ExecutorFactory;
|
||||
|
||||
/**
|
||||
* Manages a queue of jobs with dynamic sizing based on system resources. Used when system resources
|
||||
* are limited to prevent overloading.
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
public class JobQueue {
|
||||
|
||||
private final ResourceMonitor resourceMonitor;
|
||||
private final WebSocketProgressController webSocketSender;
|
||||
|
||||
@Value("${stirling.job.queue.base-capacity:10}")
|
||||
private int baseQueueCapacity = 10;
|
||||
|
||||
@Value("${stirling.job.queue.min-capacity:2}")
|
||||
private int minQueueCapacity = 2;
|
||||
|
||||
@Value("${stirling.job.queue.check-interval-ms:1000}")
|
||||
private long queueCheckIntervalMs = 1000;
|
||||
|
||||
@Value("${stirling.job.queue.max-wait-time-ms:600000}")
|
||||
private long maxWaitTimeMs = 600000; // 10 minutes
|
||||
|
||||
private BlockingQueue<QueuedJob> jobQueue;
|
||||
private final Map<String, QueuedJob> jobMap = new ConcurrentHashMap<>();
|
||||
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
|
||||
private final ExecutorService jobExecutor = ExecutorFactory.newVirtualOrCachedThreadExecutor();
|
||||
|
||||
private boolean shuttingDown = false;
|
||||
|
||||
@Getter private int rejectedJobs = 0;
|
||||
|
||||
@Getter private int totalQueuedJobs = 0;
|
||||
|
||||
@Getter private int currentQueueSize = 0;
|
||||
|
||||
/** Represents a job waiting in the queue. */
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
private static class QueuedJob {
|
||||
private final String jobId;
|
||||
private final int resourceWeight;
|
||||
private final Supplier<Object> work;
|
||||
private final long timeoutMs;
|
||||
private final Instant queuedAt;
|
||||
private CompletableFuture<ResponseEntity<?>> future;
|
||||
private volatile boolean cancelled = false;
|
||||
}
|
||||
|
||||
public JobQueue(ResourceMonitor resourceMonitor, WebSocketProgressController webSocketSender) {
|
||||
this.resourceMonitor = resourceMonitor;
|
||||
this.webSocketSender = webSocketSender;
|
||||
|
||||
// Initialize with dynamic capacity
|
||||
int capacity =
|
||||
resourceMonitor.calculateDynamicQueueCapacity(baseQueueCapacity, minQueueCapacity);
|
||||
this.jobQueue = new LinkedBlockingQueue<>(capacity);
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
public void initialize() {
|
||||
log.debug(
|
||||
"Starting job queue with base capacity {}, min capacity {}",
|
||||
baseQueueCapacity,
|
||||
minQueueCapacity);
|
||||
|
||||
// Periodically process the job queue
|
||||
scheduler.scheduleWithFixedDelay(
|
||||
this::processQueue, 0, queueCheckIntervalMs, TimeUnit.MILLISECONDS);
|
||||
|
||||
// Periodically update queue capacity based on resource usage
|
||||
scheduler.scheduleWithFixedDelay(
|
||||
this::updateQueueCapacity,
|
||||
10000, // Initial delay
|
||||
30000, // 30 second interval
|
||||
TimeUnit.MILLISECONDS);
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
public void shutdown() {
|
||||
log.info("Shutting down job queue");
|
||||
shuttingDown = true;
|
||||
|
||||
// Complete any futures that are still waiting
|
||||
jobMap.forEach(
|
||||
(id, job) -> {
|
||||
if (!job.future.isDone()) {
|
||||
job.future.completeExceptionally(
|
||||
new RuntimeException("Server shutting down, job cancelled"));
|
||||
}
|
||||
});
|
||||
|
||||
scheduler.shutdownNow();
|
||||
jobExecutor.shutdownNow();
|
||||
|
||||
log.info(
|
||||
"Job queue shutdown complete. Stats: total={}, rejected={}",
|
||||
totalQueuedJobs,
|
||||
rejectedJobs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Queues a job for execution when resources permit.
|
||||
*
|
||||
* @param jobId The job ID
|
||||
* @param resourceWeight The resource weight of the job (1-100)
|
||||
* @param work The work to be done
|
||||
* @param timeoutMs The timeout in milliseconds
|
||||
* @return A CompletableFuture that will complete when the job is executed
|
||||
*/
|
||||
public CompletableFuture<ResponseEntity<?>> queueJob(
|
||||
String jobId, int resourceWeight, Supplier<Object> work, long timeoutMs) {
|
||||
|
||||
// Create a CompletableFuture to track this job's completion
|
||||
CompletableFuture<ResponseEntity<?>> future = new CompletableFuture<>();
|
||||
|
||||
// Create the queued job
|
||||
QueuedJob job =
|
||||
new QueuedJob(jobId, resourceWeight, work, timeoutMs, Instant.now(), future, false);
|
||||
|
||||
// Store in our map for lookup
|
||||
jobMap.put(jobId, job);
|
||||
|
||||
// Update stats
|
||||
totalQueuedJobs++;
|
||||
currentQueueSize = jobQueue.size();
|
||||
|
||||
// Try to add to the queue
|
||||
try {
|
||||
boolean added = jobQueue.offer(job, 5, TimeUnit.SECONDS);
|
||||
if (!added) {
|
||||
log.warn("Queue full, rejecting job {}", jobId);
|
||||
rejectedJobs++;
|
||||
future.completeExceptionally(
|
||||
new RuntimeException("Job queue full, please try again later"));
|
||||
jobMap.remove(jobId);
|
||||
return future;
|
||||
}
|
||||
|
||||
log.debug(
|
||||
"Job {} queued for execution (weight: {}, queue size: {})",
|
||||
jobId,
|
||||
resourceWeight,
|
||||
jobQueue.size());
|
||||
|
||||
// Notify client via WebSocket that job is queued
|
||||
webSocketSender.sendProgress(
|
||||
jobId, new JobProgress(jobId, "Queued", 0, "Waiting in queue for resources"));
|
||||
|
||||
return future;
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
future.completeExceptionally(new RuntimeException("Job queue interrupted"));
|
||||
jobMap.remove(jobId);
|
||||
return future;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current capacity of the job queue.
|
||||
*
|
||||
* @return The current capacity
|
||||
*/
|
||||
public int getQueueCapacity() {
|
||||
return ((LinkedBlockingQueue<QueuedJob>) jobQueue).remainingCapacity() + jobQueue.size();
|
||||
}
|
||||
|
||||
/** Updates the capacity of the job queue based on available system resources. */
|
||||
private void updateQueueCapacity() {
|
||||
try {
|
||||
// Calculate new capacity
|
||||
int newCapacity =
|
||||
resourceMonitor.calculateDynamicQueueCapacity(
|
||||
baseQueueCapacity, minQueueCapacity);
|
||||
|
||||
int currentCapacity = getQueueCapacity();
|
||||
if (newCapacity != currentCapacity) {
|
||||
log.debug(
|
||||
"Updating job queue capacity from {} to {}", currentCapacity, newCapacity);
|
||||
|
||||
// Create new queue with updated capacity
|
||||
BlockingQueue<QueuedJob> newQueue = new LinkedBlockingQueue<>(newCapacity);
|
||||
|
||||
// Transfer jobs from old queue to new queue
|
||||
jobQueue.drainTo(newQueue);
|
||||
jobQueue = newQueue;
|
||||
|
||||
currentQueueSize = jobQueue.size();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Error updating queue capacity: {}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Processes jobs in the queue, executing them when resources permit. */
|
||||
private void processQueue() {
|
||||
if (shuttingDown || jobQueue.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get current resource status
|
||||
ResourceMonitor.ResourceStatus status = resourceMonitor.getCurrentStatus().get();
|
||||
|
||||
// Check if we should execute any jobs
|
||||
boolean canExecuteJobs = (status != ResourceMonitor.ResourceStatus.CRITICAL);
|
||||
|
||||
if (!canExecuteJobs) {
|
||||
// Under critical load, don't execute any jobs
|
||||
log.debug("System under critical load, delaying job execution");
|
||||
return;
|
||||
}
|
||||
|
||||
// Get jobs from the queue, up to a limit based on resource availability
|
||||
int jobsToProcess =
|
||||
Math.max(
|
||||
1,
|
||||
switch (status) {
|
||||
case OK -> 3;
|
||||
case WARNING -> 1;
|
||||
case CRITICAL -> 0;
|
||||
});
|
||||
|
||||
for (int i = 0; i < jobsToProcess && !jobQueue.isEmpty(); i++) {
|
||||
QueuedJob job = jobQueue.poll();
|
||||
if (job == null) break;
|
||||
|
||||
// Check if it's been waiting too long
|
||||
long waitTimeMs = Instant.now().toEpochMilli() - job.queuedAt.toEpochMilli();
|
||||
if (waitTimeMs > maxWaitTimeMs) {
|
||||
log.warn(
|
||||
"Job {} exceeded maximum wait time ({} ms), executing anyway",
|
||||
job.jobId,
|
||||
waitTimeMs);
|
||||
}
|
||||
|
||||
// Remove from our map
|
||||
jobMap.remove(job.jobId);
|
||||
currentQueueSize = jobQueue.size();
|
||||
|
||||
// Notify via WebSocket
|
||||
webSocketSender.sendProgress(
|
||||
job.jobId,
|
||||
new JobProgress(job.jobId, "Processing", 10, "Starting job execution"));
|
||||
|
||||
// Execute the job
|
||||
executeJob(job);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Error processing job queue: {}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a job from the queue.
|
||||
*
|
||||
* @param job The job to execute
|
||||
*/
|
||||
private void executeJob(QueuedJob job) {
|
||||
if (job.cancelled) {
|
||||
log.debug("Job {} was cancelled, not executing", job.jobId);
|
||||
return;
|
||||
}
|
||||
|
||||
jobExecutor.execute(
|
||||
() -> {
|
||||
log.debug("Executing queued job {} (queued at {})", job.jobId, job.queuedAt);
|
||||
|
||||
try {
|
||||
// Execute with timeout
|
||||
Object result = executeWithTimeout(job.work, job.timeoutMs);
|
||||
|
||||
// Process the result
|
||||
if (result instanceof ResponseEntity) {
|
||||
job.future.complete((ResponseEntity<?>) result);
|
||||
} else {
|
||||
job.future.complete(ResponseEntity.ok(result));
|
||||
}
|
||||
|
||||
// Update WebSocket
|
||||
webSocketSender.sendProgress(
|
||||
job.jobId,
|
||||
new JobProgress(
|
||||
job.jobId, "Complete", 100, "Job completed successfully"));
|
||||
} catch (Exception e) {
|
||||
log.error(
|
||||
"Error executing queued job {}: {}", job.jobId, e.getMessage(), e);
|
||||
job.future.completeExceptionally(e);
|
||||
|
||||
// Update WebSocket
|
||||
webSocketSender.sendProgress(
|
||||
job.jobId,
|
||||
new JobProgress(
|
||||
job.jobId, "Error", 100, "Job failed: " + e.getMessage()));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a supplier with a timeout.
|
||||
*
|
||||
* @param supplier The supplier to execute
|
||||
* @param timeoutMs The timeout in milliseconds
|
||||
* @return The result from the supplier
|
||||
* @throws Exception If there is an execution error
|
||||
*/
|
||||
private <T> T executeWithTimeout(Supplier<T> supplier, long timeoutMs) throws Exception {
|
||||
CompletableFuture<T> future = CompletableFuture.supplyAsync(supplier);
|
||||
|
||||
try {
|
||||
if (timeoutMs <= 0) {
|
||||
// No timeout
|
||||
return future.join();
|
||||
} else {
|
||||
// With timeout
|
||||
return future.get(timeoutMs, TimeUnit.MILLISECONDS);
|
||||
}
|
||||
} catch (TimeoutException e) {
|
||||
future.cancel(true);
|
||||
throw new TimeoutException("Job timed out after " + timeoutMs + "ms");
|
||||
} catch (ExecutionException e) {
|
||||
throw (Exception) e.getCause();
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new InterruptedException("Job was interrupted");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a job is queued.
|
||||
*
|
||||
* @param jobId The job ID
|
||||
* @return true if the job is queued
|
||||
*/
|
||||
public boolean isJobQueued(String jobId) {
|
||||
return jobMap.containsKey(jobId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current position of a job in the queue.
|
||||
*
|
||||
* @param jobId The job ID
|
||||
* @return The position (0-based) or -1 if not found
|
||||
*/
|
||||
public int getJobPosition(String jobId) {
|
||||
if (!jobMap.containsKey(jobId)) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Count positions
|
||||
int position = 0;
|
||||
for (QueuedJob job : jobQueue) {
|
||||
if (job.jobId.equals(jobId)) {
|
||||
return position;
|
||||
}
|
||||
position++;
|
||||
}
|
||||
|
||||
// If we didn't find it in the queue but it's in the map,
|
||||
// it might be executing already
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels a queued job.
|
||||
*
|
||||
* @param jobId The job ID
|
||||
* @return true if the job was cancelled, false if not found
|
||||
*/
|
||||
public boolean cancelJob(String jobId) {
|
||||
QueuedJob job = jobMap.remove(jobId);
|
||||
if (job != null) {
|
||||
job.cancelled = true;
|
||||
job.future.completeExceptionally(new RuntimeException("Job cancelled by user"));
|
||||
|
||||
// Try to remove from queue if it's still there
|
||||
jobQueue.remove(job);
|
||||
currentQueueSize = jobQueue.size();
|
||||
|
||||
log.debug("Job {} cancelled", jobId);
|
||||
|
||||
// Update WebSocket
|
||||
webSocketSender.sendProgress(
|
||||
jobId, new JobProgress(jobId, "Cancelled", 100, "Job cancelled by user"));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get queue statistics.
|
||||
*
|
||||
* @return A map containing queue statistics
|
||||
*/
|
||||
public Map<String, Object> getQueueStats() {
|
||||
return Map.of(
|
||||
"queuedJobs", jobQueue.size(),
|
||||
"queueCapacity", getQueueCapacity(),
|
||||
"totalQueuedJobs", totalQueuedJobs,
|
||||
"rejectedJobs", rejectedJobs,
|
||||
"resourceStatus", resourceMonitor.getCurrentStatus().get().name());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import java.lang.management.ManagementFactory;
|
||||
import java.lang.management.MemoryMXBean;
|
||||
import java.lang.management.OperatingSystemMXBean;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Monitors system resources (CPU, memory) to inform job scheduling decisions. Provides information
|
||||
* about available resources to prevent overloading the system.
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
public class ResourceMonitor {
|
||||
|
||||
@Value("${stirling.resource.memory.critical-threshold:0.9}")
|
||||
private double memoryCriticalThreshold = 0.9; // 90% usage is critical
|
||||
|
||||
@Value("${stirling.resource.memory.high-threshold:0.75}")
|
||||
private double memoryHighThreshold = 0.75; // 75% usage is high
|
||||
|
||||
@Value("${stirling.resource.cpu.critical-threshold:0.9}")
|
||||
private double cpuCriticalThreshold = 0.9; // 90% usage is critical
|
||||
|
||||
@Value("${stirling.resource.cpu.high-threshold:0.75}")
|
||||
private double cpuHighThreshold = 0.75; // 75% usage is high
|
||||
|
||||
@Value("${stirling.resource.monitor.interval-ms:60000}")
|
||||
private long monitorIntervalMs = 60000; // 60 seconds
|
||||
|
||||
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
|
||||
private final MemoryMXBean memoryMXBean = ManagementFactory.getMemoryMXBean();
|
||||
private final OperatingSystemMXBean osMXBean = ManagementFactory.getOperatingSystemMXBean();
|
||||
|
||||
@Getter
|
||||
private final AtomicReference<ResourceStatus> currentStatus =
|
||||
new AtomicReference<>(ResourceStatus.OK);
|
||||
|
||||
@Getter
|
||||
private final AtomicReference<ResourceMetrics> latestMetrics =
|
||||
new AtomicReference<>(new ResourceMetrics());
|
||||
|
||||
/** Represents the current status of system resources. */
|
||||
public enum ResourceStatus {
|
||||
/** Resources are available, normal operations can proceed */
|
||||
OK,
|
||||
|
||||
/** Resources are under strain, consider queueing high-resource operations */
|
||||
WARNING,
|
||||
|
||||
/** Resources are critically low, queue all operations */
|
||||
CRITICAL
|
||||
}
|
||||
|
||||
/** Detailed metrics about system resources. */
|
||||
@Getter
|
||||
public static class ResourceMetrics {
|
||||
private final double cpuUsage;
|
||||
private final double memoryUsage;
|
||||
private final long freeMemoryBytes;
|
||||
private final long totalMemoryBytes;
|
||||
private final long maxMemoryBytes;
|
||||
private final Instant timestamp;
|
||||
|
||||
public ResourceMetrics() {
|
||||
this(0, 0, 0, 0, 0, Instant.now());
|
||||
}
|
||||
|
||||
public ResourceMetrics(
|
||||
double cpuUsage,
|
||||
double memoryUsage,
|
||||
long freeMemoryBytes,
|
||||
long totalMemoryBytes,
|
||||
long maxMemoryBytes,
|
||||
Instant timestamp) {
|
||||
this.cpuUsage = cpuUsage;
|
||||
this.memoryUsage = memoryUsage;
|
||||
this.freeMemoryBytes = freeMemoryBytes;
|
||||
this.totalMemoryBytes = totalMemoryBytes;
|
||||
this.maxMemoryBytes = maxMemoryBytes;
|
||||
this.timestamp = timestamp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the age of these metrics.
|
||||
*
|
||||
* @return Duration since these metrics were collected
|
||||
*/
|
||||
public Duration getAge() {
|
||||
return Duration.between(timestamp, Instant.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if these metrics are stale (older than threshold).
|
||||
*
|
||||
* @param thresholdMs Staleness threshold in milliseconds
|
||||
* @return true if metrics are stale
|
||||
*/
|
||||
public boolean isStale(long thresholdMs) {
|
||||
return getAge().toMillis() > thresholdMs;
|
||||
}
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
public void initialize() {
|
||||
log.debug("Starting resource monitoring with interval of {}ms", monitorIntervalMs);
|
||||
scheduler.scheduleAtFixedRate(
|
||||
this::updateResourceMetrics, 0, monitorIntervalMs, TimeUnit.MILLISECONDS);
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
public void shutdown() {
|
||||
log.info("Shutting down resource monitoring");
|
||||
scheduler.shutdownNow();
|
||||
}
|
||||
|
||||
/** Updates the resource metrics by sampling current system state. */
|
||||
private void updateResourceMetrics() {
|
||||
try {
|
||||
// Get CPU usage
|
||||
double cpuUsage = osMXBean.getSystemLoadAverage() / osMXBean.getAvailableProcessors();
|
||||
if (cpuUsage < 0) cpuUsage = getAlternativeCpuLoad(); // Fallback if not available
|
||||
|
||||
// Get memory usage
|
||||
long heapUsed = memoryMXBean.getHeapMemoryUsage().getUsed();
|
||||
long nonHeapUsed = memoryMXBean.getNonHeapMemoryUsage().getUsed();
|
||||
long totalUsed = heapUsed + nonHeapUsed;
|
||||
|
||||
long maxMemory = Runtime.getRuntime().maxMemory();
|
||||
long totalMemory = Runtime.getRuntime().totalMemory();
|
||||
long freeMemory = Runtime.getRuntime().freeMemory();
|
||||
|
||||
double memoryUsage = (double) totalUsed / maxMemory;
|
||||
|
||||
// Create new metrics
|
||||
ResourceMetrics metrics =
|
||||
new ResourceMetrics(
|
||||
cpuUsage,
|
||||
memoryUsage,
|
||||
freeMemory,
|
||||
totalMemory,
|
||||
maxMemory,
|
||||
Instant.now());
|
||||
latestMetrics.set(metrics);
|
||||
|
||||
// Determine system status
|
||||
ResourceStatus newStatus;
|
||||
if (cpuUsage > cpuCriticalThreshold || memoryUsage > memoryCriticalThreshold) {
|
||||
newStatus = ResourceStatus.CRITICAL;
|
||||
} else if (cpuUsage > cpuHighThreshold || memoryUsage > memoryHighThreshold) {
|
||||
newStatus = ResourceStatus.WARNING;
|
||||
} else {
|
||||
newStatus = ResourceStatus.OK;
|
||||
}
|
||||
|
||||
// Update status if it changed
|
||||
ResourceStatus oldStatus = currentStatus.getAndSet(newStatus);
|
||||
if (oldStatus != newStatus) {
|
||||
log.info("System resource status changed from {} to {}", oldStatus, newStatus);
|
||||
log.info(
|
||||
"Current metrics - CPU: {:.1f}%, Memory: {:.1f}%, Free Memory: {} MB",
|
||||
cpuUsage * 100, memoryUsage * 100, freeMemory / (1024 * 1024));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Error updating resource metrics: {}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Alternative method to estimate CPU load if getSystemLoadAverage() is not available. This is a
|
||||
* fallback and less accurate than the official JMX method.
|
||||
*
|
||||
* @return Estimated CPU load as a value between 0.0 and 1.0
|
||||
*/
|
||||
private double getAlternativeCpuLoad() {
|
||||
try {
|
||||
// Try to get CPU time if available through reflection
|
||||
// This is a fallback since we can't directly cast to platform-specific classes
|
||||
try {
|
||||
java.lang.reflect.Method m =
|
||||
osMXBean.getClass().getDeclaredMethod("getProcessCpuLoad");
|
||||
m.setAccessible(true);
|
||||
return (double) m.invoke(osMXBean);
|
||||
} catch (Exception e) {
|
||||
// Try the older method
|
||||
try {
|
||||
java.lang.reflect.Method m =
|
||||
osMXBean.getClass().getDeclaredMethod("getSystemCpuLoad");
|
||||
m.setAccessible(true);
|
||||
return (double) m.invoke(osMXBean);
|
||||
} catch (Exception e2) {
|
||||
log.trace(
|
||||
"Could not get CPU load through reflection, assuming moderate load (0.5)");
|
||||
return 0.5;
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.trace("Could not get CPU load, assuming moderate load (0.5)");
|
||||
return 0.5; // Default to moderate load
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the dynamic job queue capacity based on current resource usage.
|
||||
*
|
||||
* @param baseCapacity The base capacity when system is under minimal load
|
||||
* @param minCapacity The minimum capacity to maintain even under high load
|
||||
* @return The calculated job queue capacity
|
||||
*/
|
||||
public int calculateDynamicQueueCapacity(int baseCapacity, int minCapacity) {
|
||||
ResourceMetrics metrics = latestMetrics.get();
|
||||
ResourceStatus status = currentStatus.get();
|
||||
|
||||
// Simple linear reduction based on memory and CPU load
|
||||
double capacityFactor =
|
||||
switch (status) {
|
||||
case OK -> 1.0;
|
||||
case WARNING -> 0.6;
|
||||
case CRITICAL -> 0.3;
|
||||
};
|
||||
|
||||
// Apply additional reduction based on specific memory pressure
|
||||
if (metrics.memoryUsage > 0.8) {
|
||||
capacityFactor *= 0.5; // Further reduce capacity under memory pressure
|
||||
}
|
||||
|
||||
// Calculate capacity with minimum safeguard
|
||||
int capacity = (int) Math.max(minCapacity, Math.ceil(baseCapacity * capacityFactor));
|
||||
|
||||
log.debug(
|
||||
"Dynamic queue capacity: {} (base: {}, factor: {:.2f}, status: {})",
|
||||
capacity,
|
||||
baseCapacity,
|
||||
capacityFactor,
|
||||
status);
|
||||
|
||||
return capacity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a job with the given weight can be executed immediately or should be queued based
|
||||
* on current resource availability.
|
||||
*
|
||||
* @param resourceWeight The resource weight of the job (1-100)
|
||||
* @return true if the job should be queued, false if it can run immediately
|
||||
*/
|
||||
public boolean shouldQueueJob(int resourceWeight) {
|
||||
ResourceStatus status = currentStatus.get();
|
||||
|
||||
// Always run lightweight jobs (weight < 20) unless critical
|
||||
if (resourceWeight < 20 && status != ResourceStatus.CRITICAL) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Medium weight jobs run immediately if resources are OK
|
||||
if (resourceWeight < 60 && status == ResourceStatus.OK) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Heavy jobs (weight >= 60) and any job during WARNING/CRITICAL should be queued
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import jakarta.annotation.PreDestroy;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.job.JobResult;
|
||||
import stirling.software.common.model.job.JobStats;
|
||||
|
||||
/** Manages async tasks and their results */
|
||||
@Service
|
||||
@Slf4j
|
||||
public class TaskManager {
|
||||
private final Map<String, JobResult> jobResults = new ConcurrentHashMap<>();
|
||||
|
||||
@Value("${stirling.jobResultExpiryMinutes:30}")
|
||||
private int jobResultExpiryMinutes = 30;
|
||||
|
||||
private final FileStorage fileStorage;
|
||||
private final ScheduledExecutorService cleanupExecutor =
|
||||
Executors.newSingleThreadScheduledExecutor();
|
||||
|
||||
/** Initialize the task manager and start the cleanup scheduler */
|
||||
public TaskManager(FileStorage fileStorage) {
|
||||
this.fileStorage = fileStorage;
|
||||
|
||||
// Schedule periodic cleanup of old job results
|
||||
cleanupExecutor.scheduleAtFixedRate(
|
||||
this::cleanupOldJobs,
|
||||
10, // Initial delay
|
||||
10, // Interval
|
||||
TimeUnit.MINUTES);
|
||||
|
||||
log.debug(
|
||||
"Task manager initialized with job result expiry of {} minutes",
|
||||
jobResultExpiryMinutes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new task with the given job ID
|
||||
*
|
||||
* @param jobId The job ID
|
||||
*/
|
||||
public void createTask(String jobId) {
|
||||
jobResults.put(jobId, JobResult.createNew(jobId));
|
||||
log.debug("Created task with job ID: {}", jobId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the result of a task as a general object
|
||||
*
|
||||
* @param jobId The job ID
|
||||
* @param result The result object
|
||||
*/
|
||||
public void setResult(String jobId, Object result) {
|
||||
JobResult jobResult = getOrCreateJobResult(jobId);
|
||||
jobResult.completeWithResult(result);
|
||||
log.debug("Set result for job ID: {}", jobId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the result of a task as a file
|
||||
*
|
||||
* @param jobId The job ID
|
||||
* @param fileId The file ID
|
||||
* @param originalFileName The original file name
|
||||
* @param contentType The content type of the file
|
||||
*/
|
||||
public void setFileResult(
|
||||
String jobId, String fileId, String originalFileName, String contentType) {
|
||||
JobResult jobResult = getOrCreateJobResult(jobId);
|
||||
jobResult.completeWithFile(fileId, originalFileName, contentType);
|
||||
log.debug("Set file result for job ID: {} with file ID: {}", jobId, fileId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set an error for a task
|
||||
*
|
||||
* @param jobId The job ID
|
||||
* @param error The error message
|
||||
*/
|
||||
public void setError(String jobId, String error) {
|
||||
JobResult jobResult = getOrCreateJobResult(jobId);
|
||||
jobResult.failWithError(error);
|
||||
log.debug("Set error for job ID: {}: {}", jobId, error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a task as complete
|
||||
*
|
||||
* @param jobId The job ID
|
||||
*/
|
||||
public void setComplete(String jobId) {
|
||||
JobResult jobResult = getOrCreateJobResult(jobId);
|
||||
if (jobResult.getResult() == null
|
||||
&& jobResult.getFileId() == null
|
||||
&& jobResult.getError() == null) {
|
||||
// If no result or error has been set, mark it as complete with an empty result
|
||||
jobResult.completeWithResult("Task completed successfully");
|
||||
}
|
||||
log.debug("Marked job ID: {} as complete", jobId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a task is complete
|
||||
*
|
||||
* @param jobId The job ID
|
||||
* @return true if the task is complete, false otherwise
|
||||
*/
|
||||
public boolean isComplete(String jobId) {
|
||||
JobResult result = jobResults.get(jobId);
|
||||
return result != null && result.isComplete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the result of a task
|
||||
*
|
||||
* @param jobId The job ID
|
||||
* @return The result object, or null if the task doesn't exist or is not complete
|
||||
*/
|
||||
public JobResult getJobResult(String jobId) {
|
||||
return jobResults.get(jobId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get statistics about all jobs in the system
|
||||
*
|
||||
* @return Job statistics
|
||||
*/
|
||||
public JobStats getJobStats() {
|
||||
int totalJobs = jobResults.size();
|
||||
int activeJobs = 0;
|
||||
int completedJobs = 0;
|
||||
int failedJobs = 0;
|
||||
int successfulJobs = 0;
|
||||
int fileResultJobs = 0;
|
||||
|
||||
LocalDateTime oldestActiveJobTime = null;
|
||||
LocalDateTime newestActiveJobTime = null;
|
||||
long totalProcessingTimeMs = 0;
|
||||
|
||||
for (JobResult result : jobResults.values()) {
|
||||
if (result.isComplete()) {
|
||||
completedJobs++;
|
||||
|
||||
// Calculate processing time for completed jobs
|
||||
if (result.getCreatedAt() != null && result.getCompletedAt() != null) {
|
||||
long processingTimeMs =
|
||||
java.time.Duration.between(
|
||||
result.getCreatedAt(), result.getCompletedAt())
|
||||
.toMillis();
|
||||
totalProcessingTimeMs += processingTimeMs;
|
||||
}
|
||||
|
||||
if (result.getError() != null) {
|
||||
failedJobs++;
|
||||
} else {
|
||||
successfulJobs++;
|
||||
if (result.getFileId() != null) {
|
||||
fileResultJobs++;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
activeJobs++;
|
||||
|
||||
// Track oldest and newest active jobs
|
||||
if (result.getCreatedAt() != null) {
|
||||
if (oldestActiveJobTime == null
|
||||
|| result.getCreatedAt().isBefore(oldestActiveJobTime)) {
|
||||
oldestActiveJobTime = result.getCreatedAt();
|
||||
}
|
||||
|
||||
if (newestActiveJobTime == null
|
||||
|| result.getCreatedAt().isAfter(newestActiveJobTime)) {
|
||||
newestActiveJobTime = result.getCreatedAt();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate average processing time
|
||||
long averageProcessingTimeMs =
|
||||
completedJobs > 0 ? totalProcessingTimeMs / completedJobs : 0;
|
||||
|
||||
return JobStats.builder()
|
||||
.totalJobs(totalJobs)
|
||||
.activeJobs(activeJobs)
|
||||
.completedJobs(completedJobs)
|
||||
.failedJobs(failedJobs)
|
||||
.successfulJobs(successfulJobs)
|
||||
.fileResultJobs(fileResultJobs)
|
||||
.oldestActiveJobTime(oldestActiveJobTime)
|
||||
.newestActiveJobTime(newestActiveJobTime)
|
||||
.averageProcessingTimeMs(averageProcessingTimeMs)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a job result
|
||||
*
|
||||
* @param jobId The job ID
|
||||
* @return The job result
|
||||
*/
|
||||
private JobResult getOrCreateJobResult(String jobId) {
|
||||
return jobResults.computeIfAbsent(jobId, JobResult::createNew);
|
||||
}
|
||||
|
||||
/** Clean up old completed job results */
|
||||
public void cleanupOldJobs() {
|
||||
LocalDateTime expiryThreshold =
|
||||
LocalDateTime.now().minus(jobResultExpiryMinutes, ChronoUnit.MINUTES);
|
||||
int removedCount = 0;
|
||||
|
||||
try {
|
||||
for (Map.Entry<String, JobResult> entry : jobResults.entrySet()) {
|
||||
JobResult result = entry.getValue();
|
||||
|
||||
// Remove completed jobs that are older than the expiry threshold
|
||||
if (result.isComplete()
|
||||
&& result.getCompletedAt() != null
|
||||
&& result.getCompletedAt().isBefore(expiryThreshold)) {
|
||||
|
||||
// If the job has a file result, delete the file
|
||||
if (result.getFileId() != null) {
|
||||
try {
|
||||
fileStorage.deleteFile(result.getFileId());
|
||||
} catch (Exception e) {
|
||||
log.warn(
|
||||
"Failed to delete file for job {}: {}",
|
||||
entry.getKey(),
|
||||
e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the job result
|
||||
jobResults.remove(entry.getKey());
|
||||
removedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (removedCount > 0) {
|
||||
log.info("Cleaned up {} expired job results", removedCount);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Error during job cleanup: {}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Shutdown the cleanup executor */
|
||||
@PreDestroy
|
||||
public void shutdown() {
|
||||
try {
|
||||
log.info("Shutting down job result cleanup executor");
|
||||
cleanupExecutor.shutdown();
|
||||
if (!cleanupExecutor.awaitTermination(5, TimeUnit.SECONDS)) {
|
||||
cleanupExecutor.shutdownNow();
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
cleanupExecutor.shutdownNow();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@Slf4j
|
||||
public class ExecutorFactory {
|
||||
|
||||
/**
|
||||
* Creates an ExecutorService using virtual threads if available (Java 21+), or falls back to a
|
||||
* cached thread pool on older Java versions.
|
||||
*/
|
||||
public static ExecutorService newVirtualOrCachedThreadExecutor() {
|
||||
try {
|
||||
ExecutorService executor =
|
||||
(ExecutorService)
|
||||
Executors.class
|
||||
.getMethod("newVirtualThreadPerTaskExecutor")
|
||||
.invoke(null);
|
||||
return executor;
|
||||
} catch (NoSuchMethodException e) {
|
||||
log.debug("Virtual threads not available; falling back to cached thread pool.");
|
||||
} catch (Exception e) {
|
||||
log.debug("Error initializing virtual thread executor: {}", e.getMessage(), e);
|
||||
}
|
||||
|
||||
return Executors.newCachedThreadPool();
|
||||
}
|
||||
}
|
||||
+214
@@ -0,0 +1,214 @@
|
||||
package stirling.software.common.annotations;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyBoolean;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import org.aspectj.lang.ProceedingJoinPoint;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Captor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import stirling.software.common.aop.AutoJobAspect;
|
||||
import stirling.software.common.controller.WebSocketProgressController;
|
||||
import stirling.software.common.model.api.PDFFile;
|
||||
import stirling.software.common.service.FileOrUploadService;
|
||||
import stirling.software.common.service.FileStorage;
|
||||
import stirling.software.common.service.JobExecutorService;
|
||||
import stirling.software.common.service.JobQueue;
|
||||
import stirling.software.common.service.ResourceMonitor;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class AutoJobPostMappingIntegrationTest {
|
||||
|
||||
private AutoJobAspect autoJobAspect;
|
||||
|
||||
@Mock
|
||||
private JobExecutorService jobExecutorService;
|
||||
|
||||
@Mock
|
||||
private HttpServletRequest request;
|
||||
|
||||
@Mock
|
||||
private FileOrUploadService fileOrUploadService;
|
||||
|
||||
@Mock
|
||||
private FileStorage fileStorage;
|
||||
|
||||
@Mock
|
||||
private WebSocketProgressController webSocketSender;
|
||||
|
||||
@Mock
|
||||
private ResourceMonitor resourceMonitor;
|
||||
|
||||
@Mock
|
||||
private JobQueue jobQueue;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
autoJobAspect = new AutoJobAspect(
|
||||
jobExecutorService,
|
||||
request,
|
||||
fileOrUploadService,
|
||||
fileStorage,
|
||||
webSocketSender
|
||||
);
|
||||
}
|
||||
|
||||
@Mock
|
||||
private ProceedingJoinPoint joinPoint;
|
||||
|
||||
@Mock
|
||||
private AutoJobPostMapping autoJobPostMapping;
|
||||
|
||||
@Captor
|
||||
private ArgumentCaptor<Supplier<Object>> workCaptor;
|
||||
|
||||
@Captor
|
||||
private ArgumentCaptor<Boolean> asyncCaptor;
|
||||
|
||||
@Captor
|
||||
private ArgumentCaptor<Long> timeoutCaptor;
|
||||
|
||||
@Captor
|
||||
private ArgumentCaptor<Boolean> queueableCaptor;
|
||||
|
||||
@Captor
|
||||
private ArgumentCaptor<Integer> resourceWeightCaptor;
|
||||
|
||||
@Test
|
||||
void shouldExecuteWithCustomParameters() throws Throwable {
|
||||
// Given
|
||||
PDFFile pdfFile = new PDFFile();
|
||||
pdfFile.setFileId("test-file-id");
|
||||
Object[] args = new Object[] { pdfFile };
|
||||
|
||||
when(joinPoint.getArgs()).thenReturn(args);
|
||||
when(request.getParameter("async")).thenReturn("true");
|
||||
when(autoJobPostMapping.timeout()).thenReturn(60000L);
|
||||
when(autoJobPostMapping.retryCount()).thenReturn(3);
|
||||
when(autoJobPostMapping.trackProgress()).thenReturn(true);
|
||||
when(autoJobPostMapping.queueable()).thenReturn(true);
|
||||
when(autoJobPostMapping.resourceWeight()).thenReturn(75);
|
||||
|
||||
MultipartFile mockFile = mock(MultipartFile.class);
|
||||
when(fileStorage.retrieveFile("test-file-id")).thenReturn(mockFile);
|
||||
|
||||
|
||||
when(jobExecutorService.runJobGeneric(
|
||||
anyBoolean(), any(Supplier.class), anyLong(), anyBoolean(), anyInt()))
|
||||
.thenReturn(ResponseEntity.ok("success"));
|
||||
|
||||
// When
|
||||
Object result = autoJobAspect.wrapWithJobExecution(joinPoint, autoJobPostMapping);
|
||||
|
||||
// Then
|
||||
assertEquals(ResponseEntity.ok("success"), result);
|
||||
|
||||
verify(jobExecutorService).runJobGeneric(
|
||||
asyncCaptor.capture(),
|
||||
workCaptor.capture(),
|
||||
timeoutCaptor.capture(),
|
||||
queueableCaptor.capture(),
|
||||
resourceWeightCaptor.capture());
|
||||
|
||||
assertTrue(asyncCaptor.getValue(), "Async should be true");
|
||||
assertEquals(60000L, timeoutCaptor.getValue(), "Timeout should be 60000ms");
|
||||
assertTrue(queueableCaptor.getValue(), "Queueable should be true");
|
||||
assertEquals(75, resourceWeightCaptor.getValue(), "Resource weight should be 75");
|
||||
|
||||
// Test that file was resolved
|
||||
assertNotNull(pdfFile.getFileInput(), "File input should be set");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRetryOnError() throws Throwable {
|
||||
// Given
|
||||
when(joinPoint.getArgs()).thenReturn(new Object[0]);
|
||||
when(request.getParameter("async")).thenReturn("false");
|
||||
when(autoJobPostMapping.timeout()).thenReturn(-1L);
|
||||
when(autoJobPostMapping.retryCount()).thenReturn(2);
|
||||
when(autoJobPostMapping.trackProgress()).thenReturn(false);
|
||||
when(autoJobPostMapping.queueable()).thenReturn(false);
|
||||
when(autoJobPostMapping.resourceWeight()).thenReturn(50);
|
||||
|
||||
// First call throws exception, second succeeds
|
||||
when(joinPoint.proceed(any()))
|
||||
.thenThrow(new RuntimeException("First attempt failed"))
|
||||
.thenReturn(ResponseEntity.ok("retry succeeded"));
|
||||
|
||||
// Mock jobExecutorService to execute the work immediately
|
||||
when(jobExecutorService.runJobGeneric(
|
||||
anyBoolean(), any(Supplier.class), anyLong(), anyBoolean(), anyInt()))
|
||||
.thenAnswer(invocation -> {
|
||||
Supplier<Object> work = invocation.getArgument(1);
|
||||
return work.get();
|
||||
});
|
||||
|
||||
// When
|
||||
Object result = autoJobAspect.wrapWithJobExecution(joinPoint, autoJobPostMapping);
|
||||
|
||||
// Then
|
||||
assertEquals(ResponseEntity.ok("retry succeeded"), result);
|
||||
|
||||
// Verify that proceed was called twice (initial attempt + 1 retry)
|
||||
verify(joinPoint, times(2)).proceed(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandlePDFFileWithAsyncRequests() throws Throwable {
|
||||
// Given
|
||||
PDFFile pdfFile = new PDFFile();
|
||||
pdfFile.setFileInput(mock(MultipartFile.class));
|
||||
Object[] args = new Object[] { pdfFile };
|
||||
|
||||
when(joinPoint.getArgs()).thenReturn(args);
|
||||
when(request.getParameter("async")).thenReturn("true");
|
||||
when(autoJobPostMapping.retryCount()).thenReturn(1);
|
||||
|
||||
when(fileStorage.storeFile(any(MultipartFile.class))).thenReturn("stored-file-id");
|
||||
when(fileStorage.retrieveFile("stored-file-id")).thenReturn(mock(MultipartFile.class));
|
||||
|
||||
// Mock job executor to return a successful response
|
||||
when(jobExecutorService.runJobGeneric(
|
||||
anyBoolean(), any(Supplier.class), anyLong(), anyBoolean(), anyInt()))
|
||||
.thenReturn(ResponseEntity.ok("success"));
|
||||
|
||||
// When
|
||||
autoJobAspect.wrapWithJobExecution(joinPoint, autoJobPostMapping);
|
||||
|
||||
// Then
|
||||
assertEquals("stored-file-id", pdfFile.getFileId(),
|
||||
"FileId should be set to the stored file id");
|
||||
assertNotNull(pdfFile.getFileInput(), "FileInput should be replaced with persistent file");
|
||||
|
||||
// Verify storage operations
|
||||
verify(fileStorage).storeFile(any(MultipartFile.class));
|
||||
verify(fileStorage).retrieveFile("stored-file-id");
|
||||
}
|
||||
}
|
||||
|
||||
// Move PDFFileTest to its own class file if needed
|
||||
@@ -0,0 +1,227 @@
|
||||
package stirling.software.common.controller;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockitoAnnotations;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import stirling.software.common.model.job.JobResult;
|
||||
import stirling.software.common.model.job.JobStats;
|
||||
import stirling.software.common.service.FileStorage;
|
||||
import stirling.software.common.service.TaskManager;
|
||||
|
||||
class JobControllerTest {
|
||||
|
||||
@Mock
|
||||
private TaskManager taskManager;
|
||||
|
||||
@Mock
|
||||
private FileStorage fileStorage;
|
||||
|
||||
@InjectMocks
|
||||
private JobController controller;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
MockitoAnnotations.openMocks(this);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetJobStatus_ExistingJob() {
|
||||
// Arrange
|
||||
String jobId = "test-job-id";
|
||||
JobResult mockResult = new JobResult();
|
||||
mockResult.setJobId(jobId);
|
||||
when(taskManager.getJobResult(jobId)).thenReturn(mockResult);
|
||||
|
||||
// Act
|
||||
ResponseEntity<?> response = controller.getJobStatus(jobId);
|
||||
|
||||
// Assert
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
assertEquals(mockResult, response.getBody());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetJobStatus_NonExistentJob() {
|
||||
// Arrange
|
||||
String jobId = "non-existent-job";
|
||||
when(taskManager.getJobResult(jobId)).thenReturn(null);
|
||||
|
||||
// Act
|
||||
ResponseEntity<?> response = controller.getJobStatus(jobId);
|
||||
|
||||
// Assert
|
||||
assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetJobResult_CompletedSuccessfulWithObject() {
|
||||
// Arrange
|
||||
String jobId = "test-job-id";
|
||||
JobResult mockResult = new JobResult();
|
||||
mockResult.setJobId(jobId);
|
||||
mockResult.setComplete(true);
|
||||
String resultObject = "Test result";
|
||||
mockResult.completeWithResult(resultObject);
|
||||
|
||||
when(taskManager.getJobResult(jobId)).thenReturn(mockResult);
|
||||
|
||||
// Act
|
||||
ResponseEntity<?> response = controller.getJobResult(jobId);
|
||||
|
||||
// Assert
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
assertEquals(resultObject, response.getBody());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetJobResult_CompletedSuccessfulWithFile() throws Exception {
|
||||
// Arrange
|
||||
String jobId = "test-job-id";
|
||||
String fileId = "file-id";
|
||||
String originalFileName = "test.pdf";
|
||||
String contentType = "application/pdf";
|
||||
byte[] fileContent = "Test file content".getBytes();
|
||||
|
||||
JobResult mockResult = new JobResult();
|
||||
mockResult.setJobId(jobId);
|
||||
mockResult.completeWithFile(fileId, originalFileName, contentType);
|
||||
|
||||
when(taskManager.getJobResult(jobId)).thenReturn(mockResult);
|
||||
when(fileStorage.retrieveBytes(fileId)).thenReturn(fileContent);
|
||||
|
||||
// Act
|
||||
ResponseEntity<?> response = controller.getJobResult(jobId);
|
||||
|
||||
// Assert
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
assertEquals(contentType, response.getHeaders().getFirst("Content-Type"));
|
||||
assertTrue(response.getHeaders().getFirst("Content-Disposition").contains(originalFileName));
|
||||
assertEquals(fileContent, response.getBody());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetJobResult_CompletedWithError() {
|
||||
// Arrange
|
||||
String jobId = "test-job-id";
|
||||
String errorMessage = "Test error";
|
||||
|
||||
JobResult mockResult = new JobResult();
|
||||
mockResult.setJobId(jobId);
|
||||
mockResult.failWithError(errorMessage);
|
||||
|
||||
when(taskManager.getJobResult(jobId)).thenReturn(mockResult);
|
||||
|
||||
// Act
|
||||
ResponseEntity<?> response = controller.getJobResult(jobId);
|
||||
|
||||
// Assert
|
||||
assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
|
||||
assertTrue(response.getBody().toString().contains(errorMessage));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetJobResult_IncompleteJob() {
|
||||
// Arrange
|
||||
String jobId = "test-job-id";
|
||||
|
||||
JobResult mockResult = new JobResult();
|
||||
mockResult.setJobId(jobId);
|
||||
mockResult.setComplete(false);
|
||||
|
||||
when(taskManager.getJobResult(jobId)).thenReturn(mockResult);
|
||||
|
||||
// Act
|
||||
ResponseEntity<?> response = controller.getJobResult(jobId);
|
||||
|
||||
// Assert
|
||||
assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
|
||||
assertTrue(response.getBody().toString().contains("not complete"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetJobResult_NonExistentJob() {
|
||||
// Arrange
|
||||
String jobId = "non-existent-job";
|
||||
when(taskManager.getJobResult(jobId)).thenReturn(null);
|
||||
|
||||
// Act
|
||||
ResponseEntity<?> response = controller.getJobResult(jobId);
|
||||
|
||||
// Assert
|
||||
assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetJobResult_ErrorRetrievingFile() throws Exception {
|
||||
// Arrange
|
||||
String jobId = "test-job-id";
|
||||
String fileId = "file-id";
|
||||
String originalFileName = "test.pdf";
|
||||
String contentType = "application/pdf";
|
||||
|
||||
JobResult mockResult = new JobResult();
|
||||
mockResult.setJobId(jobId);
|
||||
mockResult.completeWithFile(fileId, originalFileName, contentType);
|
||||
|
||||
when(taskManager.getJobResult(jobId)).thenReturn(mockResult);
|
||||
when(fileStorage.retrieveBytes(fileId)).thenThrow(new RuntimeException("File not found"));
|
||||
|
||||
// Act
|
||||
ResponseEntity<?> response = controller.getJobResult(jobId);
|
||||
|
||||
// Assert
|
||||
assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode());
|
||||
assertTrue(response.getBody().toString().contains("Error retrieving file"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetJobStats() {
|
||||
// Arrange
|
||||
JobStats mockStats = JobStats.builder()
|
||||
.totalJobs(10)
|
||||
.activeJobs(3)
|
||||
.completedJobs(7)
|
||||
.build();
|
||||
|
||||
when(taskManager.getJobStats()).thenReturn(mockStats);
|
||||
|
||||
// Act
|
||||
ResponseEntity<JobStats> response = controller.getJobStats();
|
||||
|
||||
// Assert
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
assertEquals(mockStats, response.getBody());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCleanupOldJobs() {
|
||||
// Arrange
|
||||
when(taskManager.getJobStats())
|
||||
.thenReturn(JobStats.builder().totalJobs(10).build())
|
||||
.thenReturn(JobStats.builder().totalJobs(7).build());
|
||||
|
||||
// Act
|
||||
ResponseEntity<?> response = controller.cleanupOldJobs();
|
||||
|
||||
// Assert
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> responseBody = (Map<String, Object>) response.getBody();
|
||||
assertEquals("Cleanup complete", responseBody.get("message"));
|
||||
assertEquals(3, responseBody.get("removedJobs"));
|
||||
assertEquals(7, responseBody.get("remainingJobs"));
|
||||
|
||||
verify(taskManager).cleanupOldJobs();
|
||||
}
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
package stirling.software.common.controller;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockitoAnnotations;
|
||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||
|
||||
import stirling.software.common.model.job.JobProgress;
|
||||
|
||||
class WebSocketProgressControllerTest {
|
||||
|
||||
@Mock
|
||||
private SimpMessagingTemplate messagingTemplate;
|
||||
|
||||
@InjectMocks
|
||||
private WebSocketProgressController controller;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
MockitoAnnotations.openMocks(this);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSendProgress_WithMessagingTemplate() {
|
||||
// Arrange
|
||||
String jobId = "test-job-id";
|
||||
JobProgress progress = new JobProgress(jobId, "In Progress", 50, "Processing");
|
||||
|
||||
// Act
|
||||
controller.sendProgress(jobId, progress);
|
||||
|
||||
// Assert
|
||||
verify(messagingTemplate).convertAndSend("/topic/progress/" + jobId, progress);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSendProgress_WithNullMessagingTemplate() {
|
||||
// Arrange
|
||||
WebSocketProgressController controllerWithNullTemplate = new WebSocketProgressController();
|
||||
String jobId = "test-job-id";
|
||||
JobProgress progress = new JobProgress(jobId, "In Progress", 50, "Processing");
|
||||
|
||||
// Act - should not throw exception even with null template
|
||||
controllerWithNullTemplate.sendProgress(jobId, progress);
|
||||
|
||||
// No assertion needed - test passes if no exception is thrown
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSetMessagingTemplate() {
|
||||
// Arrange
|
||||
WebSocketProgressController newController = new WebSocketProgressController();
|
||||
SimpMessagingTemplate newTemplate = mock(SimpMessagingTemplate.class);
|
||||
|
||||
// Act
|
||||
newController.setMessagingTemplate(newTemplate);
|
||||
String jobId = "test-job-id";
|
||||
JobProgress progress = new JobProgress(jobId, "In Progress", 50, "Processing");
|
||||
newController.sendProgress(jobId, progress);
|
||||
|
||||
// Assert
|
||||
verify(newTemplate).convertAndSend("/topic/progress/" + jobId, progress);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.mockito.AdditionalAnswers.*;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockitoAnnotations;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
class FileStorageTest {
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
@Mock
|
||||
private FileOrUploadService fileOrUploadService;
|
||||
|
||||
@InjectMocks
|
||||
private FileStorage fileStorage;
|
||||
|
||||
private MultipartFile mockFile;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
MockitoAnnotations.openMocks(this);
|
||||
ReflectionTestUtils.setField(fileStorage, "tempDirPath", tempDir.toString());
|
||||
|
||||
// Create a mock MultipartFile
|
||||
mockFile = mock(MultipartFile.class);
|
||||
when(mockFile.getOriginalFilename()).thenReturn("test.pdf");
|
||||
when(mockFile.getContentType()).thenReturn("application/pdf");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testStoreFile() throws IOException {
|
||||
// Arrange
|
||||
byte[] fileContent = "Test PDF content".getBytes();
|
||||
when(mockFile.getBytes()).thenReturn(fileContent);
|
||||
|
||||
// Set up mock to handle transferTo by writing the file
|
||||
doAnswer(invocation -> {
|
||||
java.io.File file = invocation.getArgument(0);
|
||||
Files.write(file.toPath(), fileContent);
|
||||
return null;
|
||||
}).when(mockFile).transferTo(any(java.io.File.class));
|
||||
|
||||
// Act
|
||||
String fileId = fileStorage.storeFile(mockFile);
|
||||
|
||||
// Assert
|
||||
assertNotNull(fileId);
|
||||
assertTrue(Files.exists(tempDir.resolve(fileId)));
|
||||
verify(mockFile).transferTo(any(java.io.File.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testStoreBytes() throws IOException {
|
||||
// Arrange
|
||||
byte[] fileContent = "Test PDF content".getBytes();
|
||||
String originalName = "test.pdf";
|
||||
|
||||
// Act
|
||||
String fileId = fileStorage.storeBytes(fileContent, originalName);
|
||||
|
||||
// Assert
|
||||
assertNotNull(fileId);
|
||||
assertTrue(Files.exists(tempDir.resolve(fileId)));
|
||||
assertArrayEquals(fileContent, Files.readAllBytes(tempDir.resolve(fileId)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRetrieveFile() throws IOException {
|
||||
// Arrange
|
||||
byte[] fileContent = "Test PDF content".getBytes();
|
||||
String fileId = UUID.randomUUID().toString();
|
||||
Path filePath = tempDir.resolve(fileId);
|
||||
Files.write(filePath, fileContent);
|
||||
|
||||
MultipartFile expectedFile = mock(MultipartFile.class);
|
||||
when(fileOrUploadService.toMockMultipartFile(eq(fileId), eq(fileContent)))
|
||||
.thenReturn(expectedFile);
|
||||
|
||||
// Act
|
||||
MultipartFile result = fileStorage.retrieveFile(fileId);
|
||||
|
||||
// Assert
|
||||
assertSame(expectedFile, result);
|
||||
verify(fileOrUploadService).toMockMultipartFile(eq(fileId), eq(fileContent));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRetrieveBytes() throws IOException {
|
||||
// Arrange
|
||||
byte[] fileContent = "Test PDF content".getBytes();
|
||||
String fileId = UUID.randomUUID().toString();
|
||||
Path filePath = tempDir.resolve(fileId);
|
||||
Files.write(filePath, fileContent);
|
||||
|
||||
// Act
|
||||
byte[] result = fileStorage.retrieveBytes(fileId);
|
||||
|
||||
// Assert
|
||||
assertArrayEquals(fileContent, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRetrieveFile_FileNotFound() {
|
||||
// Arrange
|
||||
String nonExistentFileId = "non-existent-file";
|
||||
|
||||
// Act & Assert
|
||||
assertThrows(IOException.class, () -> fileStorage.retrieveFile(nonExistentFileId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRetrieveBytes_FileNotFound() {
|
||||
// Arrange
|
||||
String nonExistentFileId = "non-existent-file";
|
||||
|
||||
// Act & Assert
|
||||
assertThrows(IOException.class, () -> fileStorage.retrieveBytes(nonExistentFileId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDeleteFile() throws IOException {
|
||||
// Arrange
|
||||
byte[] fileContent = "Test PDF content".getBytes();
|
||||
String fileId = UUID.randomUUID().toString();
|
||||
Path filePath = tempDir.resolve(fileId);
|
||||
Files.write(filePath, fileContent);
|
||||
|
||||
// Act
|
||||
boolean result = fileStorage.deleteFile(fileId);
|
||||
|
||||
// Assert
|
||||
assertTrue(result);
|
||||
assertFalse(Files.exists(filePath));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDeleteFile_FileNotFound() {
|
||||
// Arrange
|
||||
String nonExistentFileId = "non-existent-file";
|
||||
|
||||
// Act
|
||||
boolean result = fileStorage.deleteFile(nonExistentFileId);
|
||||
|
||||
// Assert
|
||||
assertFalse(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFileExists() throws IOException {
|
||||
// Arrange
|
||||
byte[] fileContent = "Test PDF content".getBytes();
|
||||
String fileId = UUID.randomUUID().toString();
|
||||
Path filePath = tempDir.resolve(fileId);
|
||||
Files.write(filePath, fileContent);
|
||||
|
||||
// Act
|
||||
boolean result = fileStorage.fileExists(fileId);
|
||||
|
||||
// Assert
|
||||
assertTrue(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFileExists_FileNotFound() {
|
||||
// Arrange
|
||||
String nonExistentFileId = "non-existent-file";
|
||||
|
||||
// Act
|
||||
boolean result = fileStorage.fileExists(nonExistentFileId);
|
||||
|
||||
// Assert
|
||||
assertFalse(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Captor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.Mockito;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import stirling.software.common.controller.WebSocketProgressController;
|
||||
import stirling.software.common.model.job.JobProgress;
|
||||
import stirling.software.common.model.job.JobResponse;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class JobExecutorServiceTest {
|
||||
|
||||
private JobExecutorService jobExecutorService;
|
||||
|
||||
@Mock
|
||||
private TaskManager taskManager;
|
||||
|
||||
@Mock
|
||||
private WebSocketProgressController webSocketSender;
|
||||
|
||||
@Mock
|
||||
private FileStorage fileStorage;
|
||||
|
||||
@Mock
|
||||
private HttpServletRequest request;
|
||||
|
||||
@Mock
|
||||
private ResourceMonitor resourceMonitor;
|
||||
|
||||
@Mock
|
||||
private JobQueue jobQueue;
|
||||
|
||||
@Captor
|
||||
private ArgumentCaptor<String> jobIdCaptor;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
// Initialize the service manually with all its dependencies
|
||||
jobExecutorService = new JobExecutorService(
|
||||
taskManager,
|
||||
webSocketSender,
|
||||
fileStorage,
|
||||
request,
|
||||
resourceMonitor,
|
||||
jobQueue,
|
||||
30000L, // asyncRequestTimeoutMs
|
||||
"30m" // sessionTimeout
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRunSyncJobSuccessfully() throws Exception {
|
||||
// Given
|
||||
Supplier<Object> work = () -> "test-result";
|
||||
|
||||
// When
|
||||
ResponseEntity<?> response = jobExecutorService.runJobGeneric(false, work);
|
||||
|
||||
// Then
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
assertEquals("test-result", response.getBody());
|
||||
|
||||
// Verify request attribute was set with jobId
|
||||
verify(request).setAttribute(eq("jobId"), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRunAsyncJobSuccessfully() throws Exception {
|
||||
// Given
|
||||
Supplier<Object> work = () -> "test-result";
|
||||
|
||||
// When
|
||||
ResponseEntity<?> response = jobExecutorService.runJobGeneric(true, work);
|
||||
|
||||
// Then
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
assertTrue(response.getBody() instanceof JobResponse);
|
||||
JobResponse<?> jobResponse = (JobResponse<?>) response.getBody();
|
||||
assertTrue(jobResponse.isAsync());
|
||||
assertNotNull(jobResponse.getJobId());
|
||||
|
||||
// Verify task manager was called
|
||||
verify(taskManager).createTask(jobIdCaptor.capture());
|
||||
verify(webSocketSender, Mockito.atLeastOnce()).sendProgress(eq(jobIdCaptor.getValue()), any(JobProgress.class));
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
void shouldHandleSyncJobError() {
|
||||
// Given
|
||||
Supplier<Object> work = () -> {
|
||||
throw new RuntimeException("Test error");
|
||||
};
|
||||
|
||||
// When
|
||||
ResponseEntity<?> response = jobExecutorService.runJobGeneric(false, work);
|
||||
|
||||
// Then
|
||||
assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode());
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, String> errorMap = (Map<String, String>) response.getBody();
|
||||
assertEquals("Job failed: Test error", errorMap.get("error"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldQueueJobWhenResourcesLimited() {
|
||||
// Given
|
||||
Supplier<Object> work = () -> "test-result";
|
||||
CompletableFuture<ResponseEntity<?>> future = new CompletableFuture<>();
|
||||
|
||||
// Configure resourceMonitor to indicate job should be queued
|
||||
when(resourceMonitor.shouldQueueJob(80)).thenReturn(true);
|
||||
|
||||
// Configure jobQueue to return our future
|
||||
when(jobQueue.queueJob(anyString(), eq(80), any(), anyLong())).thenReturn(future);
|
||||
|
||||
// When
|
||||
ResponseEntity<?> response = jobExecutorService.runJobGeneric(
|
||||
true, work, 5000, true, 80);
|
||||
|
||||
// Then
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
assertTrue(response.getBody() instanceof JobResponse);
|
||||
|
||||
// Verify job was queued
|
||||
verify(jobQueue).queueJob(anyString(), eq(80), any(), eq(5000L));
|
||||
verify(taskManager).createTask(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldUseCustomTimeoutWhenProvided() throws Exception {
|
||||
// Given
|
||||
Supplier<Object> work = () -> "test-result";
|
||||
long customTimeout = 60000L;
|
||||
|
||||
// Use reflection to access the private executeWithTimeout method
|
||||
java.lang.reflect.Method executeMethod = JobExecutorService.class
|
||||
.getDeclaredMethod("executeWithTimeout", Supplier.class, long.class);
|
||||
executeMethod.setAccessible(true);
|
||||
|
||||
// Create a spy on the JobExecutorService to verify method calls
|
||||
JobExecutorService spy = Mockito.spy(jobExecutorService);
|
||||
|
||||
// When
|
||||
spy.runJobGeneric(false, work, customTimeout);
|
||||
|
||||
// Then
|
||||
verify(spy).runJobGeneric(eq(false), any(Supplier.class), eq(customTimeout));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleTimeout() throws Exception {
|
||||
// Given
|
||||
Supplier<Object> work = () -> {
|
||||
try {
|
||||
Thread.sleep(100); // Simulate long-running job
|
||||
return "test-result";
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
};
|
||||
|
||||
// Use reflection to access the private executeWithTimeout method
|
||||
java.lang.reflect.Method executeMethod = JobExecutorService.class
|
||||
.getDeclaredMethod("executeWithTimeout", Supplier.class, long.class);
|
||||
executeMethod.setAccessible(true);
|
||||
|
||||
// When/Then
|
||||
try {
|
||||
executeMethod.invoke(jobExecutorService, work, 1L); // Very short timeout
|
||||
} catch (Exception e) {
|
||||
assertTrue(e.getCause() instanceof TimeoutException);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import stirling.software.common.controller.WebSocketProgressController;
|
||||
import stirling.software.common.model.job.JobProgress;
|
||||
import stirling.software.common.service.ResourceMonitor.ResourceStatus;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class JobQueueTest {
|
||||
|
||||
private JobQueue jobQueue;
|
||||
|
||||
@Mock
|
||||
private ResourceMonitor resourceMonitor;
|
||||
|
||||
@Mock
|
||||
private WebSocketProgressController webSocketSender;
|
||||
|
||||
private final AtomicReference<ResourceStatus> statusRef = new AtomicReference<>(ResourceStatus.OK);
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
// Mark stubbing as lenient to avoid UnnecessaryStubbingException
|
||||
lenient().when(resourceMonitor.calculateDynamicQueueCapacity(anyInt(), anyInt())).thenReturn(10);
|
||||
lenient().when(resourceMonitor.getCurrentStatus()).thenReturn(statusRef);
|
||||
|
||||
jobQueue = new JobQueue(resourceMonitor, webSocketSender);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldQueueJob() {
|
||||
String jobId = "test-job-1";
|
||||
int resourceWeight = 50;
|
||||
Supplier<Object> work = () -> "test-result";
|
||||
long timeoutMs = 1000;
|
||||
|
||||
jobQueue.queueJob(jobId, resourceWeight, work, timeoutMs);
|
||||
|
||||
verify(webSocketSender).sendProgress(
|
||||
org.mockito.ArgumentMatchers.eq(jobId),
|
||||
org.mockito.ArgumentMatchers.any(JobProgress.class));
|
||||
|
||||
assertTrue(jobQueue.isJobQueued(jobId));
|
||||
assertEquals(1, jobQueue.getTotalQueuedJobs());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCancelJob() {
|
||||
String jobId = "test-job-2";
|
||||
Supplier<Object> work = () -> "test-result";
|
||||
|
||||
jobQueue.queueJob(jobId, 50, work, 1000);
|
||||
boolean cancelled = jobQueue.cancelJob(jobId);
|
||||
|
||||
assertTrue(cancelled);
|
||||
assertFalse(jobQueue.isJobQueued(jobId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldGetQueueStats() {
|
||||
when(resourceMonitor.getCurrentStatus()).thenReturn(statusRef);
|
||||
|
||||
jobQueue.queueJob("job1", 50, () -> "ok", 1000);
|
||||
jobQueue.queueJob("job2", 50, () -> "ok", 1000);
|
||||
jobQueue.cancelJob("job2");
|
||||
|
||||
Map<String, Object> stats = jobQueue.getQueueStats();
|
||||
|
||||
assertEquals(2, stats.get("totalQueuedJobs"));
|
||||
assertTrue(stats.containsKey("queuedJobs"));
|
||||
assertTrue(stats.containsKey("resourceStatus"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCalculateQueueCapacity() {
|
||||
when(resourceMonitor.calculateDynamicQueueCapacity(5, 2)).thenReturn(8);
|
||||
int capacity = resourceMonitor.calculateDynamicQueueCapacity(5, 2);
|
||||
assertEquals(8, capacity);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCheckIfJobIsQueued() {
|
||||
String jobId = "job-123";
|
||||
Supplier<Object> work = () -> "hello";
|
||||
|
||||
jobQueue.queueJob(jobId, 40, work, 500);
|
||||
|
||||
assertTrue(jobQueue.isJobQueued(jobId));
|
||||
assertFalse(jobQueue.isJobQueued("nonexistent"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.lang.management.MemoryMXBean;
|
||||
import java.lang.management.MemoryUsage;
|
||||
import java.lang.management.OperatingSystemMXBean;
|
||||
import java.time.Instant;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.Spy;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import stirling.software.common.service.ResourceMonitor.ResourceMetrics;
|
||||
import stirling.software.common.service.ResourceMonitor.ResourceStatus;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ResourceMonitorTest {
|
||||
|
||||
@InjectMocks
|
||||
private ResourceMonitor resourceMonitor;
|
||||
|
||||
@Mock
|
||||
private OperatingSystemMXBean osMXBean;
|
||||
|
||||
@Mock
|
||||
private MemoryMXBean memoryMXBean;
|
||||
|
||||
@Spy
|
||||
private AtomicReference<ResourceStatus> currentStatus = new AtomicReference<>(ResourceStatus.OK);
|
||||
|
||||
@Spy
|
||||
private AtomicReference<ResourceMetrics> latestMetrics = new AtomicReference<>(new ResourceMetrics());
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
// Set thresholds for testing
|
||||
ReflectionTestUtils.setField(resourceMonitor, "memoryCriticalThreshold", 0.9);
|
||||
ReflectionTestUtils.setField(resourceMonitor, "memoryHighThreshold", 0.75);
|
||||
ReflectionTestUtils.setField(resourceMonitor, "cpuCriticalThreshold", 0.9);
|
||||
ReflectionTestUtils.setField(resourceMonitor, "cpuHighThreshold", 0.75);
|
||||
ReflectionTestUtils.setField(resourceMonitor, "osMXBean", osMXBean);
|
||||
ReflectionTestUtils.setField(resourceMonitor, "memoryMXBean", memoryMXBean);
|
||||
ReflectionTestUtils.setField(resourceMonitor, "currentStatus", currentStatus);
|
||||
ReflectionTestUtils.setField(resourceMonitor, "latestMetrics", latestMetrics);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCalculateDynamicQueueCapacity() {
|
||||
// Given
|
||||
int baseCapacity = 10;
|
||||
int minCapacity = 2;
|
||||
|
||||
// Mock current status as OK
|
||||
currentStatus.set(ResourceStatus.OK);
|
||||
|
||||
// When
|
||||
int capacity = resourceMonitor.calculateDynamicQueueCapacity(baseCapacity, minCapacity);
|
||||
|
||||
// Then
|
||||
assertEquals(baseCapacity, capacity, "With OK status, capacity should equal base capacity");
|
||||
|
||||
// Given
|
||||
currentStatus.set(ResourceStatus.WARNING);
|
||||
|
||||
// When
|
||||
capacity = resourceMonitor.calculateDynamicQueueCapacity(baseCapacity, minCapacity);
|
||||
|
||||
// Then
|
||||
assertEquals(6, capacity, "With WARNING status, capacity should be reduced to 60%");
|
||||
|
||||
// Given
|
||||
currentStatus.set(ResourceStatus.CRITICAL);
|
||||
|
||||
// When
|
||||
capacity = resourceMonitor.calculateDynamicQueueCapacity(baseCapacity, minCapacity);
|
||||
|
||||
// Then
|
||||
assertEquals(3, capacity, "With CRITICAL status, capacity should be reduced to 30%");
|
||||
|
||||
// Test minimum capacity enforcement
|
||||
assertEquals(minCapacity, resourceMonitor.calculateDynamicQueueCapacity(1, minCapacity),
|
||||
"Should never go below minimum capacity");
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource({
|
||||
"10, OK, false", // Light job, OK status
|
||||
"10, WARNING, false", // Light job, WARNING status
|
||||
"10, CRITICAL, true", // Light job, CRITICAL status
|
||||
"30, OK, false", // Medium job, OK status
|
||||
"30, WARNING, true", // Medium job, WARNING status
|
||||
"30, CRITICAL, true", // Medium job, CRITICAL status
|
||||
"80, OK, true", // Heavy job, OK status
|
||||
"80, WARNING, true", // Heavy job, WARNING status
|
||||
"80, CRITICAL, true" // Heavy job, CRITICAL status
|
||||
})
|
||||
void shouldQueueJobBasedOnWeightAndStatus(int weight, ResourceStatus status, boolean shouldQueue) {
|
||||
// Given
|
||||
currentStatus.set(status);
|
||||
|
||||
// When
|
||||
boolean result = resourceMonitor.shouldQueueJob(weight);
|
||||
|
||||
// Then
|
||||
assertEquals(shouldQueue, result,
|
||||
String.format("For weight %d and status %s, shouldQueue should be %s",
|
||||
weight, status, shouldQueue));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resourceMetricsShouldDetectStaleState() {
|
||||
// Given
|
||||
Instant now = Instant.now();
|
||||
Instant pastInstant = now.minusMillis(6000);
|
||||
|
||||
ResourceMetrics staleMetrics = new ResourceMetrics(0.5, 0.5, 1024, 2048, 4096, pastInstant);
|
||||
ResourceMetrics freshMetrics = new ResourceMetrics(0.5, 0.5, 1024, 2048, 4096, now);
|
||||
|
||||
// When/Then
|
||||
assertTrue(staleMetrics.isStale(5000), "Metrics from 6 seconds ago should be stale with 5s threshold");
|
||||
assertFalse(freshMetrics.isStale(5000), "Fresh metrics should not be stale");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockitoAnnotations;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import stirling.software.common.model.job.JobResult;
|
||||
import stirling.software.common.model.job.JobStats;
|
||||
|
||||
class TaskManagerTest {
|
||||
|
||||
@Mock
|
||||
private FileStorage fileStorage;
|
||||
|
||||
@InjectMocks
|
||||
private TaskManager taskManager;
|
||||
|
||||
private AutoCloseable closeable;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
closeable = MockitoAnnotations.openMocks(this);
|
||||
ReflectionTestUtils.setField(taskManager, "jobResultExpiryMinutes", 30);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() throws Exception {
|
||||
closeable.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCreateTask() {
|
||||
// Act
|
||||
String jobId = UUID.randomUUID().toString();
|
||||
taskManager.createTask(jobId);
|
||||
|
||||
// Assert
|
||||
JobResult result = taskManager.getJobResult(jobId);
|
||||
assertNotNull(result);
|
||||
assertEquals(jobId, result.getJobId());
|
||||
assertFalse(result.isComplete());
|
||||
assertNotNull(result.getCreatedAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSetResult() {
|
||||
// Arrange
|
||||
String jobId = UUID.randomUUID().toString();
|
||||
taskManager.createTask(jobId);
|
||||
Object resultObject = "Test result";
|
||||
|
||||
// Act
|
||||
taskManager.setResult(jobId, resultObject);
|
||||
|
||||
// Assert
|
||||
JobResult result = taskManager.getJobResult(jobId);
|
||||
assertNotNull(result);
|
||||
assertTrue(result.isComplete());
|
||||
assertEquals(resultObject, result.getResult());
|
||||
assertNotNull(result.getCompletedAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSetFileResult() {
|
||||
// Arrange
|
||||
String jobId = UUID.randomUUID().toString();
|
||||
taskManager.createTask(jobId);
|
||||
String fileId = "file-id";
|
||||
String originalFileName = "test.pdf";
|
||||
String contentType = "application/pdf";
|
||||
|
||||
// Act
|
||||
taskManager.setFileResult(jobId, fileId, originalFileName, contentType);
|
||||
|
||||
// Assert
|
||||
JobResult result = taskManager.getJobResult(jobId);
|
||||
assertNotNull(result);
|
||||
assertTrue(result.isComplete());
|
||||
assertEquals(fileId, result.getFileId());
|
||||
assertEquals(originalFileName, result.getOriginalFileName());
|
||||
assertEquals(contentType, result.getContentType());
|
||||
assertNotNull(result.getCompletedAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSetError() {
|
||||
// Arrange
|
||||
String jobId = UUID.randomUUID().toString();
|
||||
taskManager.createTask(jobId);
|
||||
String errorMessage = "Test error";
|
||||
|
||||
// Act
|
||||
taskManager.setError(jobId, errorMessage);
|
||||
|
||||
// Assert
|
||||
JobResult result = taskManager.getJobResult(jobId);
|
||||
assertNotNull(result);
|
||||
assertTrue(result.isComplete());
|
||||
assertEquals(errorMessage, result.getError());
|
||||
assertNotNull(result.getCompletedAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSetComplete_WithExistingResult() {
|
||||
// Arrange
|
||||
String jobId = UUID.randomUUID().toString();
|
||||
taskManager.createTask(jobId);
|
||||
Object resultObject = "Test result";
|
||||
taskManager.setResult(jobId, resultObject);
|
||||
|
||||
// Act
|
||||
taskManager.setComplete(jobId);
|
||||
|
||||
// Assert
|
||||
JobResult result = taskManager.getJobResult(jobId);
|
||||
assertNotNull(result);
|
||||
assertTrue(result.isComplete());
|
||||
assertEquals(resultObject, result.getResult());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSetComplete_WithoutExistingResult() {
|
||||
// Arrange
|
||||
String jobId = UUID.randomUUID().toString();
|
||||
taskManager.createTask(jobId);
|
||||
|
||||
// Act
|
||||
taskManager.setComplete(jobId);
|
||||
|
||||
// Assert
|
||||
JobResult result = taskManager.getJobResult(jobId);
|
||||
assertNotNull(result);
|
||||
assertTrue(result.isComplete());
|
||||
assertEquals("Task completed successfully", result.getResult());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsComplete() {
|
||||
// Arrange
|
||||
String jobId = UUID.randomUUID().toString();
|
||||
taskManager.createTask(jobId);
|
||||
|
||||
// Assert - not complete initially
|
||||
assertFalse(taskManager.isComplete(jobId));
|
||||
|
||||
// Act - mark as complete
|
||||
taskManager.setComplete(jobId);
|
||||
|
||||
// Assert - now complete
|
||||
assertTrue(taskManager.isComplete(jobId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetJobStats() {
|
||||
// Arrange
|
||||
// 1. Create active job
|
||||
String activeJobId = "active-job";
|
||||
taskManager.createTask(activeJobId);
|
||||
|
||||
// 2. Create completed successful job with file
|
||||
String successFileJobId = "success-file-job";
|
||||
taskManager.createTask(successFileJobId);
|
||||
taskManager.setFileResult(successFileJobId, "file-id", "test.pdf", "application/pdf");
|
||||
|
||||
// 3. Create completed successful job without file
|
||||
String successJobId = "success-job";
|
||||
taskManager.createTask(successJobId);
|
||||
taskManager.setResult(successJobId, "Result");
|
||||
|
||||
// 4. Create failed job
|
||||
String failedJobId = "failed-job";
|
||||
taskManager.createTask(failedJobId);
|
||||
taskManager.setError(failedJobId, "Error message");
|
||||
|
||||
// Act
|
||||
JobStats stats = taskManager.getJobStats();
|
||||
|
||||
// Assert
|
||||
assertEquals(4, stats.getTotalJobs());
|
||||
assertEquals(1, stats.getActiveJobs());
|
||||
assertEquals(3, stats.getCompletedJobs());
|
||||
assertEquals(1, stats.getFailedJobs());
|
||||
assertEquals(2, stats.getSuccessfulJobs());
|
||||
assertEquals(1, stats.getFileResultJobs());
|
||||
assertNotNull(stats.getNewestActiveJobTime());
|
||||
assertNotNull(stats.getOldestActiveJobTime());
|
||||
assertTrue(stats.getAverageProcessingTimeMs() >= 0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCleanupOldJobs() throws Exception {
|
||||
// Arrange
|
||||
// 1. Create a recent completed job
|
||||
String recentJobId = "recent-job";
|
||||
taskManager.createTask(recentJobId);
|
||||
taskManager.setResult(recentJobId, "Result");
|
||||
|
||||
// 2. Create an old completed job with file result
|
||||
String oldJobId = "old-job";
|
||||
taskManager.createTask(oldJobId);
|
||||
JobResult oldJob = taskManager.getJobResult(oldJobId);
|
||||
|
||||
// Manually set the completion time to be older than the expiry
|
||||
LocalDateTime oldTime = LocalDateTime.now().minusHours(1);
|
||||
ReflectionTestUtils.setField(oldJob, "completedAt", oldTime);
|
||||
ReflectionTestUtils.setField(oldJob, "complete", true);
|
||||
ReflectionTestUtils.setField(oldJob, "fileId", "file-id");
|
||||
ReflectionTestUtils.setField(oldJob, "originalFileName", "test.pdf");
|
||||
ReflectionTestUtils.setField(oldJob, "contentType", "application/pdf");
|
||||
|
||||
when(fileStorage.deleteFile("file-id")).thenReturn(true);
|
||||
|
||||
// Obtain access to the private jobResults map
|
||||
Map<String, JobResult> jobResultsMap = (Map<String, JobResult>) ReflectionTestUtils.getField(taskManager, "jobResults");
|
||||
|
||||
// 3. Create an active job
|
||||
String activeJobId = "active-job";
|
||||
taskManager.createTask(activeJobId);
|
||||
|
||||
// Verify all jobs are in the map
|
||||
assertTrue(jobResultsMap.containsKey(recentJobId));
|
||||
assertTrue(jobResultsMap.containsKey(oldJobId));
|
||||
assertTrue(jobResultsMap.containsKey(activeJobId));
|
||||
|
||||
// Act
|
||||
taskManager.cleanupOldJobs();
|
||||
|
||||
// Assert - the old job should be removed
|
||||
assertFalse(jobResultsMap.containsKey(oldJobId));
|
||||
assertTrue(jobResultsMap.containsKey(recentJobId));
|
||||
assertTrue(jobResultsMap.containsKey(activeJobId));
|
||||
verify(fileStorage).deleteFile("file-id");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testShutdown() throws Exception {
|
||||
// This mainly tests that the shutdown method doesn't throw exceptions
|
||||
taskManager.shutdown();
|
||||
|
||||
// Verify the executor service is shutdown
|
||||
// This is difficult to test directly, but we can verify it doesn't throw exceptions
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
# AutoJobPostMapping Annotation
|
||||
|
||||
The `AutoJobPostMapping` annotation simplifies the creation of job-based REST endpoints in Stirling-PDF. It automatically handles job execution, file persistence, error handling, retries, and progress tracking.
|
||||
|
||||
## Features
|
||||
|
||||
- Wraps endpoint methods with job execution logic
|
||||
- Supports both synchronous and asynchronous execution (via `?async=true` query parameter)
|
||||
- Custom timeout configuration per endpoint
|
||||
- Automatic retries with configurable retry count
|
||||
- WebSocket-based progress tracking
|
||||
- Consistent error handling and reporting
|
||||
- Automatic persistence of uploaded files for async processing
|
||||
|
||||
## Usage
|
||||
|
||||
```java
|
||||
@AutoJobPostMapping("/api/v1/security/remove-password")
|
||||
public ResponseEntity<byte[]> removePassword(@ModelAttribute PDFPasswordRequest request)
|
||||
throws IOException {
|
||||
MultipartFile fileInput = request.getFileInput();
|
||||
String password = request.getPassword();
|
||||
PDDocument document = pdfDocumentFactory.load(fileInput, password);
|
||||
document.setAllSecurityToBeRemoved(true);
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document,
|
||||
Filenames.toSimpleFileName(fileInput.getOriginalFilename())
|
||||
.replaceFirst("[.][^.]+$", "")
|
||||
+ "_password_removed.pdf");
|
||||
}
|
||||
```
|
||||
|
||||
## Parameters
|
||||
|
||||
The `AutoJobPostMapping` annotation accepts the following parameters:
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `value` | String[] | `{}` | The path mapping URIs (e.g., "/api/v1/security/add-password") |
|
||||
| `consumes` | String[] | `{"multipart/form-data"}` | Supported media types for requests |
|
||||
| `timeout` | long | `-1` (use system default) | Custom timeout in milliseconds for this job |
|
||||
| `retryCount` | int | `1` (no retries) | Maximum number of retry attempts on failure |
|
||||
| `trackProgress` | boolean | `true` | Enable WebSocket progress tracking for async jobs |
|
||||
| `queueable` | boolean | `false` | Whether this job can be queued when system resources are limited |
|
||||
| `resourceWeight` | int | `50` | Resource weight of this job (1-100), higher values indicate more resource-intensive jobs |
|
||||
|
||||
## Examples
|
||||
|
||||
### Basic Usage
|
||||
```java
|
||||
@AutoJobPostMapping("/api/v1/security/remove-password")
|
||||
public ResponseEntity<byte[]> removePassword(@ModelAttribute PDFPasswordRequest request) {
|
||||
// Implementation
|
||||
}
|
||||
```
|
||||
|
||||
### With Custom Timeout
|
||||
```java
|
||||
// Set a 5-minute timeout for this operation
|
||||
@AutoJobPostMapping(value = "/api/v1/misc/ocr-pdf", timeout = 300000)
|
||||
public ResponseEntity<byte[]> ocrPdf(@ModelAttribute OCRRequest request) {
|
||||
// OCR implementation
|
||||
}
|
||||
```
|
||||
|
||||
### With Retries
|
||||
```java
|
||||
// Allow up to 3 retry attempts for external API calls
|
||||
@AutoJobPostMapping(value = "/api/v1/convert/url-to-pdf", retryCount = 3)
|
||||
public ResponseEntity<byte[]> convertUrlToPdf(@ModelAttribute WebsiteToPDFRequest request) {
|
||||
// Implementation
|
||||
}
|
||||
```
|
||||
|
||||
### Disable Progress Tracking
|
||||
```java
|
||||
// Simple, fast operation that doesn't need progress tracking
|
||||
@AutoJobPostMapping(value = "/api/v1/misc/flatten", trackProgress = false)
|
||||
public ResponseEntity<byte[]> flattenPdf(@ModelAttribute FlattenRequest request) {
|
||||
// Implementation
|
||||
}
|
||||
```
|
||||
|
||||
### Enable Job Queueing for Resource-Intensive Operations
|
||||
```java
|
||||
// Resource-intensive operation that can be queued during high system load
|
||||
@AutoJobPostMapping(
|
||||
value = "/api/v1/misc/ocr-pdf",
|
||||
queueable = true,
|
||||
resourceWeight = 80, // High resource usage
|
||||
timeout = 600000 // 10 minutes
|
||||
)
|
||||
public ResponseEntity<byte[]> ocrPdf(@ModelAttribute OCRRequest request) {
|
||||
// OCR implementation
|
||||
}
|
||||
```
|
||||
|
||||
### Lightweight Operation
|
||||
```java
|
||||
// Very lightweight operation with low resource requirements
|
||||
@AutoJobPostMapping(
|
||||
value = "/api/v1/misc/get-page-count",
|
||||
queueable = false,
|
||||
resourceWeight = 10 // Very low resource usage
|
||||
)
|
||||
public ResponseEntity<Integer> getPageCount(@ModelAttribute PDFFile request) {
|
||||
// Simple page count implementation
|
||||
}
|
||||
```
|
||||
|
||||
## Client-Side Integration
|
||||
|
||||
For asynchronous jobs, clients can:
|
||||
1. Submit the job with `?async=true` parameter
|
||||
2. Receive a job ID in the response
|
||||
3. Connect to the WebSocket at `/ws/progress/{jobId}` to receive progress updates
|
||||
4. Fetch the completed result from `/api/v1/general/job/{jobId}/result` when done
|
||||
|
||||
Example WebSocket message:
|
||||
```json
|
||||
{
|
||||
"jobId": "b4c9a31d-4b7e-42b2-8ab9-3cbe99d5b94f",
|
||||
"status": "Processing",
|
||||
"progress": 65,
|
||||
"message": "OCR processing page 13/20"
|
||||
}
|
||||
```
|
||||
|
||||
## Resource-Aware Job Queueing
|
||||
|
||||
The `queueable` parameter enables intelligent resource-aware job queueing for heavy operations. When enabled:
|
||||
|
||||
1. Jobs are automatically queued when system resources (CPU, memory) are constrained
|
||||
2. Queue capacity dynamically adjusts based on available resources
|
||||
3. Queue position and status updates are sent via WebSocket
|
||||
4. Jobs with high `resourceWeight` values have stricter queueing conditions
|
||||
5. Long-running jobs don't block the system from handling other requests
|
||||
|
||||
### Resource Weight Guidelines
|
||||
|
||||
When setting the `resourceWeight` parameter, use these guidelines:
|
||||
|
||||
| Weight Range | Appropriate For |
|
||||
|--------------|----------------|
|
||||
| 1-20 | Lightweight operations: metadata reads, simple transforms, etc. |
|
||||
| 21-50 | Medium operations: basic PDF manipulation, simple image operations |
|
||||
| 51-80 | Heavy operations: PDF merging, image conversions, medium OCR |
|
||||
| 81-100 | Very intensive operations: large OCR jobs, complex transformations |
|
||||
|
||||
### Example Queue Status Messages
|
||||
|
||||
```json
|
||||
{
|
||||
"jobId": "b4c9a31d-4b7e-42b2-8ab9-3cbe99d5b94f",
|
||||
"status": "Queued",
|
||||
"progress": 0,
|
||||
"message": "Waiting in queue for resources (position 3)"
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"jobId": "b4c9a31d-4b7e-42b2-8ab9-3cbe99d5b94f",
|
||||
"status": "Starting",
|
||||
"progress": 10,
|
||||
"message": "Resources available, starting job execution"
|
||||
}
|
||||
```
|
||||
@@ -23,7 +23,8 @@ public class CleanUrlInterceptor implements HandlerInterceptor {
|
||||
"errorOAuth",
|
||||
"file",
|
||||
"messageType",
|
||||
"infoMessage");
|
||||
"infoMessage",
|
||||
"async");
|
||||
|
||||
@Override
|
||||
public boolean preHandle(
|
||||
|
||||
@@ -17,7 +17,7 @@ import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.model.api.PDFFile;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
|
||||
@@ -29,7 +29,7 @@ public class AnalysisController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
|
||||
@PostMapping(value = "/page-count", consumes = "multipart/form-data")
|
||||
@AutoJobPostMapping(value = "/page-count", consumes = "multipart/form-data")
|
||||
@Operation(
|
||||
summary = "Get PDF page count",
|
||||
description = "Returns total number of pages in PDF. Input:PDF Output:JSON Type:SISO")
|
||||
@@ -39,7 +39,7 @@ public class AnalysisController {
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping(value = "/basic-info", consumes = "multipart/form-data")
|
||||
@AutoJobPostMapping(value = "/basic-info", consumes = "multipart/form-data")
|
||||
@Operation(
|
||||
summary = "Get basic PDF information",
|
||||
description = "Returns page count, version, file size. Input:PDF Output:JSON Type:SISO")
|
||||
@@ -53,7 +53,7 @@ public class AnalysisController {
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping(value = "/document-properties", consumes = "multipart/form-data")
|
||||
@AutoJobPostMapping(value = "/document-properties", consumes = "multipart/form-data")
|
||||
@Operation(
|
||||
summary = "Get PDF document properties",
|
||||
description = "Returns title, author, subject, etc. Input:PDF Output:JSON Type:SISO")
|
||||
@@ -76,7 +76,7 @@ public class AnalysisController {
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping(value = "/page-dimensions", consumes = "multipart/form-data")
|
||||
@AutoJobPostMapping(value = "/page-dimensions", consumes = "multipart/form-data")
|
||||
@Operation(
|
||||
summary = "Get page dimensions for all pages",
|
||||
description = "Returns width and height of each page. Input:PDF Output:JSON Type:SISO")
|
||||
@@ -96,7 +96,7 @@ public class AnalysisController {
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping(value = "/form-fields", consumes = "multipart/form-data")
|
||||
@AutoJobPostMapping(value = "/form-fields", consumes = "multipart/form-data")
|
||||
@Operation(
|
||||
summary = "Get form field information",
|
||||
description =
|
||||
@@ -119,7 +119,7 @@ public class AnalysisController {
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping(value = "/annotation-info", consumes = "multipart/form-data")
|
||||
@AutoJobPostMapping(value = "/annotation-info", consumes = "multipart/form-data")
|
||||
@Operation(
|
||||
summary = "Get annotation information",
|
||||
description = "Returns count and types of annotations. Input:PDF Output:JSON Type:SISO")
|
||||
@@ -143,7 +143,7 @@ public class AnalysisController {
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping(value = "/font-info", consumes = "multipart/form-data")
|
||||
@AutoJobPostMapping(value = "/font-info", consumes = "multipart/form-data")
|
||||
@Operation(
|
||||
summary = "Get font information",
|
||||
description =
|
||||
@@ -165,7 +165,7 @@ public class AnalysisController {
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping(value = "/security-info", consumes = "multipart/form-data")
|
||||
@AutoJobPostMapping(value = "/security-info", consumes = "multipart/form-data")
|
||||
@Operation(
|
||||
summary = "Get security information",
|
||||
description =
|
||||
|
||||
@@ -12,7 +12,7 @@ import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@@ -33,7 +33,7 @@ public class CropController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
|
||||
@PostMapping(value = "/crop", consumes = "multipart/form-data")
|
||||
@AutoJobPostMapping(value = "/crop", consumes = "multipart/form-data")
|
||||
@Operation(
|
||||
summary = "Crops a PDF document",
|
||||
description =
|
||||
|
||||
@@ -28,6 +28,7 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.security.database.DatabaseService;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
|
||||
@Slf4j
|
||||
@Controller
|
||||
|
||||
@@ -5,7 +5,7 @@ import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.mail.MailSendException;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@@ -42,7 +42,7 @@ public class EmailController {
|
||||
* attachment.
|
||||
* @return ResponseEntity with success or error message.
|
||||
*/
|
||||
@PostMapping(consumes = "multipart/form-data", value = "/send-email")
|
||||
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/send-email")
|
||||
@Operation(
|
||||
summary = "Send an email with an attachment",
|
||||
description =
|
||||
|
||||
@@ -20,7 +20,7 @@ import org.apache.pdfbox.pdmodel.interactive.form.PDField;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -110,7 +110,7 @@ public class MergeController {
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping(consumes = "multipart/form-data", value = "/merge-pdfs")
|
||||
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/merge-pdfs")
|
||||
@Operation(
|
||||
summary = "Merge multiple PDF files into one",
|
||||
description =
|
||||
|
||||
@@ -13,7 +13,7 @@ import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
|
||||
import org.apache.pdfbox.util.Matrix;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -36,7 +36,7 @@ public class MultiPageLayoutController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
|
||||
@PostMapping(value = "/multi-page-layout", consumes = "multipart/form-data")
|
||||
@AutoJobPostMapping(value = "/multi-page-layout", consumes = "multipart/form-data")
|
||||
@Operation(
|
||||
summary = "Merge multiple pages of a PDF document into a single page",
|
||||
description =
|
||||
|
||||
@@ -6,7 +6,7 @@ import java.io.IOException;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@@ -46,7 +46,7 @@ public class PdfImageRemovalController {
|
||||
* content type and filename.
|
||||
* @throws IOException If an error occurs while processing the PDF file.
|
||||
*/
|
||||
@PostMapping(consumes = "multipart/form-data", value = "/remove-image-pdf")
|
||||
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/remove-image-pdf")
|
||||
@Operation(
|
||||
summary = "Remove images from file to reduce the file size.",
|
||||
description =
|
||||
|
||||
@@ -15,7 +15,7 @@ import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -39,7 +39,7 @@ public class PdfOverlayController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
|
||||
@PostMapping(value = "/overlay-pdfs", consumes = "multipart/form-data")
|
||||
@AutoJobPostMapping(value = "/overlay-pdfs", consumes = "multipart/form-data")
|
||||
@Operation(
|
||||
summary = "Overlay PDF files in various modes",
|
||||
description =
|
||||
|
||||
+3
-3
@@ -9,7 +9,7 @@ import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -37,7 +37,7 @@ public class RearrangePagesPDFController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
|
||||
@PostMapping(consumes = "multipart/form-data", value = "/remove-pages")
|
||||
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/remove-pages")
|
||||
@Operation(
|
||||
summary = "Remove pages from a PDF file",
|
||||
description =
|
||||
@@ -236,7 +236,7 @@ public class RearrangePagesPDFController {
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping(consumes = "multipart/form-data", value = "/rearrange-pages")
|
||||
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/rearrange-pages")
|
||||
@Operation(
|
||||
summary = "Rearrange pages in a PDF file",
|
||||
description =
|
||||
|
||||
@@ -7,7 +7,7 @@ import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageTree;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -19,6 +19,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.SPDF.model.api.general.RotatePDFRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@@ -30,7 +31,7 @@ public class RotationController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
|
||||
@PostMapping(consumes = "multipart/form-data", value = "/rotate-pdf")
|
||||
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/rotate-pdf")
|
||||
@Operation(
|
||||
summary = "Rotate a PDF file",
|
||||
description =
|
||||
|
||||
@@ -14,7 +14,7 @@ import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
|
||||
import org.apache.pdfbox.util.Matrix;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -37,7 +37,7 @@ public class ScalePagesController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
|
||||
@PostMapping(value = "/scale-pages", consumes = "multipart/form-data")
|
||||
@AutoJobPostMapping(value = "/scale-pages", consumes = "multipart/form-data")
|
||||
@Operation(
|
||||
summary = "Change the size of a PDF page/document",
|
||||
description =
|
||||
|
||||
@@ -7,7 +7,7 @@ import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
|
||||
@@ -31,7 +31,7 @@ public class SettingsController {
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final EndpointConfiguration endpointConfiguration;
|
||||
|
||||
@PostMapping("/update-enable-analytics")
|
||||
@AutoJobPostMapping("/update-enable-analytics")
|
||||
@Hidden
|
||||
public ResponseEntity<String> updateApiKey(@RequestBody Boolean enabled) throws IOException {
|
||||
if (applicationProperties.getSystem().getEnableAnalytics() != null) {
|
||||
|
||||
@@ -15,7 +15,7 @@ import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -40,7 +40,7 @@ public class SplitPDFController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
|
||||
@PostMapping(consumes = "multipart/form-data", value = "/split-pages")
|
||||
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/split-pages")
|
||||
@Operation(
|
||||
summary = "Split a PDF file into separate documents",
|
||||
description =
|
||||
|
||||
+2
-2
@@ -15,7 +15,7 @@ import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDOutlin
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -116,7 +116,7 @@ public class SplitPdfByChaptersController {
|
||||
return bookmarks;
|
||||
}
|
||||
|
||||
@PostMapping(value = "/split-pdf-by-chapters", consumes = "multipart/form-data")
|
||||
@AutoJobPostMapping(value = "/split-pdf-by-chapters", consumes = "multipart/form-data")
|
||||
@Operation(
|
||||
summary = "Split PDFs by Chapters",
|
||||
description = "Splits a PDF into chapters and returns a ZIP file.")
|
||||
|
||||
+2
-2
@@ -20,7 +20,7 @@ import org.apache.pdfbox.util.Matrix;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -43,7 +43,7 @@ public class SplitPdfBySectionsController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
|
||||
@PostMapping(value = "/split-pdf-by-sections", consumes = "multipart/form-data")
|
||||
@AutoJobPostMapping(value = "/split-pdf-by-sections", consumes = "multipart/form-data")
|
||||
@Operation(
|
||||
summary = "Split PDF pages into smaller sections",
|
||||
description =
|
||||
|
||||
@@ -12,7 +12,7 @@ import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -38,7 +38,7 @@ public class SplitPdfBySizeController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
|
||||
@PostMapping(value = "/split-by-size-or-count", consumes = "multipart/form-data")
|
||||
@AutoJobPostMapping(value = "/split-by-size-or-count", consumes = "multipart/form-data")
|
||||
@Operation(
|
||||
summary = "Auto split PDF pages into separate documents based on size or count",
|
||||
description =
|
||||
|
||||
@@ -12,7 +12,7 @@ import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@@ -33,7 +33,7 @@ public class ToSinglePageController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
|
||||
@PostMapping(consumes = "multipart/form-data", value = "/pdf-to-single-page")
|
||||
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/pdf-to-single-page")
|
||||
@Operation(
|
||||
summary = "Convert a multi-page PDF into a single long page PDF",
|
||||
description =
|
||||
|
||||
@@ -36,6 +36,7 @@ import stirling.software.SPDF.model.AuthenticationType;
|
||||
import stirling.software.SPDF.model.Role;
|
||||
import stirling.software.SPDF.model.User;
|
||||
import stirling.software.SPDF.model.api.user.UsernameAndPass;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.exception.UnsupportedProviderException;
|
||||
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@ package stirling.software.SPDF.controller.api.converters;
|
||||
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -32,7 +32,7 @@ public class ConvertHtmlToPDF {
|
||||
|
||||
private final RuntimePathConfig runtimePathConfig;
|
||||
|
||||
@PostMapping(consumes = "multipart/form-data", value = "/html/pdf")
|
||||
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/html/pdf")
|
||||
@Operation(
|
||||
summary = "Convert an HTML or ZIP (containing HTML and CSS) to PDF",
|
||||
description =
|
||||
|
||||
+3
-3
@@ -18,7 +18,7 @@ import org.apache.pdfbox.rendering.ImageType;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -49,7 +49,7 @@ public class ConvertImgPDFController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
|
||||
@PostMapping(consumes = "multipart/form-data", value = "/pdf/img")
|
||||
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/pdf/img")
|
||||
@Operation(
|
||||
summary = "Convert PDF to image(s)",
|
||||
description =
|
||||
@@ -205,7 +205,7 @@ public class ConvertImgPDFController {
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping(consumes = "multipart/form-data", value = "/img/pdf")
|
||||
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/img/pdf")
|
||||
@Operation(
|
||||
summary = "Convert images to a PDF file",
|
||||
description =
|
||||
|
||||
+2
-2
@@ -12,7 +12,7 @@ import org.commonmark.renderer.html.AttributeProvider;
|
||||
import org.commonmark.renderer.html.HtmlRenderer;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -41,7 +41,7 @@ public class ConvertMarkdownToPdf {
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final RuntimePathConfig runtimePathConfig;
|
||||
|
||||
@PostMapping(consumes = "multipart/form-data", value = "/markdown/pdf")
|
||||
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/markdown/pdf")
|
||||
@Operation(
|
||||
summary = "Convert a Markdown file to PDF",
|
||||
description =
|
||||
|
||||
+2
-2
@@ -12,7 +12,7 @@ import org.apache.commons.io.FilenameUtils;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -84,7 +84,7 @@ public class ConvertOfficeController {
|
||||
return fileExtension.matches(extensionPattern);
|
||||
}
|
||||
|
||||
@PostMapping(consumes = "multipart/form-data", value = "/file/pdf")
|
||||
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/file/pdf")
|
||||
@Operation(
|
||||
summary = "Convert a file to a PDF using LibreOffice",
|
||||
description =
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@ package stirling.software.SPDF.controller.api.converters;
|
||||
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -18,7 +18,7 @@ import stirling.software.common.util.PDFToFile;
|
||||
@RequestMapping("/api/v1/convert")
|
||||
public class ConvertPDFToHtml {
|
||||
|
||||
@PostMapping(consumes = "multipart/form-data", value = "/pdf/html")
|
||||
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/pdf/html")
|
||||
@Operation(
|
||||
summary = "Convert PDF to HTML",
|
||||
description =
|
||||
|
||||
+5
-5
@@ -7,7 +7,7 @@ import org.apache.pdfbox.text.PDFTextStripper;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -34,7 +34,7 @@ public class ConvertPDFToOffice {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
|
||||
@PostMapping(consumes = "multipart/form-data", value = "/pdf/presentation")
|
||||
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/pdf/presentation")
|
||||
@Operation(
|
||||
summary = "Convert PDF to Presentation format",
|
||||
description =
|
||||
@@ -49,7 +49,7 @@ public class ConvertPDFToOffice {
|
||||
return pdfToFile.processPdfToOfficeFormat(inputFile, outputFormat, "impress_pdf_import");
|
||||
}
|
||||
|
||||
@PostMapping(consumes = "multipart/form-data", value = "/pdf/text")
|
||||
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/pdf/text")
|
||||
@Operation(
|
||||
summary = "Convert PDF to Text or RTF format",
|
||||
description =
|
||||
@@ -77,7 +77,7 @@ public class ConvertPDFToOffice {
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping(consumes = "multipart/form-data", value = "/pdf/word")
|
||||
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/pdf/word")
|
||||
@Operation(
|
||||
summary = "Convert PDF to Word document",
|
||||
description =
|
||||
@@ -91,7 +91,7 @@ public class ConvertPDFToOffice {
|
||||
return pdfToFile.processPdfToOfficeFormat(inputFile, outputFormat, "writer_pdf_import");
|
||||
}
|
||||
|
||||
@PostMapping(consumes = "multipart/form-data", value = "/pdf/xml")
|
||||
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/pdf/xml")
|
||||
@Operation(
|
||||
summary = "Convert PDF to XML",
|
||||
description =
|
||||
|
||||
+2
-2
@@ -11,7 +11,7 @@ import org.apache.commons.io.FileUtils;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -33,7 +33,7 @@ import stirling.software.common.util.WebResponseUtils;
|
||||
@Tag(name = "Convert", description = "Convert APIs")
|
||||
public class ConvertPDFToPDFA {
|
||||
|
||||
@PostMapping(consumes = "multipart/form-data", value = "/pdf/pdfa")
|
||||
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/pdf/pdfa")
|
||||
@Operation(
|
||||
summary = "Convert a PDF to a PDF/A",
|
||||
description =
|
||||
|
||||
+2
-2
@@ -9,7 +9,7 @@ import java.util.List;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@@ -39,7 +39,7 @@ public class ConvertWebsiteToPDF {
|
||||
private final RuntimePathConfig runtimePathConfig;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
@PostMapping(consumes = "multipart/form-data", value = "/url/pdf")
|
||||
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/url/pdf")
|
||||
@Operation(
|
||||
summary = "Convert a URL to a PDF",
|
||||
description =
|
||||
|
||||
+2
-2
@@ -18,7 +18,7 @@ import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@@ -46,7 +46,7 @@ public class ExtractCSVController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
|
||||
@PostMapping(value = "/pdf/csv", consumes = "multipart/form-data")
|
||||
@AutoJobPostMapping(value = "/pdf/csv", consumes = "multipart/form-data")
|
||||
@Operation(
|
||||
summary = "Extracts a CSV document from a PDF",
|
||||
description =
|
||||
|
||||
@@ -7,7 +7,7 @@ import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -36,7 +36,7 @@ public class FilterController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
|
||||
@PostMapping(consumes = "multipart/form-data", value = "/filter-contains-text")
|
||||
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/filter-contains-text")
|
||||
@Operation(
|
||||
summary = "Checks if a PDF contains set text, returns true if does",
|
||||
description = "Input:PDF Output:Boolean Type:SISO")
|
||||
@@ -54,7 +54,7 @@ public class FilterController {
|
||||
}
|
||||
|
||||
// TODO
|
||||
@PostMapping(consumes = "multipart/form-data", value = "/filter-contains-image")
|
||||
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/filter-contains-image")
|
||||
@Operation(
|
||||
summary = "Checks if a PDF contains an image",
|
||||
description = "Input:PDF Output:Boolean Type:SISO")
|
||||
@@ -70,7 +70,7 @@ public class FilterController {
|
||||
return null;
|
||||
}
|
||||
|
||||
@PostMapping(consumes = "multipart/form-data", value = "/filter-page-count")
|
||||
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/filter-page-count")
|
||||
@Operation(
|
||||
summary = "Checks if a PDF is greater, less or equal to a setPageCount",
|
||||
description = "Input:PDF Output:Boolean Type:SISO")
|
||||
@@ -103,7 +103,7 @@ public class FilterController {
|
||||
return null;
|
||||
}
|
||||
|
||||
@PostMapping(consumes = "multipart/form-data", value = "/filter-page-size")
|
||||
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/filter-page-size")
|
||||
@Operation(
|
||||
summary = "Checks if a PDF is of a certain size",
|
||||
description = "Input:PDF Output:Boolean Type:SISO")
|
||||
@@ -146,7 +146,7 @@ public class FilterController {
|
||||
return null;
|
||||
}
|
||||
|
||||
@PostMapping(consumes = "multipart/form-data", value = "/filter-file-size")
|
||||
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/filter-file-size")
|
||||
@Operation(
|
||||
summary = "Checks if a PDF is a set file size",
|
||||
description = "Input:PDF Output:Boolean Type:SISO")
|
||||
@@ -179,7 +179,7 @@ public class FilterController {
|
||||
return null;
|
||||
}
|
||||
|
||||
@PostMapping(consumes = "multipart/form-data", value = "/filter-page-rotation")
|
||||
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/filter-page-rotation")
|
||||
@Operation(
|
||||
summary = "Checks if a PDF is of a certain rotation",
|
||||
description = "Input:PDF Output:Boolean Type:SISO")
|
||||
|
||||
@@ -10,7 +10,7 @@ import org.apache.pdfbox.text.PDFTextStripper;
|
||||
import org.apache.pdfbox.text.TextPosition;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -38,7 +38,7 @@ public class AutoRenameController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
|
||||
@PostMapping(consumes = "multipart/form-data", value = "/auto-rename")
|
||||
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/auto-rename")
|
||||
@Operation(
|
||||
summary = "Extract header from PDF file",
|
||||
description =
|
||||
|
||||
+2
-2
@@ -19,7 +19,7 @@ import org.apache.pdfbox.rendering.PDFRenderer;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -102,7 +102,7 @@ public class AutoSplitPdfController {
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping(value = "/auto-split-pdf", consumes = "multipart/form-data")
|
||||
@AutoJobPostMapping(value = "/auto-split-pdf", consumes = "multipart/form-data")
|
||||
@Operation(
|
||||
summary = "Auto split PDF pages into separate documents",
|
||||
description =
|
||||
|
||||
@@ -17,7 +17,7 @@ import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -69,7 +69,7 @@ public class BlankPageController {
|
||||
return whitePixelPercentage >= whitePercent;
|
||||
}
|
||||
|
||||
@PostMapping(consumes = "multipart/form-data", value = "/remove-blanks")
|
||||
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/remove-blanks")
|
||||
@Operation(
|
||||
summary = "Remove blank pages from a PDF file",
|
||||
description =
|
||||
|
||||
@@ -34,7 +34,7 @@ import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
|
||||
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -654,7 +654,7 @@ public class CompressController {
|
||||
};
|
||||
}
|
||||
|
||||
@PostMapping(consumes = "multipart/form-data", value = "/compress-pdf")
|
||||
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/compress-pdf")
|
||||
@Operation(
|
||||
summary = "Optimize PDF file",
|
||||
description =
|
||||
|
||||
+2
-2
@@ -13,7 +13,7 @@ import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -37,7 +37,7 @@ public class DecompressPdfController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
|
||||
@PostMapping(value = "/decompress-pdf", consumes = "multipart/form-data")
|
||||
@AutoJobPostMapping(value = "/decompress-pdf", consumes = "multipart/form-data")
|
||||
@Operation(
|
||||
summary = "Decompress PDF streams",
|
||||
description = "Fully decompresses all PDF streams including text content")
|
||||
|
||||
+2
-2
@@ -19,7 +19,7 @@ import org.apache.pdfbox.rendering.PDFRenderer;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -48,7 +48,7 @@ public class ExtractImageScansController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
|
||||
@PostMapping(consumes = "multipart/form-data", value = "/extract-image-scans")
|
||||
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/extract-image-scans")
|
||||
@Operation(
|
||||
summary = "Extract image scans from an input file",
|
||||
description =
|
||||
|
||||
+2
-2
@@ -27,7 +27,7 @@ import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -53,7 +53,7 @@ public class ExtractImagesController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
|
||||
@PostMapping(consumes = "multipart/form-data", value = "/extract-images")
|
||||
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/extract-images")
|
||||
@Operation(
|
||||
summary = "Extract images from a PDF file",
|
||||
description =
|
||||
|
||||
@@ -19,7 +19,7 @@ import org.apache.pdfbox.rendering.PDFRenderer;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -47,7 +47,7 @@ public class FakeScanController {
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private static final Random RANDOM = new Random();
|
||||
|
||||
@PostMapping(value = "/fake-scan", consumes = "multipart/form-data")
|
||||
@AutoJobPostMapping(value = "/fake-scan", consumes = "multipart/form-data")
|
||||
@Operation(
|
||||
summary = "Convert PDF to look like a scanned document",
|
||||
description =
|
||||
|
||||
@@ -13,7 +13,7 @@ import org.apache.pdfbox.rendering.ImageType;
|
||||
import org.apache.pdfbox.rendering.PDFRenderer;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -38,7 +38,7 @@ public class FlattenController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
|
||||
@PostMapping(consumes = "multipart/form-data", value = "/flatten")
|
||||
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/flatten")
|
||||
@Operation(
|
||||
summary = "Flatten PDF form fields or full page",
|
||||
description =
|
||||
|
||||
@@ -23,6 +23,7 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.model.api.misc.MetadataRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
import stirling.software.common.util.propertyeditor.StringToMapPropertyEditor;
|
||||
@@ -51,7 +52,7 @@ public class MetadataController {
|
||||
binder.registerCustomEditor(Map.class, "allRequestParams", new StringToMapPropertyEditor());
|
||||
}
|
||||
|
||||
@PostMapping(consumes = "multipart/form-data", value = "/update-metadata")
|
||||
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/update-metadata")
|
||||
@Operation(
|
||||
summary = "Update metadata of a PDF file",
|
||||
description =
|
||||
|
||||
@@ -18,7 +18,7 @@ import org.apache.pdfbox.text.PDFTextStripper;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -60,7 +60,7 @@ public class OCRController {
|
||||
.toList();
|
||||
}
|
||||
|
||||
@PostMapping(consumes = "multipart/form-data", value = "/ocr-pdf")
|
||||
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/ocr-pdf")
|
||||
@Operation(
|
||||
summary = "Process PDF files with OCR using Tesseract",
|
||||
description =
|
||||
|
||||
+2
-2
@@ -5,7 +5,7 @@ import java.io.IOException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -31,7 +31,7 @@ public class OverlayImageController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
|
||||
@PostMapping(consumes = "multipart/form-data", value = "/add-image")
|
||||
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/add-image")
|
||||
@Operation(
|
||||
summary = "Overlay image onto a PDF file",
|
||||
description =
|
||||
|
||||
@@ -13,7 +13,7 @@ import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -37,7 +37,7 @@ public class PageNumbersController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
|
||||
@PostMapping(value = "/add-page-numbers", consumes = "multipart/form-data")
|
||||
@AutoJobPostMapping(value = "/add-page-numbers", consumes = "multipart/form-data")
|
||||
@Operation(
|
||||
summary = "Add page numbers to a PDF document",
|
||||
description =
|
||||
|
||||
@@ -36,7 +36,7 @@ import stirling.software.SPDF.model.api.misc.PrintFileRequest;
|
||||
public class PrintFileController {
|
||||
|
||||
// TODO
|
||||
// @PostMapping(value = "/print-file", consumes = "multipart/form-data")
|
||||
// @AutoJobPostMapping(value = "/print-file", consumes = "multipart/form-data")
|
||||
// @Operation(
|
||||
// summary = "Prints PDF/Image file to a set printer",
|
||||
// description =
|
||||
|
||||
@@ -8,7 +8,7 @@ import java.util.List;
|
||||
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -33,7 +33,7 @@ public class RepairController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
|
||||
@PostMapping(consumes = "multipart/form-data", value = "/repair")
|
||||
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/repair")
|
||||
@Operation(
|
||||
summary = "Repair a PDF file",
|
||||
description =
|
||||
|
||||
+2
-2
@@ -7,7 +7,7 @@ import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@@ -27,7 +27,7 @@ public class ReplaceAndInvertColorController {
|
||||
|
||||
private final ReplaceAndInvertColorService replaceAndInvertColorService;
|
||||
|
||||
@PostMapping(consumes = "multipart/form-data", value = "/replace-invert-pdf")
|
||||
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/replace-invert-pdf")
|
||||
@Operation(
|
||||
summary = "Replace-Invert Color PDF",
|
||||
description =
|
||||
|
||||
@@ -9,7 +9,7 @@ import org.apache.pdfbox.pdmodel.interactive.action.PDActionJavaScript;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -32,7 +32,7 @@ public class ShowJavascript {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
|
||||
@PostMapping(consumes = "multipart/form-data", value = "/show-javascript")
|
||||
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/show-javascript")
|
||||
@Operation(
|
||||
summary = "Grabs all JS from a PDF and returns a single JS file with all code",
|
||||
description = "desc. Input:PDF Output:JS Type:SISO")
|
||||
|
||||
@@ -27,7 +27,7 @@ import org.apache.pdfbox.util.Matrix;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -50,7 +50,7 @@ public class StampController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
|
||||
@PostMapping(consumes = "multipart/form-data", value = "/add-stamp")
|
||||
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/add-stamp")
|
||||
@Operation(
|
||||
summary = "Add stamp to a PDF file",
|
||||
description =
|
||||
|
||||
+2
-2
@@ -13,7 +13,7 @@ import org.apache.pdfbox.pdmodel.interactive.form.PDField;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@@ -39,7 +39,7 @@ public class UnlockPDFFormsController {
|
||||
this.pdfDocumentFactory = pdfDocumentFactory;
|
||||
}
|
||||
|
||||
@PostMapping(consumes = "multipart/form-data", value = "/unlock-pdf-forms")
|
||||
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/unlock-pdf-forms")
|
||||
@Operation(
|
||||
summary = "Remove read-only property from form fields",
|
||||
description =
|
||||
|
||||
+2
-2
@@ -12,7 +12,7 @@ import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -46,7 +46,7 @@ public class PipelineController {
|
||||
|
||||
private final PostHogService postHogService;
|
||||
|
||||
@PostMapping(value = "/handleData", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@AutoJobPostMapping(value = "/handleData", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
public ResponseEntity<byte[]> handleData(@ModelAttribute HandleDataRequest request)
|
||||
throws JsonMappingException, JsonProcessingException {
|
||||
MultipartFile[] files = request.getFileInput();
|
||||
|
||||
+2
-2
@@ -59,7 +59,7 @@ import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.WebDataBinder;
|
||||
import org.springframework.web.bind.annotation.InitBinder;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -136,7 +136,7 @@ public class CertSignController {
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping(
|
||||
@AutoJobPostMapping(
|
||||
consumes = {
|
||||
MediaType.MULTIPART_FORM_DATA_VALUE,
|
||||
MediaType.APPLICATION_FORM_URLENCODED_VALUE
|
||||
|
||||
@@ -46,7 +46,7 @@ import org.apache.xmpbox.xml.XmpSerializer;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -118,7 +118,7 @@ public class GetInfoOnPDF {
|
||||
return false;
|
||||
}
|
||||
|
||||
@PostMapping(consumes = "multipart/form-data", value = "/get-info-on-pdf")
|
||||
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/get-info-on-pdf")
|
||||
@Operation(summary = "Summary here", description = "desc. Input:PDF Output:JSON Type:SISO")
|
||||
public ResponseEntity<byte[]> getPdfInfo(@ModelAttribute PDFFile request) throws IOException {
|
||||
MultipartFile inputFile = request.getFileInput();
|
||||
|
||||
+3
-3
@@ -7,7 +7,7 @@ import org.apache.pdfbox.pdmodel.encryption.AccessPermission;
|
||||
import org.apache.pdfbox.pdmodel.encryption.StandardProtectionPolicy;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -31,7 +31,7 @@ public class PasswordController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
|
||||
@PostMapping(consumes = "multipart/form-data", value = "/remove-password")
|
||||
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/remove-password")
|
||||
@Operation(
|
||||
summary = "Remove password from a PDF file",
|
||||
description =
|
||||
@@ -50,7 +50,7 @@ public class PasswordController {
|
||||
+ "_password_removed.pdf");
|
||||
}
|
||||
|
||||
@PostMapping(consumes = "multipart/form-data", value = "/add-password")
|
||||
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/add-password")
|
||||
@Operation(
|
||||
summary = "Add password to a PDF file",
|
||||
description =
|
||||
|
||||
@@ -18,7 +18,7 @@ import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.WebDataBinder;
|
||||
import org.springframework.web.bind.annotation.InitBinder;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -56,7 +56,7 @@ public class RedactController {
|
||||
List.class, "redactions", new StringToArrayListPropertyEditor());
|
||||
}
|
||||
|
||||
@PostMapping(value = "/redact", consumes = "multipart/form-data")
|
||||
@AutoJobPostMapping(value = "/redact", consumes = "multipart/form-data")
|
||||
@Operation(
|
||||
summary = "Redacts areas and pages in a PDF document",
|
||||
description =
|
||||
@@ -190,7 +190,7 @@ public class RedactController {
|
||||
return pageNumbers;
|
||||
}
|
||||
|
||||
@PostMapping(value = "/auto-redact", consumes = "multipart/form-data")
|
||||
@AutoJobPostMapping(value = "/auto-redact", consumes = "multipart/form-data")
|
||||
@Operation(
|
||||
summary = "Redacts listOfText in a PDF document",
|
||||
description =
|
||||
|
||||
+2
-2
@@ -9,7 +9,7 @@ import org.apache.pdfbox.pdmodel.interactive.form.PDField;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -32,7 +32,7 @@ public class RemoveCertSignController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
|
||||
@PostMapping(consumes = "multipart/form-data", value = "/remove-cert-sign")
|
||||
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/remove-cert-sign")
|
||||
@Operation(
|
||||
summary = "Remove digital signature from PDF",
|
||||
description =
|
||||
|
||||
+2
-2
@@ -14,7 +14,7 @@ import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -37,7 +37,7 @@ public class SanitizeController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
|
||||
@PostMapping(consumes = "multipart/form-data", value = "/sanitize-pdf")
|
||||
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/sanitize-pdf")
|
||||
@Operation(
|
||||
summary = "Sanitize a PDF file",
|
||||
description =
|
||||
|
||||
+2
-2
@@ -27,7 +27,7 @@ import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.WebDataBinder;
|
||||
import org.springframework.web.bind.annotation.InitBinder;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -68,7 +68,7 @@ public class ValidateSignatureController {
|
||||
description =
|
||||
"Validates the digital signatures in a PDF file against default or custom"
|
||||
+ " certificates. Input:PDF Output:JSON Type:SISO")
|
||||
@PostMapping(value = "/validate-signature", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@AutoJobPostMapping(value = "/validate-signature", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
public ResponseEntity<List<SignatureValidationResult>> validateSignature(
|
||||
@ModelAttribute SignatureValidationRequest request) throws IOException {
|
||||
List<SignatureValidationResult> results = new ArrayList<>();
|
||||
|
||||
+4
-3
@@ -28,7 +28,7 @@ import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.WebDataBinder;
|
||||
import org.springframework.web.bind.annotation.InitBinder;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -64,7 +64,7 @@ public class WatermarkController {
|
||||
});
|
||||
}
|
||||
|
||||
@PostMapping(consumes = "multipart/form-data", value = "/add-watermark")
|
||||
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/add-watermark")
|
||||
@Operation(
|
||||
summary = "Add watermark to a PDF file",
|
||||
description =
|
||||
@@ -177,7 +177,8 @@ public class WatermarkController {
|
||||
}
|
||||
|
||||
if (!"".equals(resourceDir)) {
|
||||
ClassPathResource classPathResource = new ClassPathResource(resourceDir);
|
||||
ClassPathResource classPathResource = new ClassPathResource(resourceDir, getClass().getClassLoader());
|
||||
|
||||
String fileExtension = resourceDir.substring(resourceDir.lastIndexOf("."));
|
||||
File tempFile = Files.createTempFile("NotoSansFont", fileExtension).toFile();
|
||||
try (InputStream is = classPathResource.getInputStream();
|
||||
|
||||
@@ -2,7 +2,7 @@ package stirling.software.SPDF.model.api.converters;
|
||||
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -18,7 +18,7 @@ import stirling.software.common.util.PDFToFile;
|
||||
@RequestMapping("/api/v1/convert")
|
||||
public class ConvertPDFToMarkdown {
|
||||
|
||||
@PostMapping(consumes = "multipart/form-data", value = "/pdf/markdown")
|
||||
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/pdf/markdown")
|
||||
@Operation(
|
||||
summary = "Convert PDF to Markdown",
|
||||
description =
|
||||
|
||||
+577
@@ -0,0 +1,577 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Test script for AutoJobPostMapping functionality
|
||||
# Tests the rotate-pdf endpoint with various configurations
|
||||
|
||||
# Don't exit on error for Git Bash compatibility
|
||||
# set -e
|
||||
|
||||
# Colors for output
|
||||
GREEN='\033[0;32m'
|
||||
RED='\033[0;31m'
|
||||
BLUE='\033[0;34m'
|
||||
YELLOW='\033[0;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Utility functions
|
||||
function log_info() {
|
||||
echo -e "${BLUE}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
function log_success() {
|
||||
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
||||
}
|
||||
|
||||
function log_error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
# Don't exit on error for Git Bash
|
||||
# exit 1
|
||||
}
|
||||
|
||||
function log_warning() {
|
||||
echo -e "${YELLOW}[WARNING]${NC} $1"
|
||||
}
|
||||
|
||||
function separator() {
|
||||
echo -e "\n${YELLOW}----------------------------------------${NC}\n"
|
||||
}
|
||||
|
||||
# Check if Stirling-PDF is running
|
||||
function check_service() {
|
||||
log_info "Checking if Stirling-PDF service is running..."
|
||||
|
||||
# Try to connect to the service
|
||||
if curl -s -f http://localhost:8080 > /dev/null; then
|
||||
log_success "Stirling-PDF service is running"
|
||||
return 0
|
||||
else
|
||||
log_error "Stirling-PDF service is not running. Please start it first."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Create test directory
|
||||
TEST_DIR="/tmp/autojob_test"
|
||||
mkdir -p "$TEST_DIR"
|
||||
|
||||
# Clean previous test results
|
||||
rm -rf "$TEST_DIR"/*
|
||||
|
||||
# Prepare test PDF file if it doesn't exist
|
||||
TEST_PDF="$TEST_DIR/test.pdf"
|
||||
if [ ! -f "$TEST_PDF" ]; then
|
||||
log_info "Creating test PDF..."
|
||||
# Check if we have a sample PDF in the repository
|
||||
if [ -f "src/main/resources/static/files/Auto Splitter Divider (with instructions).pdf" ]; then
|
||||
cp "src/main/resources/static/files/Auto Splitter Divider (with instructions).pdf" "$TEST_PDF"
|
||||
else
|
||||
# Create a simple PDF with text
|
||||
echo "This is a test PDF file for AutoJobPostMapping testing" > "$TEST_DIR/test.txt"
|
||||
if command -v convert &> /dev/null; then
|
||||
convert -size 612x792 canvas:white -font Arial -pointsize 20 -draw "text 50,400 '@$TEST_DIR/test.txt'" "$TEST_PDF"
|
||||
else
|
||||
log_error "ImageMagick 'convert' command not found. Cannot create test PDF."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Test variables
|
||||
SUCCESS_COUNT=0
|
||||
FAILURE_COUNT=0
|
||||
START_TIME=$(date +%s)
|
||||
|
||||
# Test 1: Synchronous mode with file upload
|
||||
function test_sync_file_upload() {
|
||||
separator
|
||||
log_info "Test 1: Synchronous mode with file upload"
|
||||
|
||||
RESPONSE_FILE="$TEST_DIR/sync_response.pdf"
|
||||
|
||||
log_info "Calling rotate-pdf endpoint with angle=90..."
|
||||
|
||||
# Call the endpoint
|
||||
HTTP_CODE=$(curl -s -w "%{http_code}" -o "$RESPONSE_FILE" \
|
||||
-F "fileInput=@$TEST_PDF" \
|
||||
-F "angle=90" \
|
||||
-H "Accept: application/pdf" \
|
||||
http://localhost:8080/api/v1/general/rotate-pdf)
|
||||
|
||||
if [[ $HTTP_CODE -ge 200 && $HTTP_CODE -lt 300 ]]; then
|
||||
# Check if response is a valid PDF
|
||||
if file "$RESPONSE_FILE" | grep -q "PDF document"; then
|
||||
log_success "Test 1 succeeded: Received valid PDF response (HTTP $HTTP_CODE)"
|
||||
((SUCCESS_COUNT++))
|
||||
# Check if it's a JSON response with an embedded PDF
|
||||
elif grep -q "result" "$RESPONSE_FILE" && grep -q "application/pdf" "$RESPONSE_FILE"; then
|
||||
log_warning "Test 1 partial: Response is a JSON wrapper instead of direct PDF (HTTP $HTTP_CODE)"
|
||||
log_info "The API returned a JSON wrapper. This will be fixed by the JobExecutorService update."
|
||||
((SUCCESS_COUNT++))
|
||||
else
|
||||
log_error "Test 1 failed: Response is neither a valid PDF nor a JSON wrapper (HTTP $HTTP_CODE)"
|
||||
((FAILURE_COUNT++))
|
||||
fi
|
||||
else
|
||||
log_error "Test 1 failed: API call returned error (HTTP $HTTP_CODE)"
|
||||
((FAILURE_COUNT++))
|
||||
fi
|
||||
}
|
||||
|
||||
# Test 2: Asynchronous mode with file upload
|
||||
function test_async_file_upload() {
|
||||
separator
|
||||
log_info "Test 2: Asynchronous mode with file upload"
|
||||
|
||||
RESPONSE_FILE="$TEST_DIR/async_response.json"
|
||||
|
||||
log_info "Calling rotate-pdf endpoint with angle=180 and async=true..."
|
||||
|
||||
# Call the endpoint - simplified for Git Bash
|
||||
curl -s -o "$RESPONSE_FILE" \
|
||||
-F "fileInput=@$TEST_PDF" \
|
||||
-F "angle=180" \
|
||||
"http://localhost:8080/api/v1/general/rotate-pdf?async=true"
|
||||
|
||||
# Check if file exists and has content
|
||||
if [ -f "$RESPONSE_FILE" ] && [ -s "$RESPONSE_FILE" ]; then
|
||||
|
||||
# Extract job ID from response
|
||||
JOB_ID=$(grep -o '"jobId":"[^"]*"' "$RESPONSE_FILE" | cut -d':' -f2 | tr -d '"')
|
||||
|
||||
if [ -n "$JOB_ID" ]; then
|
||||
log_success "Received job ID: $JOB_ID"
|
||||
|
||||
# Wait for job to complete (polling)
|
||||
log_info "Polling for job completion..."
|
||||
MAX_ATTEMPTS=10
|
||||
ATTEMPT=0
|
||||
|
||||
while [ $ATTEMPT -lt $MAX_ATTEMPTS ]; do
|
||||
((ATTEMPT++))
|
||||
sleep 2
|
||||
|
||||
# Check job status
|
||||
STATUS_FILE="$TEST_DIR/job_status.json"
|
||||
if curl -s -o "$STATUS_FILE" "http://localhost:8080/api/v1/general/job/$JOB_ID"; then
|
||||
if grep -q '"complete":true' "$STATUS_FILE"; then
|
||||
log_success "Job completed successfully"
|
||||
|
||||
# Download result
|
||||
RESULT_FILE="$TEST_DIR/async_result.pdf"
|
||||
if curl -s -o "$RESULT_FILE" "http://localhost:8080/api/v1/general/job/$JOB_ID/result"; then
|
||||
# Check if response is a valid PDF
|
||||
if file "$RESULT_FILE" | grep -q "PDF document"; then
|
||||
log_success "Test 2 succeeded: Received valid PDF result"
|
||||
((SUCCESS_COUNT++))
|
||||
break
|
||||
else
|
||||
log_error "Test 2 failed: Result is not a valid PDF"
|
||||
((FAILURE_COUNT++))
|
||||
break
|
||||
fi
|
||||
else
|
||||
log_error "Test 2 failed: Could not download result"
|
||||
((FAILURE_COUNT++))
|
||||
break
|
||||
fi
|
||||
elif grep -q '"error":' "$STATUS_FILE"; then
|
||||
ERROR=$(grep -o '"error":"[^"]*"' "$STATUS_FILE" | cut -d':' -f2 | tr -d '"')
|
||||
log_error "Test 2 failed: Job reported error: $ERROR"
|
||||
((FAILURE_COUNT++))
|
||||
break
|
||||
else
|
||||
log_info "Job still processing (attempt $ATTEMPT/$MAX_ATTEMPTS)..."
|
||||
fi
|
||||
else
|
||||
log_error "Failed to check job status"
|
||||
fi
|
||||
|
||||
if [ $ATTEMPT -eq $MAX_ATTEMPTS ]; then
|
||||
log_error "Test 2 failed: Job did not complete in time"
|
||||
((FAILURE_COUNT++))
|
||||
fi
|
||||
done
|
||||
else
|
||||
log_error "Test 2 failed: No job ID found in response"
|
||||
((FAILURE_COUNT++))
|
||||
fi
|
||||
else
|
||||
log_error "Test 2 failed: API call returned error"
|
||||
((FAILURE_COUNT++))
|
||||
fi
|
||||
}
|
||||
|
||||
# Test 3: Using fileId parameter from an async job result
|
||||
function test_file_id() {
|
||||
separator
|
||||
log_info "Test 3: Using fileId parameter from an async job"
|
||||
|
||||
# First, we need to run an async operation to get a fileId
|
||||
ASYNC_RESPONSE="$TEST_DIR/test3_async_response.json"
|
||||
|
||||
log_info "First, submitting an async rotation to get a server-side file..."
|
||||
|
||||
# Call the endpoint with async=true
|
||||
curl -s -o "$ASYNC_RESPONSE" \
|
||||
-F "fileInput=@$TEST_PDF" \
|
||||
-F "angle=90" \
|
||||
"http://localhost:8080/api/v1/general/rotate-pdf?async=true"
|
||||
|
||||
# Extract job ID from response
|
||||
JOB_ID=$(grep -o '"jobId":"[^"]*"' "$ASYNC_RESPONSE" | cut -d':' -f2 | tr -d '"')
|
||||
|
||||
if [ -z "$JOB_ID" ]; then
|
||||
log_error "Test 3 failed: No job ID found in async response"
|
||||
((FAILURE_COUNT++))
|
||||
return
|
||||
fi
|
||||
|
||||
log_success "Received job ID: $JOB_ID"
|
||||
|
||||
# Wait for job to complete
|
||||
log_info "Waiting for async job to complete..."
|
||||
MAX_ATTEMPTS=10
|
||||
ATTEMPT=0
|
||||
JOB_COMPLETED=false
|
||||
|
||||
while [ $ATTEMPT -lt $MAX_ATTEMPTS ]; do
|
||||
((ATTEMPT++))
|
||||
sleep 2
|
||||
|
||||
# Check job status
|
||||
STATUS_FILE="$TEST_DIR/test3_job_status.json"
|
||||
if curl -s -o "$STATUS_FILE" "http://localhost:8080/api/v1/general/job/$JOB_ID"; then
|
||||
echo "Job status response:"
|
||||
cat "$STATUS_FILE"
|
||||
|
||||
if grep -q '"complete":true' "$STATUS_FILE"; then
|
||||
log_success "Async job completed successfully"
|
||||
JOB_COMPLETED=true
|
||||
break
|
||||
elif grep -q '"error":' "$STATUS_FILE"; then
|
||||
ERROR=$(grep -o '"error":"[^"]*"' "$STATUS_FILE" | cut -d':' -f2 | tr -d '"')
|
||||
log_error "Test 3 failed: Async job reported error: $ERROR"
|
||||
((FAILURE_COUNT++))
|
||||
return
|
||||
else
|
||||
log_info "Job still processing (attempt $ATTEMPT/$MAX_ATTEMPTS)..."
|
||||
fi
|
||||
else
|
||||
log_error "Failed to check job status"
|
||||
fi
|
||||
|
||||
if [ $ATTEMPT -eq $MAX_ATTEMPTS ]; then
|
||||
log_error "Test 3 failed: Async job did not complete in time"
|
||||
((FAILURE_COUNT++))
|
||||
return
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$JOB_COMPLETED" = false ]; then
|
||||
log_error "Test 3 failed: Async job did not complete successfully"
|
||||
((FAILURE_COUNT++))
|
||||
return
|
||||
fi
|
||||
|
||||
# Now get the result file from the completed job
|
||||
RESULT_FILE="$TEST_DIR/test3_result.pdf"
|
||||
if curl -s -o "$RESULT_FILE" "http://localhost:8080/api/v1/general/job/$JOB_ID/result"; then
|
||||
if file "$RESULT_FILE" | grep -q "PDF document"; then
|
||||
log_success "Successfully downloaded result file"
|
||||
else
|
||||
log_error "Test 3 failed: Downloaded result is not a valid PDF"
|
||||
((FAILURE_COUNT++))
|
||||
return
|
||||
fi
|
||||
else
|
||||
log_error "Test 3 failed: Could not download result file"
|
||||
((FAILURE_COUNT++))
|
||||
return
|
||||
fi
|
||||
|
||||
# Now check the job result info to get fileId
|
||||
RESULT_INFO="$TEST_DIR/test3_job_info.json"
|
||||
curl -s -o "$RESULT_INFO" "http://localhost:8080/api/v1/general/job/$JOB_ID"
|
||||
|
||||
# Try to extract fileId directly from the job info
|
||||
FILE_ID=$(grep -o '"fileId":"[^"]*"' "$RESULT_INFO" | head -1 | cut -d':' -f2 | tr -d '"')
|
||||
|
||||
if [ -z "$FILE_ID" ]; then
|
||||
log_error "Test 3 failed: Could not find fileId in job result"
|
||||
echo "Job result content:"
|
||||
cat "$RESULT_INFO"
|
||||
|
||||
# Even if we couldn't find a fileId, let's try to proceed using an alternate approach
|
||||
log_warning "Falling back to alternate approach: extracting fileId from request PDFFile"
|
||||
|
||||
# Run another async job but extract fileId from the request PDFFile
|
||||
ASYNC_RESPONSE2="$TEST_DIR/test3_async_response2.json"
|
||||
|
||||
curl -vvv -s -o "$ASYNC_RESPONSE2" \
|
||||
-F "fileInput=@$TEST_PDF" \
|
||||
-F "angle=90" \
|
||||
"http://localhost:8080/api/v1/general/rotate-pdf?async=true" 2>&1 | tee "$TEST_DIR/curl_verbose.log"
|
||||
|
||||
echo "Curl verbose log for debugging:"
|
||||
cat "$TEST_DIR/curl_verbose.log"
|
||||
|
||||
# Try to get the fileId from the async response
|
||||
JOB_ID2=$(grep -o '"jobId":"[^"]*"' "$ASYNC_RESPONSE2" | cut -d':' -f2 | tr -d '"')
|
||||
|
||||
if [ -z "$JOB_ID2" ]; then
|
||||
log_error "Test 3 failed: No job ID found in second async response"
|
||||
((FAILURE_COUNT++))
|
||||
return
|
||||
fi
|
||||
|
||||
log_success "Received second job ID: $JOB_ID2"
|
||||
|
||||
# Wait for this job to complete as well
|
||||
sleep 5
|
||||
|
||||
# Get the job status to see if fileId is available
|
||||
RESULT_INFO2="$TEST_DIR/test3_job_info2.json"
|
||||
curl -s -o "$RESULT_INFO2" "http://localhost:8080/api/v1/general/job/$JOB_ID2"
|
||||
|
||||
# Try to extract fileId directly from the job info
|
||||
FILE_ID=$(grep -o '"fileId":"[^"]*"' "$RESULT_INFO2" | head -1 | cut -d':' -f2 | tr -d '"')
|
||||
|
||||
if [ -z "$FILE_ID" ]; then
|
||||
log_error "Test 3 failed: Could not find fileId in second job result either"
|
||||
echo "Second job result content:"
|
||||
cat "$RESULT_INFO2"
|
||||
((FAILURE_COUNT++))
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
log_success "Extracted fileId from job result: $FILE_ID"
|
||||
|
||||
# Now use the fileId to rotate the PDF again with a different angle
|
||||
RESPONSE_FILE="$TEST_DIR/fileid_response.pdf"
|
||||
|
||||
log_info "Calling rotate-pdf endpoint with fileId and angle=270..."
|
||||
|
||||
HTTP_CODE=$(curl -s -w "%{http_code}" -o "$RESPONSE_FILE" \
|
||||
-F "fileId=$FILE_ID" \
|
||||
-F "angle=270" \
|
||||
-H "Accept: application/pdf" \
|
||||
http://localhost:8080/api/v1/general/rotate-pdf)
|
||||
|
||||
echo "Response status code: $HTTP_CODE"
|
||||
|
||||
if [[ $HTTP_CODE -ge 200 && $HTTP_CODE -lt 300 ]]; then
|
||||
# Check if response is a valid PDF
|
||||
if file "$RESPONSE_FILE" | grep -q "PDF document"; then
|
||||
log_success "Test 3 succeeded: Received valid PDF response using fileId (HTTP $HTTP_CODE)"
|
||||
((SUCCESS_COUNT++))
|
||||
# Check if it's a JSON response with an embedded PDF
|
||||
elif grep -q "result" "$RESPONSE_FILE" && grep -q "application/pdf" "$RESPONSE_FILE"; then
|
||||
log_warning "Test 3 partial: Response is a JSON wrapper instead of direct PDF (HTTP $HTTP_CODE)"
|
||||
log_info "The API returned a JSON wrapper. This will be fixed by the JobExecutorService update."
|
||||
((SUCCESS_COUNT++))
|
||||
else
|
||||
log_error "Test 3 failed: Response is neither a valid PDF nor a JSON wrapper (HTTP $HTTP_CODE)"
|
||||
echo "Response content:"
|
||||
cat "$RESPONSE_FILE"
|
||||
((FAILURE_COUNT++))
|
||||
fi
|
||||
else
|
||||
log_error "Test 3 failed: API call with fileId returned error (HTTP $HTTP_CODE)"
|
||||
echo "Response content:"
|
||||
cat "$RESPONSE_FILE"
|
||||
((FAILURE_COUNT++))
|
||||
fi
|
||||
}
|
||||
|
||||
# Test 4: Error handling (invalid angle)
|
||||
function test_error_handling() {
|
||||
separator
|
||||
log_info "Test 4: Error handling (invalid angle)"
|
||||
|
||||
RESPONSE_FILE="$TEST_DIR/error_response.txt"
|
||||
|
||||
log_info "Calling rotate-pdf endpoint with invalid angle=45..."
|
||||
|
||||
# Call the endpoint with an invalid angle (not multiple of 90)
|
||||
HTTP_CODE=$(curl -s -w "%{http_code}" -o "$RESPONSE_FILE" \
|
||||
-F "fileInput=@$TEST_PDF" \
|
||||
-F "angle=45" \
|
||||
http://localhost:8080/api/v1/general/rotate-pdf)
|
||||
|
||||
# Check if we got an error response (4xx or 5xx)
|
||||
if [[ $HTTP_CODE -ge 400 ]]; then
|
||||
log_success "Test 4 succeeded: Received error response for invalid angle (HTTP $HTTP_CODE)"
|
||||
((SUCCESS_COUNT++))
|
||||
else
|
||||
log_error "Test 4 failed: Did not receive error for invalid angle (HTTP $HTTP_CODE)"
|
||||
((FAILURE_COUNT++))
|
||||
fi
|
||||
}
|
||||
|
||||
# Test 5: Non-async operation with fileId from an async job
|
||||
function test_non_async_with_fileid() {
|
||||
separator
|
||||
log_info "Test 5: Non-async operation with fileId from an async job"
|
||||
|
||||
# First, we need to run an async operation to get a fileId
|
||||
ASYNC_RESPONSE="$TEST_DIR/test5_async_response.json"
|
||||
|
||||
log_info "First, submitting an async rotation to get a server-side file..."
|
||||
|
||||
# Call the endpoint with async=true
|
||||
curl -s -o "$ASYNC_RESPONSE" \
|
||||
-F "fileInput=@$TEST_PDF" \
|
||||
-F "angle=90" \
|
||||
"http://localhost:8080/api/v1/general/rotate-pdf?async=true"
|
||||
|
||||
# Extract job ID from response
|
||||
JOB_ID=$(grep -o '"jobId":"[^"]*"' "$ASYNC_RESPONSE" | cut -d':' -f2 | tr -d '"')
|
||||
|
||||
if [ -z "$JOB_ID" ]; then
|
||||
log_error "Test 5 failed: No job ID found in async response"
|
||||
((FAILURE_COUNT++))
|
||||
return
|
||||
fi
|
||||
|
||||
log_success "Received job ID: $JOB_ID"
|
||||
|
||||
# Wait for job to complete
|
||||
log_info "Waiting for async job to complete..."
|
||||
MAX_ATTEMPTS=10
|
||||
ATTEMPT=0
|
||||
JOB_COMPLETED=false
|
||||
|
||||
while [ $ATTEMPT -lt $MAX_ATTEMPTS ]; do
|
||||
((ATTEMPT++))
|
||||
sleep 2
|
||||
|
||||
# Check job status
|
||||
STATUS_FILE="$TEST_DIR/test5_job_status.json"
|
||||
if curl -s -o "$STATUS_FILE" "http://localhost:8080/api/v1/general/job/$JOB_ID"; then
|
||||
if grep -q '"complete":true' "$STATUS_FILE"; then
|
||||
log_success "Async job completed successfully"
|
||||
JOB_COMPLETED=true
|
||||
break
|
||||
elif grep -q '"error":' "$STATUS_FILE"; then
|
||||
ERROR=$(grep -o '"error":"[^"]*"' "$STATUS_FILE" | cut -d':' -f2 | tr -d '"')
|
||||
log_error "Test 5 failed: Async job reported error: $ERROR"
|
||||
((FAILURE_COUNT++))
|
||||
return
|
||||
else
|
||||
log_info "Job still processing (attempt $ATTEMPT/$MAX_ATTEMPTS)..."
|
||||
fi
|
||||
else
|
||||
log_error "Failed to check job status"
|
||||
fi
|
||||
|
||||
if [ $ATTEMPT -eq $MAX_ATTEMPTS ]; then
|
||||
log_error "Test 5 failed: Async job did not complete in time"
|
||||
((FAILURE_COUNT++))
|
||||
return
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$JOB_COMPLETED" = false ]; then
|
||||
log_error "Test 5 failed: Async job did not complete successfully"
|
||||
((FAILURE_COUNT++))
|
||||
return
|
||||
fi
|
||||
|
||||
# Get the job status info
|
||||
RESULT_INFO="$TEST_DIR/test5_job_info.json"
|
||||
curl -s -o "$RESULT_INFO" "http://localhost:8080/api/v1/general/job/$JOB_ID"
|
||||
|
||||
# Try to extract fileId directly from the job info
|
||||
FILE_ID=$(grep -o '"fileId":"[^"]*"' "$RESULT_INFO" | head -1 | cut -d':' -f2 | tr -d '"')
|
||||
|
||||
if [ -z "$FILE_ID" ]; then
|
||||
log_error "Test 5 failed: Could not find fileId in job result"
|
||||
echo "Job result content:"
|
||||
cat "$RESULT_INFO"
|
||||
((FAILURE_COUNT++))
|
||||
return
|
||||
fi
|
||||
|
||||
log_success "Extracted fileId from job result: $FILE_ID"
|
||||
|
||||
# Now use the fileId to rotate the PDF with a non-async operation
|
||||
RESPONSE_FILE="$TEST_DIR/test5_response.pdf"
|
||||
|
||||
log_info "Calling rotate-pdf endpoint with fileId and angle=180 (non-async)..."
|
||||
|
||||
HTTP_CODE=$(curl -s -w "%{http_code}" -o "$RESPONSE_FILE" \
|
||||
-F "fileId=$FILE_ID" \
|
||||
-F "angle=180" \
|
||||
-H "Accept: application/pdf" \
|
||||
http://localhost:8080/api/v1/general/rotate-pdf)
|
||||
|
||||
echo "Response status code: $HTTP_CODE"
|
||||
|
||||
if [[ $HTTP_CODE -ge 200 && $HTTP_CODE -lt 300 ]]; then
|
||||
# Check if response is a valid PDF
|
||||
if file "$RESPONSE_FILE" | grep -q "PDF document"; then
|
||||
log_success "Test 5 succeeded: Received valid PDF response using fileId in non-async mode (HTTP $HTTP_CODE)"
|
||||
((SUCCESS_COUNT++))
|
||||
else
|
||||
log_error "Test 5 failed: Response is not a valid PDF (HTTP $HTTP_CODE)"
|
||||
echo "Response content type:"
|
||||
file "$RESPONSE_FILE"
|
||||
((FAILURE_COUNT++))
|
||||
fi
|
||||
else
|
||||
log_error "Test 5 failed: API call with fileId returned error (HTTP $HTTP_CODE)"
|
||||
echo "Response content:"
|
||||
cat "$RESPONSE_FILE"
|
||||
((FAILURE_COUNT++))
|
||||
fi
|
||||
}
|
||||
|
||||
# Run tests
|
||||
check_service || exit 1
|
||||
|
||||
echo "Starting Test 1"
|
||||
test_sync_file_upload
|
||||
echo "Test 1 completed"
|
||||
|
||||
echo "Starting Test 2"
|
||||
test_async_file_upload
|
||||
echo "Test 2 completed"
|
||||
|
||||
echo "Starting Test 3"
|
||||
test_file_id
|
||||
echo "Test 3 completed"
|
||||
|
||||
echo "Starting Test 4"
|
||||
test_error_handling
|
||||
echo "Test 4 completed"
|
||||
|
||||
echo "Starting Test 5"
|
||||
test_non_async_with_fileid
|
||||
echo "Test 5 completed"
|
||||
|
||||
# Calculate test duration
|
||||
END_TIME=$(date +%s)
|
||||
DURATION=$((END_TIME - START_TIME))
|
||||
|
||||
# Generate report
|
||||
separator
|
||||
echo -e "${BLUE}==== AutoJobPostMapping Test Report ====${NC}"
|
||||
echo -e "Test duration: ${DURATION} seconds"
|
||||
echo -e "Tests passed: ${GREEN}${SUCCESS_COUNT}${NC}"
|
||||
echo -e "Tests failed: ${RED}${FAILURE_COUNT}${NC}"
|
||||
echo -e "Total tests: $((SUCCESS_COUNT + FAILURE_COUNT))"
|
||||
echo -e "Success rate: $(( (SUCCESS_COUNT * 100) / (SUCCESS_COUNT + FAILURE_COUNT) ))%"
|
||||
|
||||
if [ $FAILURE_COUNT -eq 0 ]; then
|
||||
echo -e "\n${GREEN}All tests passed successfully!${NC}"
|
||||
else
|
||||
echo -e "\n${RED}Some tests failed. Check the logs above for details.${NC}"
|
||||
fi
|
||||
separator
|
||||
|
||||
# Clean up
|
||||
# Uncomment the following line to keep test files for inspection
|
||||
# rm -rf "$TEST_DIR"
|
||||
|
||||
echo "Test files are available in $TEST_DIR"
|
||||
@@ -6,7 +6,6 @@
|
||||
# ___) || | | || _ <| |___ | || |\ | |_| |_____| __/| |_| | _| #
|
||||
# |____/ |_| |___|_| \_\_____|___|_| \_|\____| |_| |____/|_| #
|
||||
# #
|
||||
# Custom setting.yml file with all endpoints disabled to only be used for testing purposes #
|
||||
# Do not comment out any entry, it will be removed on next startup #
|
||||
# If you want to override with environment parameter follow parameter naming SECURITY_INITIALLOGIN_USERNAME #
|
||||
#############################################################################################################
|
||||
@@ -65,12 +64,13 @@ premium:
|
||||
key: 00000000-0000-0000-0000-000000000000
|
||||
enabled: false # Enable license key checks for pro/enterprise features
|
||||
proFeatures:
|
||||
database: true # Enable database features
|
||||
SSOAutoLogin: false
|
||||
CustomMetadata:
|
||||
autoUpdateMetadata: false # set to 'true' to automatically update metadata with below values
|
||||
author: username # supports text such as 'John Doe' or types such as username to autopopulate with user's username
|
||||
creator: Stirling-PDF # supports text such as 'Company-PDF'
|
||||
producer: Stirling-PDF # supports text such as 'Company-PDF'
|
||||
autoUpdateMetadata: false
|
||||
author: username
|
||||
creator: Stirling-PDF
|
||||
producer: Stirling-PDF
|
||||
googleDrive:
|
||||
enabled: false
|
||||
clientId: ''
|
||||
@@ -127,7 +127,7 @@ ui:
|
||||
appNameNavbar: '' # name displayed on the navigation bar
|
||||
languages: [] # If empty, all languages are enabled. To display only German and Polish ["de_DE", "pl_PL"]. British English is always enabled.
|
||||
|
||||
endpoints: # All the possible endpoints are disabled
|
||||
endpoints:
|
||||
toRemove: [crop, merge-pdfs, multi-page-layout, overlay-pdfs, pdf-to-single-page, rearrange-pages, remove-image-pdf, remove-pages, rotate-pdf, scale-pages, split-by-size-or-count, split-pages, split-pdf-by-chapters, split-pdf-by-sections, add-password, add-watermark, auto-redact, cert-sign, get-info-on-pdf, redact, remove-cert-sign, remove-password, sanitize-pdf, validate-signature, file-to-pdf, html-to-pdf, img-to-pdf, markdown-to-pdf, pdf-to-csv, pdf-to-html, pdf-to-img, pdf-to-markdown, pdf-to-pdfa, pdf-to-presentation, pdf-to-text, pdf-to-word, pdf-to-xml, url-to-pdf, add-image, add-page-numbers, add-stamp, auto-rename, auto-split-pdf, compress-pdf, decompress-pdf, extract-image-scans, extract-images, flatten, ocr-pdf, remove-blanks, repair, replace-invert-pdf, show-javascript, update-metadata, filter-contains-image, filter-contains-text, filter-file-size, filter-page-count, filter-page-rotation, filter-page-size] # list endpoints to disable (e.g. ['img-to-pdf', 'remove-pages'])
|
||||
groupsToRemove: [] # list groups to disable (e.g. ['LibreOffice'])
|
||||
|
||||
@@ -138,7 +138,7 @@ metrics:
|
||||
AutomaticallyGenerated:
|
||||
key: cbb81c0f-50b1-450c-a2b5-89ae527776eb
|
||||
UUID: 10dd4fba-01fa-4717-9b78-3dc4f54e398a
|
||||
appVersion: 0.44.3
|
||||
appVersion: 0.46.2
|
||||
|
||||
processExecutor:
|
||||
sessionLimit: # Process executor instances limits
|
||||
|
||||
@@ -25,7 +25,7 @@ Feature: API Validation
|
||||
| password | wrongPassword |
|
||||
When I send the API request to the endpoint "/api/v1/security/remove-password"
|
||||
Then the response status code should be 500
|
||||
And the response should contain error message "Internal Server Error"
|
||||
And the response should contain error message "Job failed: org.apache.pdfbox.pdmodel.encryption.InvalidPasswordException: Cannot decrypt PDF, the password is incorrect"
|
||||
|
||||
@positive @info
|
||||
Scenario: Get info
|
||||
|
||||
Reference in New Issue
Block a user