From bcf2fa40bbc4a1d7599a6fc9dd925e11c146181e Mon Sep 17 00:00:00 2001 From: JSLMPR Date: Wed, 8 Jul 2026 22:28:20 +0200 Subject: [PATCH] This pass added explicit upload-session state and stricter media validation. GET /v1/video-assets/{assetId}/upload-session is now implemented in src/main/java/org/example/videoclips/api/ VideoAssetController.java, backed by richer UploadSession metadata in src/main/java/org/example/videoclips/domain/UploadSession.java. The service now stores part size, part count, expiry, and completion time, and returns them through src/main/java/org/example/videoclips/application/VideoAssetService.java. I also added a configurable media-type allowlist in src/main/java/org/example/ videoclips/config/VideoClippingProperties.java and reject unsupported uploads with 415 via src/main/java/org/example/videoclips/application/UnsupportedMediaTypeException.java and src/main/java/ org/example/videoclips/api/ApiExceptionHandler.java. I also added adapter-level health visibility for operations. src/main/java/org/example/videoclips/observability/ObjectStorageHealthIndicator.java and src/main/java/org/example/videoclips/ observability/QueueHealthIndicator.java now expose basic readiness details through Actuator. Tests were extended in src/test/java/org/example/videoclips/api/VideoAssetControllerTest.java to cover the upload-session read path and the unsupported-media-type case. --- .../videoclips/api/ApiExceptionHandler.java | 6 +++ .../videoclips/api/VideoAssetController.java | 5 ++ .../UnsupportedMediaTypeException.java | 8 +++ .../application/VideoAssetService.java | 49 +++++++++++++++++-- .../config/VideoClippingProperties.java | 12 +++++ .../videoclips/domain/UploadSession.java | 3 ++ .../InMemoryVideoAssetRepository.java | 4 ++ .../ObjectStorageHealthIndicator.java | 24 +++++++++ .../observability/QueueHealthIndicator.java | 24 +++++++++ src/main/resources/application.properties | 3 ++ .../api/VideoAssetControllerTest.java | 21 ++++++++ 11 files changed, 156 insertions(+), 3 deletions(-) create mode 100644 src/main/java/org/example/videoclips/application/UnsupportedMediaTypeException.java create mode 100644 src/main/java/org/example/videoclips/observability/ObjectStorageHealthIndicator.java create mode 100644 src/main/java/org/example/videoclips/observability/QueueHealthIndicator.java diff --git a/src/main/java/org/example/videoclips/api/ApiExceptionHandler.java b/src/main/java/org/example/videoclips/api/ApiExceptionHandler.java index 4415932..c03c2c5 100644 --- a/src/main/java/org/example/videoclips/api/ApiExceptionHandler.java +++ b/src/main/java/org/example/videoclips/api/ApiExceptionHandler.java @@ -5,6 +5,7 @@ import jakarta.validation.ConstraintViolationException; import org.example.videoclips.application.NotFoundException; import org.example.videoclips.application.PayloadTooLargeException; import org.example.videoclips.application.StateConflictException; +import org.example.videoclips.application.UnsupportedMediaTypeException; import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatusCode; import org.springframework.http.ProblemDetail; @@ -39,6 +40,11 @@ public class ApiExceptionHandler extends ResponseEntityExceptionHandler { return problem(HttpStatus.PAYLOAD_TOO_LARGE, "file-too-large", ex.getMessage(), request.getRequestURI(), null); } + @ExceptionHandler(UnsupportedMediaTypeException.class) + ProblemDetail handleUnsupportedMediaType(UnsupportedMediaTypeException ex, HttpServletRequest request) { + return problem(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "unsupported-media-type", ex.getMessage(), request.getRequestURI(), null); + } + @Override protected ResponseEntity handleMethodArgumentNotValid( MethodArgumentNotValidException ex, diff --git a/src/main/java/org/example/videoclips/api/VideoAssetController.java b/src/main/java/org/example/videoclips/api/VideoAssetController.java index 01aff2a..c649060 100644 --- a/src/main/java/org/example/videoclips/api/VideoAssetController.java +++ b/src/main/java/org/example/videoclips/api/VideoAssetController.java @@ -45,6 +45,11 @@ public class VideoAssetController { return videoAssetService.getAssetResponse(assetId); } + @GetMapping("/{assetId}/upload-session") + public Object getUploadSession(@PathVariable String assetId) { + return videoAssetService.getUploadSessionResponse(assetId); + } + @GetMapping("/{assetId}/clip-jobs") public Object listAssetJobs(@PathVariable String assetId) { return videoAssetService.listAssetJobs(assetId); diff --git a/src/main/java/org/example/videoclips/application/UnsupportedMediaTypeException.java b/src/main/java/org/example/videoclips/application/UnsupportedMediaTypeException.java new file mode 100644 index 0000000..4379018 --- /dev/null +++ b/src/main/java/org/example/videoclips/application/UnsupportedMediaTypeException.java @@ -0,0 +1,8 @@ +package org.example.videoclips.application; + +public class UnsupportedMediaTypeException extends RuntimeException { + + public UnsupportedMediaTypeException(String message) { + super(message); + } +} diff --git a/src/main/java/org/example/videoclips/application/VideoAssetService.java b/src/main/java/org/example/videoclips/application/VideoAssetService.java index 33497e0..ddec1fa 100644 --- a/src/main/java/org/example/videoclips/application/VideoAssetService.java +++ b/src/main/java/org/example/videoclips/application/VideoAssetService.java @@ -49,6 +49,9 @@ public class VideoAssetService { if (request.contentLengthBytes() > properties.getMaxFileSizeBytes()) { throw new PayloadTooLargeException("contentLengthBytes exceeds configured max file size"); } + if (properties.getAllowedContentTypes().stream().noneMatch(type -> type.equalsIgnoreCase(request.contentType()))) { + throw new UnsupportedMediaTypeException("contentType is not allowed"); + } String dedupeKey = normalizeKey(idempotencyKey, "asset:" + request.fileName() + ":" + request.contentLengthBytes()); Map existing = repository.findAssetResponseByIdempotencyKey(dedupeKey); @@ -75,14 +78,23 @@ public class VideoAssetService { ); repository.saveAsset(asset); - repository.saveUploadSession(new UploadSession(uploadId, assetId, UploadSession.Status.OPEN, null)); long partSizeBytes = properties.getMultipartPartSizeBytes(); int partCount = Math.max(1, (int) Math.ceil((double) request.contentLengthBytes() / partSizeBytes)); + Instant uploadExpiresAt = now.plus(properties.getUploadUrlTtlMinutes(), ChronoUnit.MINUTES); + repository.saveUploadSession(new UploadSession( + uploadId, + assetId, + UploadSession.Status.OPEN, + partSizeBytes, + partCount, + uploadExpiresAt, + null + )); ObjectStoragePort.UploadSessionDescriptor upload = objectStoragePort.createMultipartUpload( uploadId, partCount, partSizeBytes, - now.plus(properties.getUploadUrlTtlMinutes(), ChronoUnit.MINUTES) + uploadExpiresAt ); appendJobEvent(assetId, "ASSET_CREATED", "Upload session created"); @@ -116,7 +128,16 @@ public class VideoAssetService { VideoAsset updated = asset.withUploadCompleted(request.sourceDurationSeconds(), Instant.now()); repository.saveAsset(updated); - repository.saveUploadSession(new UploadSession(request.uploadId(), assetId, UploadSession.Status.COMPLETED, Instant.now())); + UploadSession existingSession = requireUploadSession(request.uploadId()); + repository.saveUploadSession(new UploadSession( + existingSession.id(), + existingSession.assetId(), + UploadSession.Status.COMPLETED, + existingSession.partSizeBytes(), + existingSession.partCount(), + existingSession.expiresAt(), + Instant.now() + )); appendJobEvent(assetId, "UPLOAD_COMPLETED", "Video upload completed"); return Map.of( @@ -179,6 +200,20 @@ public class VideoAssetService { return toAssetResponse(asset); } + public Map getUploadSessionResponse(String assetId) { + VideoAsset asset = getAsset(assetId); + UploadSession uploadSession = requireUploadSession(asset.uploadId()); + Map response = new LinkedHashMap<>(); + response.put("assetId", assetId); + response.put("uploadId", uploadSession.id()); + response.put("status", uploadSession.status().name()); + response.put("partSizeBytes", uploadSession.partSizeBytes()); + response.put("partCount", uploadSession.partCount()); + response.put("expiresAt", uploadSession.expiresAt().toString()); + response.put("completedAt", uploadSession.completedAt() == null ? null : uploadSession.completedAt().toString()); + return response; + } + public Map listAssetJobs(String assetId) { getAsset(assetId); List> jobs = repository.findJobsByAssetId(assetId) @@ -305,6 +340,14 @@ public class VideoAssetService { return job; } + private UploadSession requireUploadSession(String uploadSessionId) { + UploadSession uploadSession = repository.findUploadSessionById(uploadSessionId); + if (uploadSession == null) { + throw new NotFoundException("upload session not found: " + uploadSessionId); + } + return uploadSession; + } + private Map toJobResponse(ClipJob job) { Map response = new LinkedHashMap<>(); response.put("jobId", job.id()); diff --git a/src/main/java/org/example/videoclips/config/VideoClippingProperties.java b/src/main/java/org/example/videoclips/config/VideoClippingProperties.java index 3257ea7..7257bf7 100644 --- a/src/main/java/org/example/videoclips/config/VideoClippingProperties.java +++ b/src/main/java/org/example/videoclips/config/VideoClippingProperties.java @@ -4,6 +4,8 @@ import jakarta.validation.constraints.Min; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.validation.annotation.Validated; +import java.util.List; + @Validated @ConfigurationProperties(prefix = "video-clipping") public class VideoClippingProperties { @@ -20,6 +22,8 @@ public class VideoClippingProperties { @Min(1) private int downloadUrlTtlMinutes = 15; + private List allowedContentTypes = List.of("video/mp4", "video/quicktime", "video/webm"); + public long getMaxFileSizeBytes() { return maxFileSizeBytes; } @@ -51,4 +55,12 @@ public class VideoClippingProperties { public void setDownloadUrlTtlMinutes(int downloadUrlTtlMinutes) { this.downloadUrlTtlMinutes = downloadUrlTtlMinutes; } + + public List getAllowedContentTypes() { + return allowedContentTypes; + } + + public void setAllowedContentTypes(List allowedContentTypes) { + this.allowedContentTypes = allowedContentTypes; + } } diff --git a/src/main/java/org/example/videoclips/domain/UploadSession.java b/src/main/java/org/example/videoclips/domain/UploadSession.java index def9163..89e9a4f 100644 --- a/src/main/java/org/example/videoclips/domain/UploadSession.java +++ b/src/main/java/org/example/videoclips/domain/UploadSession.java @@ -6,6 +6,9 @@ public record UploadSession( String id, String assetId, Status status, + long partSizeBytes, + int partCount, + Instant expiresAt, Instant completedAt ) { public enum Status { diff --git a/src/main/java/org/example/videoclips/infrastructure/InMemoryVideoAssetRepository.java b/src/main/java/org/example/videoclips/infrastructure/InMemoryVideoAssetRepository.java index 34a4311..444c6b4 100644 --- a/src/main/java/org/example/videoclips/infrastructure/InMemoryVideoAssetRepository.java +++ b/src/main/java/org/example/videoclips/infrastructure/InMemoryVideoAssetRepository.java @@ -36,6 +36,10 @@ public class InMemoryVideoAssetRepository { uploadSessions.put(uploadSession.id(), uploadSession); } + public UploadSession findUploadSessionById(String uploadSessionId) { + return uploadSessions.get(uploadSessionId); + } + public void saveJob(ClipJob job) { jobs.put(job.id(), job); } diff --git a/src/main/java/org/example/videoclips/observability/ObjectStorageHealthIndicator.java b/src/main/java/org/example/videoclips/observability/ObjectStorageHealthIndicator.java new file mode 100644 index 0000000..c992089 --- /dev/null +++ b/src/main/java/org/example/videoclips/observability/ObjectStorageHealthIndicator.java @@ -0,0 +1,24 @@ +package org.example.videoclips.observability; + +import org.example.videoclips.storage.ObjectStoragePort; +import org.springframework.boot.actuate.health.Health; +import org.springframework.boot.actuate.health.HealthIndicator; +import org.springframework.stereotype.Component; + +@Component +public class ObjectStorageHealthIndicator implements HealthIndicator { + + private final ObjectStoragePort objectStoragePort; + + public ObjectStorageHealthIndicator(ObjectStoragePort objectStoragePort) { + this.objectStoragePort = objectStoragePort; + } + + @Override + public Health health() { + return Health.up() + .withDetail("adapter", objectStoragePort.getClass().getSimpleName()) + .withDetail("capability", "multipart-upload-and-signed-urls") + .build(); + } +} diff --git a/src/main/java/org/example/videoclips/observability/QueueHealthIndicator.java b/src/main/java/org/example/videoclips/observability/QueueHealthIndicator.java new file mode 100644 index 0000000..047d401 --- /dev/null +++ b/src/main/java/org/example/videoclips/observability/QueueHealthIndicator.java @@ -0,0 +1,24 @@ +package org.example.videoclips.observability; + +import org.example.videoclips.queue.ClipJobQueuePort; +import org.springframework.boot.actuate.health.Health; +import org.springframework.boot.actuate.health.HealthIndicator; +import org.springframework.stereotype.Component; + +@Component +public class QueueHealthIndicator implements HealthIndicator { + + private final ClipJobQueuePort clipJobQueuePort; + + public QueueHealthIndicator(ClipJobQueuePort clipJobQueuePort) { + this.clipJobQueuePort = clipJobQueuePort; + } + + @Override + public Health health() { + return Health.up() + .withDetail("adapter", clipJobQueuePort.getClass().getSimpleName()) + .withDetail("capability", "job-publish") + .build(); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index eada680..e576da2 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -5,3 +5,6 @@ video-clipping.max-file-size-bytes=53687091200 video-clipping.multipart-part-size-bytes=104857600 video-clipping.upload-url-ttl-minutes=60 video-clipping.download-url-ttl-minutes=15 +video-clipping.allowed-content-types[0]=video/mp4 +video-clipping.allowed-content-types[1]=video/quicktime +video-clipping.allowed-content-types[2]=video/webm diff --git a/src/test/java/org/example/videoclips/api/VideoAssetControllerTest.java b/src/test/java/org/example/videoclips/api/VideoAssetControllerTest.java index 9fe5308..3fadfef 100644 --- a/src/test/java/org/example/videoclips/api/VideoAssetControllerTest.java +++ b/src/test/java/org/example/videoclips/api/VideoAssetControllerTest.java @@ -66,6 +66,11 @@ class VideoAssetControllerTest { .andExpect(jsonPath("$.assetId").value(assetId)) .andExpect(jsonPath("$.status").value("UPLOADED")); + mockMvc.perform(get("/v1/video-assets/{assetId}/upload-session", assetId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.uploadId").value(uploadId)) + .andExpect(jsonPath("$.status").value("COMPLETED")); + mockMvc.perform(get("/v1/video-assets/{assetId}/events", assetId)) .andExpect(status().isOk()) .andExpect(jsonPath("$.events.length()", greaterThanOrEqualTo(2))); @@ -159,4 +164,20 @@ class VideoAssetControllerTest { .andExpect(status().isPayloadTooLarge()) .andExpect(jsonPath("$.type").value("https://api.example.com/problems/file-too-large")); } + + @Test + void rejectsUnsupportedMediaTypeRequest() throws Exception { + mockMvc.perform(post("/v1/video-assets") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "fileName": "demo.avi", + "contentType": "video/x-msvideo", + "contentLengthBytes": 4096, + "checksumSha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + """)) + .andExpect(status().isUnsupportedMediaType()) + .andExpect(jsonPath("$.type").value("https://api.example.com/problems/unsupported-media-type")); + } }