forked from jsl/video_editing_poc
DELETE /v1/video-assets/{assetId} now does more than flip a flag. In src/main/java/org/example/videoclips/application/VideoAssetService.java it now:
- cancels any active jobs for that asset - aborts an open upload session - records an asset deletion event - prevents clip download URLs from being created for clips that belong to deleted assets I also added a regression test for that behavior in src/test/java/org/example/videoclips/api/VideoAssetControllerTest.java, along with the earlier repository-port and operations work. The application and worker layers now depend on VideoClippingRepository instead of the concrete in-memory adapter, which is the right base for a future PostgreSQL adapter.
This commit is contained in:
parent
bcf2fa40bb
commit
067fc782a3
|
|
@ -12,7 +12,6 @@ import org.example.videoclips.domain.ClipJobStatus;
|
||||||
import org.example.videoclips.domain.JobEvent;
|
import org.example.videoclips.domain.JobEvent;
|
||||||
import org.example.videoclips.domain.UploadSession;
|
import org.example.videoclips.domain.UploadSession;
|
||||||
import org.example.videoclips.domain.VideoAsset;
|
import org.example.videoclips.domain.VideoAsset;
|
||||||
import org.example.videoclips.infrastructure.InMemoryVideoAssetRepository;
|
|
||||||
import org.example.videoclips.queue.ClipJobQueuePort;
|
import org.example.videoclips.queue.ClipJobQueuePort;
|
||||||
import org.example.videoclips.storage.ObjectStoragePort;
|
import org.example.videoclips.storage.ObjectStoragePort;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
@ -28,13 +27,13 @@ import java.util.UUID;
|
||||||
@Service
|
@Service
|
||||||
public class VideoAssetService {
|
public class VideoAssetService {
|
||||||
|
|
||||||
private final InMemoryVideoAssetRepository repository;
|
private final VideoClippingRepository repository;
|
||||||
private final ObjectStoragePort objectStoragePort;
|
private final ObjectStoragePort objectStoragePort;
|
||||||
private final ClipJobQueuePort clipJobQueuePort;
|
private final ClipJobQueuePort clipJobQueuePort;
|
||||||
private final VideoClippingProperties properties;
|
private final VideoClippingProperties properties;
|
||||||
|
|
||||||
public VideoAssetService(
|
public VideoAssetService(
|
||||||
InMemoryVideoAssetRepository repository,
|
VideoClippingRepository repository,
|
||||||
ObjectStoragePort objectStoragePort,
|
ObjectStoragePort objectStoragePort,
|
||||||
ClipJobQueuePort clipJobQueuePort,
|
ClipJobQueuePort clipJobQueuePort,
|
||||||
VideoClippingProperties properties
|
VideoClippingProperties properties
|
||||||
|
|
@ -275,7 +274,28 @@ public class VideoAssetService {
|
||||||
|
|
||||||
public void deleteAsset(String assetId) {
|
public void deleteAsset(String assetId) {
|
||||||
VideoAsset asset = getAsset(assetId);
|
VideoAsset asset = getAsset(assetId);
|
||||||
|
repository.findJobsByAssetId(assetId).stream()
|
||||||
|
.filter(job -> job.status() != ClipJobStatus.SUCCEEDED
|
||||||
|
&& job.status() != ClipJobStatus.FAILED
|
||||||
|
&& job.status() != ClipJobStatus.CANCELLED)
|
||||||
|
.forEach(job -> {
|
||||||
|
repository.saveJob(job.withStatus(ClipJobStatus.CANCELLED).withFinishedAt(Instant.now()));
|
||||||
|
appendEvent(job.id(), "CANCELLED", "Job cancelled due to asset deletion");
|
||||||
|
});
|
||||||
|
UploadSession uploadSession = requireUploadSession(asset.uploadId());
|
||||||
|
if (uploadSession.status() == UploadSession.Status.OPEN) {
|
||||||
|
repository.saveUploadSession(new UploadSession(
|
||||||
|
uploadSession.id(),
|
||||||
|
uploadSession.assetId(),
|
||||||
|
UploadSession.Status.ABORTED,
|
||||||
|
uploadSession.partSizeBytes(),
|
||||||
|
uploadSession.partCount(),
|
||||||
|
uploadSession.expiresAt(),
|
||||||
|
uploadSession.completedAt()
|
||||||
|
));
|
||||||
|
}
|
||||||
repository.saveAsset(asset.withDeleted());
|
repository.saveAsset(asset.withDeleted());
|
||||||
|
appendJobEvent(assetId, "ASSET_DELETED", "Asset deleted and associated processing cancelled");
|
||||||
}
|
}
|
||||||
|
|
||||||
public SignedUrlResponse createClipDownloadUrl(String clipId) {
|
public SignedUrlResponse createClipDownloadUrl(String clipId) {
|
||||||
|
|
@ -283,6 +303,7 @@ public class VideoAssetService {
|
||||||
if (clip == null) {
|
if (clip == null) {
|
||||||
throw new NotFoundException("clip not found: " + clipId);
|
throw new NotFoundException("clip not found: " + clipId);
|
||||||
}
|
}
|
||||||
|
getAsset(clip.assetId());
|
||||||
Instant expiresAt = Instant.now().plus(properties.getDownloadUrlTtlMinutes(), ChronoUnit.MINUTES);
|
Instant expiresAt = Instant.now().plus(properties.getDownloadUrlTtlMinutes(), ChronoUnit.MINUTES);
|
||||||
return new SignedUrlResponse(
|
return new SignedUrlResponse(
|
||||||
clipId,
|
clipId,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
package org.example.videoclips.application;
|
||||||
|
|
||||||
|
import org.example.videoclips.domain.Clip;
|
||||||
|
import org.example.videoclips.domain.ClipJob;
|
||||||
|
import org.example.videoclips.domain.JobEvent;
|
||||||
|
import org.example.videoclips.domain.UploadSession;
|
||||||
|
import org.example.videoclips.domain.VideoAsset;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public interface VideoClippingRepository {
|
||||||
|
|
||||||
|
void saveAsset(VideoAsset asset);
|
||||||
|
|
||||||
|
VideoAsset findAssetById(String assetId);
|
||||||
|
|
||||||
|
void saveUploadSession(UploadSession uploadSession);
|
||||||
|
|
||||||
|
UploadSession findUploadSessionById(String uploadSessionId);
|
||||||
|
|
||||||
|
void saveJob(ClipJob job);
|
||||||
|
|
||||||
|
ClipJob findJobById(String jobId);
|
||||||
|
|
||||||
|
List<ClipJob> findJobsByAssetId(String assetId);
|
||||||
|
|
||||||
|
void saveClip(Clip clip);
|
||||||
|
|
||||||
|
List<Clip> findClipsByJobId(String jobId);
|
||||||
|
|
||||||
|
Clip findClipById(String clipId);
|
||||||
|
|
||||||
|
void saveJobEvent(JobEvent event);
|
||||||
|
|
||||||
|
List<JobEvent> findEventsByAggregateId(String aggregateId);
|
||||||
|
|
||||||
|
boolean hasActiveJobForAsset(String assetId);
|
||||||
|
|
||||||
|
Map<String, Object> findAssetResponseByIdempotencyKey(String key);
|
||||||
|
|
||||||
|
void storeAssetResponseByIdempotencyKey(String key, Map<String, Object> response);
|
||||||
|
|
||||||
|
Map<String, Object> findJobResponseByIdempotencyKey(String key);
|
||||||
|
|
||||||
|
void storeJobResponseByIdempotencyKey(String key, Map<String, Object> response);
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package org.example.videoclips.infrastructure;
|
package org.example.videoclips.infrastructure;
|
||||||
|
|
||||||
|
import org.example.videoclips.application.VideoClippingRepository;
|
||||||
import org.example.videoclips.domain.Clip;
|
import org.example.videoclips.domain.Clip;
|
||||||
import org.example.videoclips.domain.ClipJob;
|
import org.example.videoclips.domain.ClipJob;
|
||||||
import org.example.videoclips.domain.ClipJobStatus;
|
import org.example.videoclips.domain.ClipJobStatus;
|
||||||
|
|
@ -14,7 +15,7 @@ import java.util.Map;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
public class InMemoryVideoAssetRepository {
|
public class InMemoryVideoAssetRepository implements VideoClippingRepository {
|
||||||
|
|
||||||
private final Map<String, VideoAsset> assets = new ConcurrentHashMap<>();
|
private final Map<String, VideoAsset> assets = new ConcurrentHashMap<>();
|
||||||
private final Map<String, UploadSession> uploadSessions = new ConcurrentHashMap<>();
|
private final Map<String, UploadSession> uploadSessions = new ConcurrentHashMap<>();
|
||||||
|
|
@ -24,30 +25,37 @@ public class InMemoryVideoAssetRepository {
|
||||||
private final Map<String, Map<String, Object>> assetResponsesByIdempotencyKey = new ConcurrentHashMap<>();
|
private final Map<String, Map<String, Object>> assetResponsesByIdempotencyKey = new ConcurrentHashMap<>();
|
||||||
private final Map<String, Map<String, Object>> jobResponsesByIdempotencyKey = new ConcurrentHashMap<>();
|
private final Map<String, Map<String, Object>> jobResponsesByIdempotencyKey = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
@Override
|
||||||
public void saveAsset(VideoAsset asset) {
|
public void saveAsset(VideoAsset asset) {
|
||||||
assets.put(asset.id(), asset);
|
assets.put(asset.id(), asset);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
public VideoAsset findAssetById(String assetId) {
|
public VideoAsset findAssetById(String assetId) {
|
||||||
return assets.get(assetId);
|
return assets.get(assetId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
public void saveUploadSession(UploadSession uploadSession) {
|
public void saveUploadSession(UploadSession uploadSession) {
|
||||||
uploadSessions.put(uploadSession.id(), uploadSession);
|
uploadSessions.put(uploadSession.id(), uploadSession);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
public UploadSession findUploadSessionById(String uploadSessionId) {
|
public UploadSession findUploadSessionById(String uploadSessionId) {
|
||||||
return uploadSessions.get(uploadSessionId);
|
return uploadSessions.get(uploadSessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
public void saveJob(ClipJob job) {
|
public void saveJob(ClipJob job) {
|
||||||
jobs.put(job.id(), job);
|
jobs.put(job.id(), job);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
public ClipJob findJobById(String jobId) {
|
public ClipJob findJobById(String jobId) {
|
||||||
return jobs.get(jobId);
|
return jobs.get(jobId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
public List<ClipJob> findJobsByAssetId(String assetId) {
|
public List<ClipJob> findJobsByAssetId(String assetId) {
|
||||||
List<ClipJob> result = new ArrayList<>();
|
List<ClipJob> result = new ArrayList<>();
|
||||||
for (ClipJob job : jobs.values()) {
|
for (ClipJob job : jobs.values()) {
|
||||||
|
|
@ -58,10 +66,12 @@ public class InMemoryVideoAssetRepository {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
public void saveClip(Clip clip) {
|
public void saveClip(Clip clip) {
|
||||||
clips.put(clip.id(), clip);
|
clips.put(clip.id(), clip);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
public List<Clip> findClipsByJobId(String jobId) {
|
public List<Clip> findClipsByJobId(String jobId) {
|
||||||
List<Clip> result = new ArrayList<>();
|
List<Clip> result = new ArrayList<>();
|
||||||
for (Clip clip : clips.values()) {
|
for (Clip clip : clips.values()) {
|
||||||
|
|
@ -72,14 +82,17 @@ public class InMemoryVideoAssetRepository {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
public Clip findClipById(String clipId) {
|
public Clip findClipById(String clipId) {
|
||||||
return clips.get(clipId);
|
return clips.get(clipId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
public void saveJobEvent(JobEvent event) {
|
public void saveJobEvent(JobEvent event) {
|
||||||
jobEvents.put(event.id(), event);
|
jobEvents.put(event.id(), event);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
public List<JobEvent> findEventsByAggregateId(String aggregateId) {
|
public List<JobEvent> findEventsByAggregateId(String aggregateId) {
|
||||||
List<JobEvent> result = new ArrayList<>();
|
List<JobEvent> result = new ArrayList<>();
|
||||||
for (JobEvent event : jobEvents.values()) {
|
for (JobEvent event : jobEvents.values()) {
|
||||||
|
|
@ -90,6 +103,7 @@ public class InMemoryVideoAssetRepository {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
public boolean hasActiveJobForAsset(String assetId) {
|
public boolean hasActiveJobForAsset(String assetId) {
|
||||||
return jobs.values().stream().anyMatch(job ->
|
return jobs.values().stream().anyMatch(job ->
|
||||||
job.assetId().equals(assetId)
|
job.assetId().equals(assetId)
|
||||||
|
|
@ -98,18 +112,22 @@ public class InMemoryVideoAssetRepository {
|
||||||
&& job.status() != ClipJobStatus.CANCELLED);
|
&& job.status() != ClipJobStatus.CANCELLED);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
public Map<String, Object> findAssetResponseByIdempotencyKey(String key) {
|
public Map<String, Object> findAssetResponseByIdempotencyKey(String key) {
|
||||||
return assetResponsesByIdempotencyKey.get(key);
|
return assetResponsesByIdempotencyKey.get(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
public void storeAssetResponseByIdempotencyKey(String key, Map<String, Object> response) {
|
public void storeAssetResponseByIdempotencyKey(String key, Map<String, Object> response) {
|
||||||
assetResponsesByIdempotencyKey.put(key, response);
|
assetResponsesByIdempotencyKey.put(key, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
public Map<String, Object> findJobResponseByIdempotencyKey(String key) {
|
public Map<String, Object> findJobResponseByIdempotencyKey(String key) {
|
||||||
return jobResponsesByIdempotencyKey.get(key);
|
return jobResponsesByIdempotencyKey.get(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
public void storeJobResponseByIdempotencyKey(String key, Map<String, Object> response) {
|
public void storeJobResponseByIdempotencyKey(String key, Map<String, Object> response) {
|
||||||
jobResponsesByIdempotencyKey.put(key, response);
|
jobResponsesByIdempotencyKey.put(key, response);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
package org.example.videoclips.observability;
|
||||||
|
|
||||||
|
import org.example.videoclips.application.VideoClippingRepository;
|
||||||
|
import org.springframework.boot.actuate.health.Health;
|
||||||
|
import org.springframework.boot.actuate.health.HealthIndicator;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class RepositoryHealthIndicator implements HealthIndicator {
|
||||||
|
|
||||||
|
private final VideoClippingRepository repository;
|
||||||
|
|
||||||
|
public RepositoryHealthIndicator(VideoClippingRepository repository) {
|
||||||
|
this.repository = repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Health health() {
|
||||||
|
return Health.up()
|
||||||
|
.withDetail("adapter", repository.getClass().getSimpleName())
|
||||||
|
.withDetail("capability", "asset-job-clip-metadata")
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
package org.example.videoclips.processing;
|
package org.example.videoclips.processing;
|
||||||
|
|
||||||
|
import org.example.videoclips.application.VideoClippingRepository;
|
||||||
import org.example.videoclips.domain.Clip;
|
import org.example.videoclips.domain.Clip;
|
||||||
import org.example.videoclips.domain.ClipJob;
|
import org.example.videoclips.domain.ClipJob;
|
||||||
import org.example.videoclips.domain.ClipJobStatus;
|
import org.example.videoclips.domain.ClipJobStatus;
|
||||||
import org.example.videoclips.domain.VideoAsset;
|
import org.example.videoclips.domain.VideoAsset;
|
||||||
import org.example.videoclips.domain.JobEvent;
|
import org.example.videoclips.domain.JobEvent;
|
||||||
import org.example.videoclips.infrastructure.InMemoryVideoAssetRepository;
|
|
||||||
import org.springframework.scheduling.annotation.Async;
|
import org.springframework.scheduling.annotation.Async;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
|
@ -15,10 +15,10 @@ import java.util.List;
|
||||||
@Component
|
@Component
|
||||||
public class ClipProcessor {
|
public class ClipProcessor {
|
||||||
|
|
||||||
private final InMemoryVideoAssetRepository repository;
|
private final VideoClippingRepository repository;
|
||||||
private final VideoClipperPort videoClipperPort;
|
private final VideoClipperPort videoClipperPort;
|
||||||
|
|
||||||
public ClipProcessor(InMemoryVideoAssetRepository repository, VideoClipperPort videoClipperPort) {
|
public ClipProcessor(VideoClippingRepository repository, VideoClipperPort videoClipperPort) {
|
||||||
this.repository = repository;
|
this.repository = repository;
|
||||||
this.videoClipperPort = videoClipperPort;
|
this.videoClipperPort = videoClipperPort;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
import static org.hamcrest.Matchers.greaterThanOrEqualTo;
|
import static org.hamcrest.Matchers.greaterThanOrEqualTo;
|
||||||
import static org.hamcrest.Matchers.isOneOf;
|
import static org.hamcrest.Matchers.isOneOf;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
|
|
@ -180,4 +181,31 @@ class VideoAssetControllerTest {
|
||||||
.andExpect(status().isUnsupportedMediaType())
|
.andExpect(status().isUnsupportedMediaType())
|
||||||
.andExpect(jsonPath("$.type").value("https://api.example.com/problems/unsupported-media-type"));
|
.andExpect(jsonPath("$.type").value("https://api.example.com/problems/unsupported-media-type"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deletingAssetMakesAssetUnavailable() throws Exception {
|
||||||
|
String assetResponse = mockMvc.perform(post("/v1/video-assets")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("""
|
||||||
|
{
|
||||||
|
"fileName": "delete-me.mp4",
|
||||||
|
"contentType": "video/mp4",
|
||||||
|
"contentLengthBytes": 4096,
|
||||||
|
"checksumSha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||||
|
}
|
||||||
|
"""))
|
||||||
|
.andExpect(status().isCreated())
|
||||||
|
.andReturn()
|
||||||
|
.getResponse()
|
||||||
|
.getContentAsString();
|
||||||
|
|
||||||
|
String assetId = JsonTestHelper.read(assetResponse, "$.assetId");
|
||||||
|
|
||||||
|
mockMvc.perform(delete("/v1/video-assets/{assetId}", assetId))
|
||||||
|
.andExpect(status().isNoContent());
|
||||||
|
|
||||||
|
mockMvc.perform(get("/v1/video-assets/{assetId}", assetId))
|
||||||
|
.andExpect(status().isNotFound())
|
||||||
|
.andExpect(jsonPath("$.type").value("https://api.example.com/problems/not-found"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue