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.
This commit is contained in:
JSLMPR 2026-07-08 22:28:20 +02:00
parent 372d2d6fa6
commit bcf2fa40bb
11 changed files with 156 additions and 3 deletions

View File

@ -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<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex,

View File

@ -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);

View File

@ -0,0 +1,8 @@
package org.example.videoclips.application;
public class UnsupportedMediaTypeException extends RuntimeException {
public UnsupportedMediaTypeException(String message) {
super(message);
}
}

View File

@ -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<String, Object> 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<String, Object> getUploadSessionResponse(String assetId) {
VideoAsset asset = getAsset(assetId);
UploadSession uploadSession = requireUploadSession(asset.uploadId());
Map<String, Object> 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<String, Object> listAssetJobs(String assetId) {
getAsset(assetId);
List<Map<String, Object>> 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<String, Object> toJobResponse(ClipJob job) {
Map<String, Object> response = new LinkedHashMap<>();
response.put("jobId", job.id());

View File

@ -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<String> 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<String> getAllowedContentTypes() {
return allowedContentTypes;
}
public void setAllowedContentTypes(List<String> allowedContentTypes) {
this.allowedContentTypes = allowedContentTypes;
}
}

View File

@ -6,6 +6,9 @@ public record UploadSession(
String id,
String assetId,
Status status,
long partSizeBytes,
int partCount,
Instant expiresAt,
Instant completedAt
) {
public enum Status {

View File

@ -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);
}

View File

@ -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();
}
}

View File

@ -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();
}
}

View File

@ -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

View File

@ -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"));
}
}