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.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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
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());
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@ public record UploadSession(
|
|||
String id,
|
||||
String assetId,
|
||||
Status status,
|
||||
long partSizeBytes,
|
||||
int partCount,
|
||||
Instant expiresAt,
|
||||
Instant completedAt
|
||||
) {
|
||||
public enum Status {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.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
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue