forked from jsl/video_editing_poc
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:
parent
372d2d6fa6
commit
bcf2fa40bb
|
|
@ -5,6 +5,7 @@ import jakarta.validation.ConstraintViolationException;
|
||||||
import org.example.videoclips.application.NotFoundException;
|
import org.example.videoclips.application.NotFoundException;
|
||||||
import org.example.videoclips.application.PayloadTooLargeException;
|
import org.example.videoclips.application.PayloadTooLargeException;
|
||||||
import org.example.videoclips.application.StateConflictException;
|
import org.example.videoclips.application.StateConflictException;
|
||||||
|
import org.example.videoclips.application.UnsupportedMediaTypeException;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.HttpStatusCode;
|
import org.springframework.http.HttpStatusCode;
|
||||||
import org.springframework.http.ProblemDetail;
|
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);
|
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
|
@Override
|
||||||
protected ResponseEntity<Object> handleMethodArgumentNotValid(
|
protected ResponseEntity<Object> handleMethodArgumentNotValid(
|
||||||
MethodArgumentNotValidException ex,
|
MethodArgumentNotValidException ex,
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,11 @@ public class VideoAssetController {
|
||||||
return videoAssetService.getAssetResponse(assetId);
|
return videoAssetService.getAssetResponse(assetId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{assetId}/upload-session")
|
||||||
|
public Object getUploadSession(@PathVariable String assetId) {
|
||||||
|
return videoAssetService.getUploadSessionResponse(assetId);
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/{assetId}/clip-jobs")
|
@GetMapping("/{assetId}/clip-jobs")
|
||||||
public Object listAssetJobs(@PathVariable String assetId) {
|
public Object listAssetJobs(@PathVariable String assetId) {
|
||||||
return videoAssetService.listAssetJobs(assetId);
|
return videoAssetService.listAssetJobs(assetId);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
package org.example.videoclips.application;
|
||||||
|
|
||||||
|
public class UnsupportedMediaTypeException extends RuntimeException {
|
||||||
|
|
||||||
|
public UnsupportedMediaTypeException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -49,6 +49,9 @@ public class VideoAssetService {
|
||||||
if (request.contentLengthBytes() > properties.getMaxFileSizeBytes()) {
|
if (request.contentLengthBytes() > properties.getMaxFileSizeBytes()) {
|
||||||
throw new PayloadTooLargeException("contentLengthBytes exceeds configured max file size");
|
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());
|
String dedupeKey = normalizeKey(idempotencyKey, "asset:" + request.fileName() + ":" + request.contentLengthBytes());
|
||||||
Map<String, Object> existing = repository.findAssetResponseByIdempotencyKey(dedupeKey);
|
Map<String, Object> existing = repository.findAssetResponseByIdempotencyKey(dedupeKey);
|
||||||
|
|
@ -75,14 +78,23 @@ public class VideoAssetService {
|
||||||
);
|
);
|
||||||
|
|
||||||
repository.saveAsset(asset);
|
repository.saveAsset(asset);
|
||||||
repository.saveUploadSession(new UploadSession(uploadId, assetId, UploadSession.Status.OPEN, null));
|
|
||||||
long partSizeBytes = properties.getMultipartPartSizeBytes();
|
long partSizeBytes = properties.getMultipartPartSizeBytes();
|
||||||
int partCount = Math.max(1, (int) Math.ceil((double) request.contentLengthBytes() / partSizeBytes));
|
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(
|
ObjectStoragePort.UploadSessionDescriptor upload = objectStoragePort.createMultipartUpload(
|
||||||
uploadId,
|
uploadId,
|
||||||
partCount,
|
partCount,
|
||||||
partSizeBytes,
|
partSizeBytes,
|
||||||
now.plus(properties.getUploadUrlTtlMinutes(), ChronoUnit.MINUTES)
|
uploadExpiresAt
|
||||||
);
|
);
|
||||||
appendJobEvent(assetId, "ASSET_CREATED", "Upload session created");
|
appendJobEvent(assetId, "ASSET_CREATED", "Upload session created");
|
||||||
|
|
||||||
|
|
@ -116,7 +128,16 @@ public class VideoAssetService {
|
||||||
|
|
||||||
VideoAsset updated = asset.withUploadCompleted(request.sourceDurationSeconds(), Instant.now());
|
VideoAsset updated = asset.withUploadCompleted(request.sourceDurationSeconds(), Instant.now());
|
||||||
repository.saveAsset(updated);
|
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");
|
appendJobEvent(assetId, "UPLOAD_COMPLETED", "Video upload completed");
|
||||||
|
|
||||||
return Map.of(
|
return Map.of(
|
||||||
|
|
@ -179,6 +200,20 @@ public class VideoAssetService {
|
||||||
return toAssetResponse(asset);
|
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) {
|
public Map<String, Object> listAssetJobs(String assetId) {
|
||||||
getAsset(assetId);
|
getAsset(assetId);
|
||||||
List<Map<String, Object>> jobs = repository.findJobsByAssetId(assetId)
|
List<Map<String, Object>> jobs = repository.findJobsByAssetId(assetId)
|
||||||
|
|
@ -305,6 +340,14 @@ public class VideoAssetService {
|
||||||
return job;
|
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) {
|
private Map<String, Object> toJobResponse(ClipJob job) {
|
||||||
Map<String, Object> response = new LinkedHashMap<>();
|
Map<String, Object> response = new LinkedHashMap<>();
|
||||||
response.put("jobId", job.id());
|
response.put("jobId", job.id());
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import jakarta.validation.constraints.Min;
|
||||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
import org.springframework.validation.annotation.Validated;
|
import org.springframework.validation.annotation.Validated;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
@Validated
|
@Validated
|
||||||
@ConfigurationProperties(prefix = "video-clipping")
|
@ConfigurationProperties(prefix = "video-clipping")
|
||||||
public class VideoClippingProperties {
|
public class VideoClippingProperties {
|
||||||
|
|
@ -20,6 +22,8 @@ public class VideoClippingProperties {
|
||||||
@Min(1)
|
@Min(1)
|
||||||
private int downloadUrlTtlMinutes = 15;
|
private int downloadUrlTtlMinutes = 15;
|
||||||
|
|
||||||
|
private List<String> allowedContentTypes = List.of("video/mp4", "video/quicktime", "video/webm");
|
||||||
|
|
||||||
public long getMaxFileSizeBytes() {
|
public long getMaxFileSizeBytes() {
|
||||||
return maxFileSizeBytes;
|
return maxFileSizeBytes;
|
||||||
}
|
}
|
||||||
|
|
@ -51,4 +55,12 @@ public class VideoClippingProperties {
|
||||||
public void setDownloadUrlTtlMinutes(int downloadUrlTtlMinutes) {
|
public void setDownloadUrlTtlMinutes(int downloadUrlTtlMinutes) {
|
||||||
this.downloadUrlTtlMinutes = downloadUrlTtlMinutes;
|
this.downloadUrlTtlMinutes = downloadUrlTtlMinutes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<String> getAllowedContentTypes() {
|
||||||
|
return allowedContentTypes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAllowedContentTypes(List<String> allowedContentTypes) {
|
||||||
|
this.allowedContentTypes = allowedContentTypes;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,9 @@ public record UploadSession(
|
||||||
String id,
|
String id,
|
||||||
String assetId,
|
String assetId,
|
||||||
Status status,
|
Status status,
|
||||||
|
long partSizeBytes,
|
||||||
|
int partCount,
|
||||||
|
Instant expiresAt,
|
||||||
Instant completedAt
|
Instant completedAt
|
||||||
) {
|
) {
|
||||||
public enum Status {
|
public enum Status {
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,10 @@ public class InMemoryVideoAssetRepository {
|
||||||
uploadSessions.put(uploadSession.id(), uploadSession);
|
uploadSessions.put(uploadSession.id(), uploadSession);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public UploadSession findUploadSessionById(String uploadSessionId) {
|
||||||
|
return uploadSessions.get(uploadSessionId);
|
||||||
|
}
|
||||||
|
|
||||||
public void saveJob(ClipJob job) {
|
public void saveJob(ClipJob job) {
|
||||||
jobs.put(job.id(), job);
|
jobs.put(job.id(), job);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,3 +5,6 @@ video-clipping.max-file-size-bytes=53687091200
|
||||||
video-clipping.multipart-part-size-bytes=104857600
|
video-clipping.multipart-part-size-bytes=104857600
|
||||||
video-clipping.upload-url-ttl-minutes=60
|
video-clipping.upload-url-ttl-minutes=60
|
||||||
video-clipping.download-url-ttl-minutes=15
|
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
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,11 @@ class VideoAssetControllerTest {
|
||||||
.andExpect(jsonPath("$.assetId").value(assetId))
|
.andExpect(jsonPath("$.assetId").value(assetId))
|
||||||
.andExpect(jsonPath("$.status").value("UPLOADED"));
|
.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))
|
mockMvc.perform(get("/v1/video-assets/{assetId}/events", assetId))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.events.length()", greaterThanOrEqualTo(2)));
|
.andExpect(jsonPath("$.events.length()", greaterThanOrEqualTo(2)));
|
||||||
|
|
@ -159,4 +164,20 @@ class VideoAssetControllerTest {
|
||||||
.andExpect(status().isPayloadTooLarge())
|
.andExpect(status().isPayloadTooLarge())
|
||||||
.andExpect(jsonPath("$.type").value("https://api.example.com/problems/file-too-large"));
|
.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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue