From 0328333d812d0551d3b4d35780120bc2989dec49 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com.> Date: Mon, 2 Jun 2025 01:19:54 +0100 Subject: [PATCH] websocket fun! --- common/build.gradle | 3 + .../annotations/AutoJobPostMapping.java | 19 + .../software/common/aop/AutoJobAspect.java | 91 +++ .../common/config/WebSocketConfig.java | 23 + .../WebSocketProgressController.java | 27 + .../software/common/model/api/PDFFile.java | 6 +- .../common/model/job/JobProgress.java | 15 + .../common/model/job/JobResponse.java | 14 + .../software/common/model/job/JobResult.java | 115 ++++ .../common/service/FileOrUploadService.java | 78 +++ .../software/common/service/FileStorage.java | 152 +++++ .../common/service/JobExecutorService.java | 262 ++++++++ .../software/common/service/TaskManager.java | 186 ++++++ .../SPDF/config/CleanUrlInterceptor.java | 3 +- .../controller/api/RotationController.java | 3 +- test_autojob.sh | 577 ++++++++++++++++++ 16 files changed, 1571 insertions(+), 3 deletions(-) create mode 100644 common/src/main/java/stirling/software/common/annotations/AutoJobPostMapping.java create mode 100644 common/src/main/java/stirling/software/common/aop/AutoJobAspect.java create mode 100644 common/src/main/java/stirling/software/common/config/WebSocketConfig.java create mode 100644 common/src/main/java/stirling/software/common/controller/WebSocketProgressController.java create mode 100644 common/src/main/java/stirling/software/common/model/job/JobProgress.java create mode 100644 common/src/main/java/stirling/software/common/model/job/JobResponse.java create mode 100644 common/src/main/java/stirling/software/common/model/job/JobResult.java create mode 100644 common/src/main/java/stirling/software/common/service/FileOrUploadService.java create mode 100644 common/src/main/java/stirling/software/common/service/FileStorage.java create mode 100644 common/src/main/java/stirling/software/common/service/JobExecutorService.java create mode 100644 common/src/main/java/stirling/software/common/service/TaskManager.java create mode 100644 test_autojob.sh diff --git a/common/build.gradle b/common/build.gradle index 64b98b88b..80d909bf6 100644 --- a/common/build.gradle +++ b/common/build.gradle @@ -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' diff --git a/common/src/main/java/stirling/software/common/annotations/AutoJobPostMapping.java b/common/src/main/java/stirling/software/common/annotations/AutoJobPostMapping.java new file mode 100644 index 000000000..41793da82 --- /dev/null +++ b/common/src/main/java/stirling/software/common/annotations/AutoJobPostMapping.java @@ -0,0 +1,19 @@ +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"}; +} diff --git a/common/src/main/java/stirling/software/common/aop/AutoJobAspect.java b/common/src/main/java/stirling/software/common/aop/AutoJobAspect.java new file mode 100644 index 000000000..859621557 --- /dev/null +++ b/common/src/main/java/stirling/software/common/aop/AutoJobAspect.java @@ -0,0 +1,91 @@ +package stirling.software.common.aop; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.io.IOException; + +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.model.api.PDFFile; +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; + + @Around("@annotation(autoJobPostMapping)") + public Object wrapWithJobExecution( + ProceedingJoinPoint joinPoint, AutoJobPostMapping autoJobPostMapping) { + boolean async = Boolean.parseBoolean(request.getParameter("async")); + + // Inspect and possibly mutate arguments + Object[] args = joinPoint.getArgs(); + boolean isAsyncRequest = async; + + 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 (isAsyncRequest && 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); + } + } + } + } + + // Wrap job execution + return jobExecutorService.runJobGeneric( + async, + () -> { + try { + return joinPoint.proceed(args); + } catch (Throwable ex) { + throw new RuntimeException(ex); + } + }); + } +} diff --git a/common/src/main/java/stirling/software/common/config/WebSocketConfig.java b/common/src/main/java/stirling/software/common/config/WebSocketConfig.java new file mode 100644 index 000000000..493f51baf --- /dev/null +++ b/common/src/main/java/stirling/software/common/config/WebSocketConfig.java @@ -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(); + } +} diff --git a/common/src/main/java/stirling/software/common/controller/WebSocketProgressController.java b/common/src/main/java/stirling/software/common/controller/WebSocketProgressController.java new file mode 100644 index 000000000..f58392cf3 --- /dev/null +++ b/common/src/main/java/stirling/software/common/controller/WebSocketProgressController.java @@ -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); + } + } +} diff --git a/common/src/main/java/stirling/software/common/model/api/PDFFile.java b/common/src/main/java/stirling/software/common/model/api/PDFFile.java index 8ea3f0456..cc564f81e 100644 --- a/common/src/main/java/stirling/software/common/model/api/PDFFile.java +++ b/common/src/main/java/stirling/software/common/model/api/PDFFile.java @@ -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; } diff --git a/common/src/main/java/stirling/software/common/model/job/JobProgress.java b/common/src/main/java/stirling/software/common/model/job/JobProgress.java new file mode 100644 index 000000000..e8cbdb6ca --- /dev/null +++ b/common/src/main/java/stirling/software/common/model/job/JobProgress.java @@ -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; +} diff --git a/common/src/main/java/stirling/software/common/model/job/JobResponse.java b/common/src/main/java/stirling/software/common/model/job/JobResponse.java new file mode 100644 index 000000000..bd98955f0 --- /dev/null +++ b/common/src/main/java/stirling/software/common/model/job/JobResponse.java @@ -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 { + private boolean async; + private String jobId; + private T result; +} diff --git a/common/src/main/java/stirling/software/common/model/job/JobResult.java b/common/src/main/java/stirling/software/common/model/job/JobResult.java new file mode 100644 index 000000000..93c41ff4c --- /dev/null +++ b/common/src/main/java/stirling/software/common/model/job/JobResult.java @@ -0,0 +1,115 @@ +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(); + } +} \ No newline at end of file diff --git a/common/src/main/java/stirling/software/common/service/FileOrUploadService.java b/common/src/main/java/stirling/software/common/service/FileOrUploadService.java new file mode 100644 index 000000000..0b72d3dc8 --- /dev/null +++ b/common/src/main/java/stirling/software/common/service/FileOrUploadService.java @@ -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); + } + } +} diff --git a/common/src/main/java/stirling/software/common/service/FileStorage.java b/common/src/main/java/stirling/software/common/service/FileStorage.java new file mode 100644 index 000000000..64cffd00f --- /dev/null +++ b/common/src/main/java/stirling/software/common/service/FileStorage.java @@ -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(); + } +} \ No newline at end of file diff --git a/common/src/main/java/stirling/software/common/service/JobExecutorService.java b/common/src/main/java/stirling/software/common/service/JobExecutorService.java new file mode 100644 index 000000000..94c9824da --- /dev/null +++ b/common/src/main/java/stirling/software/common/service/JobExecutorService.java @@ -0,0 +1,262 @@ +package stirling.software.common.service; + +import java.io.IOException; +import java.util.UUID; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.function.Supplier; + +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 lombok.RequiredArgsConstructor; +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; + +/** + * Service for executing jobs asynchronously or synchronously + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class JobExecutorService { + + private final TaskManager taskManager; + private final WebSocketProgressController webSocketSender; + private final FileStorage fileStorage; + private final ExecutorService executor = Executors.newCachedThreadPool(); + + /** + * 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 work) { + String jobId = UUID.randomUUID().toString(); + log.debug("Running job with ID: {}, async: {}", jobId, async); + + if (async) { + taskManager.createTask(jobId); + webSocketSender.sendProgress(jobId, new JobProgress(jobId, "Started", 0, "Running")); + + executor.execute( + () -> { + try { + Object result = work.get(); + processJobResult(jobId, result); + webSocketSender.sendProgress( + jobId, new JobProgress(jobId, "Done", 100, "Complete")); + } 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 { + Object result = work.get(); + + // 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 (Exception e) { + log.error("Error executing synchronous job: {}", e.getMessage(), e); + return ResponseEntity.internalServerError().body("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 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); + } + } +} diff --git a/common/src/main/java/stirling/software/common/service/TaskManager.java b/common/src/main/java/stirling/software/common/service/TaskManager.java new file mode 100644 index 000000000..41c133219 --- /dev/null +++ b/common/src/main/java/stirling/software/common/service/TaskManager.java @@ -0,0 +1,186 @@ +package stirling.software.common.service; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.model.job.JobResult; + +/** + * Manages async tasks and their results + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class TaskManager { + private final Map jobResults = new ConcurrentHashMap<>(); + + @Value("${stirling.jobResultExpiryMinutes:30}") + private int jobResultExpiryMinutes = 30; + + private final FileStorage fileStorage; + + /** + * 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 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); + } + + /** + * REST controller for job-related endpoints + */ + @RestController + public class JobController { + + /** + * 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 = jobResults.get(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 = jobResults.get(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()); + } + } +} diff --git a/src/main/java/stirling/software/SPDF/config/CleanUrlInterceptor.java b/src/main/java/stirling/software/SPDF/config/CleanUrlInterceptor.java index cc9daff83..c55eecad6 100644 --- a/src/main/java/stirling/software/SPDF/config/CleanUrlInterceptor.java +++ b/src/main/java/stirling/software/SPDF/config/CleanUrlInterceptor.java @@ -23,7 +23,8 @@ public class CleanUrlInterceptor implements HandlerInterceptor { "errorOAuth", "file", "messageType", - "infoMessage"); + "infoMessage", + "async"); @Override public boolean preHandle( diff --git a/src/main/java/stirling/software/SPDF/controller/api/RotationController.java b/src/main/java/stirling/software/SPDF/controller/api/RotationController.java index afdfc54d9..e48551991 100644 --- a/src/main/java/stirling/software/SPDF/controller/api/RotationController.java +++ b/src/main/java/stirling/software/SPDF/controller/api/RotationController.java @@ -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 = diff --git a/test_autojob.sh b/test_autojob.sh new file mode 100644 index 000000000..99a00b562 --- /dev/null +++ b/test_autojob.sh @@ -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" \ No newline at end of file