forked from jsl/video_editing_poc
wip
This commit is contained in:
parent
01b83cec20
commit
372d2d6fa6
|
|
@ -0,0 +1,747 @@
|
|||
# Video Clipping Service Implementation Plan
|
||||
|
||||
## 1. Goal And Scope
|
||||
|
||||
Build a production-grade Java Spring Boot microservice system that accepts very large video files and generates 8-second clips.
|
||||
|
||||
The service must avoid loading full video files into application memory. The recommended architecture is an API service for metadata, authorization, upload orchestration, and job creation, plus isolated worker services that run FFmpeg asynchronously.
|
||||
|
||||
Assumptions:
|
||||
|
||||
- Clients are web, mobile, or internal services.
|
||||
- Videos can be multi-GB.
|
||||
- Default output clips are MP4.
|
||||
- Object storage is S3-compatible.
|
||||
- Metadata is stored in PostgreSQL.
|
||||
- Processing runs on Kubernetes.
|
||||
- Job processing is asynchronous and queue-driven.
|
||||
|
||||
Non-goals:
|
||||
|
||||
- Browser-based video editing UI.
|
||||
- Real-time livestream clipping.
|
||||
- Complex timelines, effects, captions, subtitles, or DRM.
|
||||
- Full media asset management.
|
||||
|
||||
## 2. Architecture
|
||||
|
||||
Recommended flow:
|
||||
|
||||
```text
|
||||
Client
|
||||
-> POST /v1/video-assets
|
||||
-> API creates asset + upload session + signed multipart URLs
|
||||
-> Client uploads video directly to object storage
|
||||
-> Client completes upload
|
||||
-> API validates upload metadata and creates a processing job
|
||||
-> API publishes job message to queue
|
||||
-> Worker receives job
|
||||
-> Worker downloads or streams source video
|
||||
-> Worker probes video with ffprobe
|
||||
-> Worker runs FFmpeg
|
||||
-> Worker uploads generated clips to object storage
|
||||
-> Worker records clips and job status in database
|
||||
-> Client polls job status or receives webhook
|
||||
```
|
||||
|
||||
Service responsibilities:
|
||||
|
||||
- API service: authentication, authorization, upload-session creation, signed URL generation, request validation, job creation, status reads, clip listing, and cancellation.
|
||||
- Worker service: durable job consumption, FFmpeg execution, progress updates, retry handling, temporary file cleanup, and output upload.
|
||||
- Database: durable metadata for assets, upload sessions, jobs, clips, and job events.
|
||||
- Queue: durable async job dispatch with retry and dead-letter handling.
|
||||
- Object storage: original videos and generated clips.
|
||||
|
||||
Use separate deployable API and worker services. The API is network-bound and latency-sensitive; workers are CPU, disk, and storage-bandwidth intensive.
|
||||
|
||||
## 3. Technology Stack
|
||||
|
||||
Recommended greenfield stack:
|
||||
|
||||
- Java: Java 25 LTS for greenfield if all dependencies support it; Java 21 LTS as the conservative fallback.
|
||||
- Spring Boot: Spring Boot 4.1.x for greenfield.
|
||||
- Web stack: Spring Web MVC for REST APIs.
|
||||
- Security: Spring Security OAuth2 Resource Server with JWT.
|
||||
- Database: PostgreSQL with Flyway migrations.
|
||||
- Queue: AWS SQS for AWS deployments, RabbitMQ for cloud-portable work queues, Kafka only if event streaming is already a platform standard.
|
||||
- Storage: S3-compatible object storage using multipart upload.
|
||||
- Processing: FFmpeg and ffprobe installed in the worker image and invoked through a strict process adapter.
|
||||
- Observability: Spring Boot Actuator, Micrometer, OpenTelemetry, Prometheus, Grafana, and structured JSON logs.
|
||||
- Tests: JUnit 5, Spring Boot Test, Testcontainers, realistic video fixtures, and an S3-compatible local test service.
|
||||
|
||||
Version notes:
|
||||
|
||||
- Spring Boot 4.1.0 requires at least Java 17 and is compatible up to Java 26: <https://docs.spring.io/spring-boot/system-requirements.html>
|
||||
- OpenJDK publishes production-ready JDK 25 builds: <https://jdk.java.net/25/>
|
||||
- Spring Boot documents Testcontainers integration for integration tests against real backend services: <https://docs.spring.io/spring-boot/reference/testing/testcontainers.html>
|
||||
|
||||
## 4. API Contract
|
||||
|
||||
All APIs are versioned under `/v1`. Use ISO-8601 UTC timestamps.
|
||||
|
||||
| Method | Path | Purpose | Auth | Idempotency |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `POST` | `/v1/video-assets` | Create asset and upload session | Owner/admin | `Idempotency-Key` |
|
||||
| `POST` | `/v1/video-assets/{assetId}/uploads:complete` | Complete uploaded object registration | Owner/admin | Idempotent by upload id |
|
||||
| `POST` | `/v1/video-assets/{assetId}/clip-jobs` | Create clipping job | Owner/admin | `Idempotency-Key` |
|
||||
| `GET` | `/v1/clip-jobs/{jobId}` | Get job status | Owner/admin | Safe |
|
||||
| `GET` | `/v1/clip-jobs/{jobId}/clips` | List generated clips | Owner/admin | Safe |
|
||||
| `POST` | `/v1/clips/{clipId}/download-url` | Create signed clip download URL | Owner/admin | Short-lived |
|
||||
| `POST` | `/v1/clip-jobs/{jobId}:cancel` | Cancel queued or running job | Owner/admin | Idempotent |
|
||||
| `DELETE` | `/v1/video-assets/{assetId}` | Delete asset and generated outputs | Owner/admin | Idempotent |
|
||||
|
||||
### Create Video Asset
|
||||
|
||||
`POST /v1/video-assets`
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"fileName": "match-final.mp4",
|
||||
"contentType": "video/mp4",
|
||||
"contentLengthBytes": 12884901888,
|
||||
"checksumSha256": "7b3e...",
|
||||
"clipProfile": "mp4-h264-aac"
|
||||
}
|
||||
```
|
||||
|
||||
Validation:
|
||||
|
||||
- `fileName`: required, `1..255` chars, stored as metadata only.
|
||||
- `contentType`: required, allowed video media type.
|
||||
- `contentLengthBytes`: required, `1` byte through configured tenant max.
|
||||
- `checksumSha256`: optional initially, required if the client can provide it.
|
||||
- `clipProfile`: optional, defaults to `mp4-h264-aac`.
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"assetId": "vid_01JZABCD123",
|
||||
"status": "PENDING_UPLOAD",
|
||||
"upload": {
|
||||
"uploadId": "upl_01JZABCD456",
|
||||
"mode": "multipart",
|
||||
"partSizeBytes": 104857600,
|
||||
"expiresAt": "2026-07-08T18:00:00Z",
|
||||
"parts": [
|
||||
{
|
||||
"partNumber": 1,
|
||||
"url": "https://storage.example/upload-part-1"
|
||||
}
|
||||
]
|
||||
},
|
||||
"createdAt": "2026-07-08T16:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
Invalid request example:
|
||||
|
||||
```json
|
||||
{
|
||||
"fileName": "match-final.exe",
|
||||
"contentType": "application/octet-stream",
|
||||
"contentLengthBytes": 0
|
||||
}
|
||||
```
|
||||
|
||||
Expected result: `400` or `415`, depending whether the media type or field validation fails first.
|
||||
|
||||
### Complete Upload
|
||||
|
||||
`POST /v1/video-assets/{assetId}/uploads:complete`
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"uploadId": "upl_01JZABCD456",
|
||||
"parts": [
|
||||
{
|
||||
"partNumber": 1,
|
||||
"etag": "\"abc123\""
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"assetId": "vid_01JZABCD123",
|
||||
"status": "UPLOADED",
|
||||
"uploadedAt": "2026-07-08T16:20:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Create Clip Job
|
||||
|
||||
`POST /v1/video-assets/{assetId}/clip-jobs`
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"segmentDurationSeconds": 8,
|
||||
"accuracyMode": "EXACT",
|
||||
"outputContainer": "mp4",
|
||||
"overwriteExisting": false
|
||||
}
|
||||
```
|
||||
|
||||
Validation:
|
||||
|
||||
- `segmentDurationSeconds`: required, `1..3600`; default product value is `8`.
|
||||
- `accuracyMode`: required, one of `FAST`, `EXACT`.
|
||||
- `outputContainer`: required, initially `mp4`.
|
||||
- `overwriteExisting`: optional, default `false`.
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"jobId": "job_01JZABCDE89",
|
||||
"assetId": "vid_01JZABCD123",
|
||||
"status": "QUEUED",
|
||||
"progressPercent": 0,
|
||||
"createdAt": "2026-07-08T16:25:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
Invalid request example:
|
||||
|
||||
```json
|
||||
{
|
||||
"segmentDurationSeconds": 0,
|
||||
"accuracyMode": "FAST"
|
||||
}
|
||||
```
|
||||
|
||||
Expected result: `400`.
|
||||
|
||||
### Get Clip Job
|
||||
|
||||
`GET /v1/clip-jobs/{jobId}`
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"jobId": "job_01JZABCDE89",
|
||||
"assetId": "vid_01JZABCD123",
|
||||
"status": "RUNNING",
|
||||
"accuracyMode": "EXACT",
|
||||
"segmentDurationSeconds": 8,
|
||||
"progressPercent": 42,
|
||||
"clipCount": 18,
|
||||
"createdAt": "2026-07-08T16:25:00Z",
|
||||
"startedAt": "2026-07-08T16:26:12Z",
|
||||
"finishedAt": null
|
||||
}
|
||||
```
|
||||
|
||||
### List Clips
|
||||
|
||||
`GET /v1/clip-jobs/{jobId}/clips`
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"jobId": "job_01JZABCDE89",
|
||||
"clips": [
|
||||
{
|
||||
"clipId": "clip_01JZABC001",
|
||||
"clipIndex": 0,
|
||||
"startSeconds": 0,
|
||||
"durationSeconds": 8,
|
||||
"contentType": "video/mp4",
|
||||
"contentLengthBytes": 10485760,
|
||||
"createdAt": "2026-07-08T16:30:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 5. Validation And Security
|
||||
|
||||
Authentication and authorization:
|
||||
|
||||
- Require JWT auth for all endpoints.
|
||||
- Enforce tenant and owner checks on assets, jobs, and clips.
|
||||
- Support service-to-service roles for internal processing/admin use.
|
||||
|
||||
Upload security:
|
||||
|
||||
- Generate short-lived signed upload URLs.
|
||||
- Use tenant-scoped object prefixes.
|
||||
- Validate object size and checksum after upload completion.
|
||||
- Store original filenames as metadata only.
|
||||
- Never use user-provided filenames as local paths or object keys.
|
||||
|
||||
Media validation:
|
||||
|
||||
- Check declared content type.
|
||||
- Probe actual media with `ffprobe`.
|
||||
- Reject unsupported containers/codecs.
|
||||
- Reject corrupt, encrypted, or unprobeable files with `422`.
|
||||
|
||||
Abuse prevention:
|
||||
|
||||
- Limit asset creation, upload sessions, clip jobs, and signed URL requests by tenant.
|
||||
- Enforce max active jobs per tenant.
|
||||
- Enforce max input duration and max file size.
|
||||
- Use malware scanning for untrusted external uploads.
|
||||
|
||||
Sensitive data must not be logged:
|
||||
|
||||
- Signed URLs.
|
||||
- Authorization headers.
|
||||
- Raw queue payloads containing object keys.
|
||||
- Internal storage credentials.
|
||||
- Full FFmpeg stderr if it contains paths or user metadata.
|
||||
|
||||
## 6. Video Processing Design
|
||||
|
||||
FFmpeg supports segmenting with `segment_time`, but exact cuts depend on keyframe placement. Fast stream-copy segmentation is efficient but usually cuts on keyframes. Exact 8-second boundaries usually require transcoding and forced keyframes. FFmpeg segment muxer docs: <https://ffmpeg.org/ffmpeg-formats.html#segment_002c-stream_005fsegment_002c-ssegment>
|
||||
|
||||
### Fast Mode
|
||||
|
||||
Fast mode uses stream copy and is best when keyframe-aligned clips are acceptable.
|
||||
|
||||
```bash
|
||||
ffmpeg -hide_banner -y -i input.mp4 \
|
||||
-map 0 -c copy \
|
||||
-f segment -segment_time 8 \
|
||||
-reset_timestamps 1 \
|
||||
clips/out_%05d.mp4
|
||||
```
|
||||
|
||||
Tradeoff:
|
||||
|
||||
- Pros: much faster, less CPU, cheaper.
|
||||
- Cons: clips may not start exactly every 8 seconds if keyframes are not aligned.
|
||||
|
||||
### Exact Mode
|
||||
|
||||
Exact mode transcodes video and forces keyframes every 8 seconds.
|
||||
|
||||
```bash
|
||||
ffmpeg -hide_banner -y -i input.mp4 \
|
||||
-map 0 \
|
||||
-c:v libx264 -preset veryfast -crf 20 \
|
||||
-force_key_frames "expr:gte(t,n_forced*8)" \
|
||||
-c:a aac -b:a 128k \
|
||||
-f segment -segment_time 8 \
|
||||
-reset_timestamps 1 \
|
||||
clips/out_%05d.mp4
|
||||
```
|
||||
|
||||
Tradeoff:
|
||||
|
||||
- Pros: frame-accurate or near frame-accurate segment boundaries.
|
||||
- Cons: much higher CPU cost, longer processing time, possible generational quality loss.
|
||||
|
||||
Processing rules:
|
||||
|
||||
- The final clip may be shorter than 8 seconds.
|
||||
- Record each clip's `startSeconds`, `durationSeconds`, and `clipIndex`.
|
||||
- Upload clips to a staging prefix first, then promote or mark complete after validation.
|
||||
- Clean partial local and remote output on failure unless retained for debugging by policy.
|
||||
- Store summarized FFmpeg/ffprobe failure information, not raw unsafe logs.
|
||||
|
||||
Worker reliability:
|
||||
|
||||
- Jobs must be idempotent.
|
||||
- Workers must tolerate duplicate queue messages.
|
||||
- Use job-state transitions guarded by optimistic locking.
|
||||
- Extend queue visibility while processing long jobs.
|
||||
- Move repeatedly failing jobs to DLQ and mark the job as `FAILED`.
|
||||
|
||||
AWS SQS visibility timeout and retry guidance: <https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-visibility-timeout.html>
|
||||
|
||||
## 7. Data Model
|
||||
|
||||
### `video_assets`
|
||||
|
||||
| Field | Type | Notes |
|
||||
| --- | --- | --- |
|
||||
| `id` | varchar | Public id, e.g. `vid_*` |
|
||||
| `tenant_id` | varchar | Required |
|
||||
| `owner_id` | varchar | Required |
|
||||
| `status` | enum | `PENDING_UPLOAD`, `UPLOADED`, `PROCESSING`, `READY`, `FAILED`, `DELETED` |
|
||||
| `original_file_name` | varchar | Metadata only |
|
||||
| `content_type` | varchar | Declared/validated media type |
|
||||
| `content_length_bytes` | bigint | Required |
|
||||
| `checksum_sha256` | varchar | Optional or required by policy |
|
||||
| `storage_bucket` | varchar | Internal |
|
||||
| `storage_key` | varchar | Internal |
|
||||
| `created_at` | timestamptz | UTC |
|
||||
| `deleted_at` | timestamptz | Nullable |
|
||||
|
||||
Indexes:
|
||||
|
||||
- `(tenant_id, created_at)`
|
||||
- `(owner_id, created_at)`
|
||||
- `(status, created_at)`
|
||||
|
||||
### `upload_sessions`
|
||||
|
||||
| Field | Type | Notes |
|
||||
| --- | --- | --- |
|
||||
| `id` | varchar | Public id, e.g. `upl_*` |
|
||||
| `asset_id` | varchar | FK |
|
||||
| `provider_upload_id` | varchar | S3 multipart upload id |
|
||||
| `status` | enum | `OPEN`, `COMPLETED`, `ABORTED`, `EXPIRED` |
|
||||
| `part_size_bytes` | bigint | Required |
|
||||
| `expires_at` | timestamptz | UTC |
|
||||
| `completed_at` | timestamptz | Nullable |
|
||||
|
||||
### `clip_jobs`
|
||||
|
||||
| Field | Type | Notes |
|
||||
| --- | --- | --- |
|
||||
| `id` | varchar | Public id, e.g. `job_*` |
|
||||
| `asset_id` | varchar | FK |
|
||||
| `tenant_id` | varchar | Required for filtering |
|
||||
| `status` | enum | `QUEUED`, `RUNNING`, `SUCCEEDED`, `FAILED`, `CANCEL_REQUESTED`, `CANCELLED` |
|
||||
| `accuracy_mode` | enum | `FAST`, `EXACT` |
|
||||
| `segment_duration_seconds` | int | Default 8 |
|
||||
| `attempt_count` | int | Retry tracking |
|
||||
| `progress_percent` | int | `0..100` |
|
||||
| `error_code` | varchar | Nullable |
|
||||
| `error_message` | text | Sanitized |
|
||||
| `created_at` | timestamptz | UTC |
|
||||
| `started_at` | timestamptz | Nullable |
|
||||
| `finished_at` | timestamptz | Nullable |
|
||||
|
||||
Indexes:
|
||||
|
||||
- `(tenant_id, created_at)`
|
||||
- `(asset_id, created_at)`
|
||||
- `(status, created_at)`
|
||||
|
||||
### `clips`
|
||||
|
||||
| Field | Type | Notes |
|
||||
| --- | --- | --- |
|
||||
| `id` | varchar | Public id, e.g. `clip_*` |
|
||||
| `job_id` | varchar | FK |
|
||||
| `asset_id` | varchar | FK |
|
||||
| `clip_index` | int | Starts at 0 |
|
||||
| `start_seconds` | numeric | Start position |
|
||||
| `duration_seconds` | numeric | May be less than 8 for final clip |
|
||||
| `storage_bucket` | varchar | Internal |
|
||||
| `storage_key` | varchar | Internal |
|
||||
| `content_length_bytes` | bigint | Required |
|
||||
| `checksum_sha256` | varchar | Optional |
|
||||
| `created_at` | timestamptz | UTC |
|
||||
|
||||
Constraint:
|
||||
|
||||
- Unique `(job_id, clip_index)`.
|
||||
|
||||
### `job_events`
|
||||
|
||||
| Field | Type | Notes |
|
||||
| --- | --- | --- |
|
||||
| `id` | bigint | Sequence |
|
||||
| `job_id` | varchar | FK |
|
||||
| `event_type` | varchar | `QUEUED`, `STARTED`, `PROGRESS`, `FAILED`, etc. |
|
||||
| `message` | text | Sanitized |
|
||||
| `created_at` | timestamptz | UTC |
|
||||
|
||||
## 8. Performance And Scalability
|
||||
|
||||
Upload path:
|
||||
|
||||
- Use direct-to-object-storage upload.
|
||||
- Use multipart upload for large files.
|
||||
- Avoid proxying large video bytes through the API service.
|
||||
- S3 multipart upload allows independent upload and retry of parts: <https://docs.aws.amazon.com/AmazonS3/latest/userguide/mpuoverview.html>
|
||||
|
||||
Worker scaling:
|
||||
|
||||
- Scale workers by queue age, queue depth, and CPU saturation.
|
||||
- Start with one FFmpeg process per worker pod, then benchmark.
|
||||
- Use larger worker pods for high-resolution transcoding.
|
||||
- Reserve enough ephemeral storage for input plus output plus overhead.
|
||||
|
||||
Backpressure:
|
||||
|
||||
- Per-tenant max active jobs.
|
||||
- Global max queued jobs.
|
||||
- Queue visibility timeout aligned with expected processing time.
|
||||
- DLQ after a configured retry count.
|
||||
|
||||
Benchmark dimensions:
|
||||
|
||||
- Input size.
|
||||
- Duration.
|
||||
- Resolution.
|
||||
- Codec.
|
||||
- Frame rate mode.
|
||||
- `FAST` vs `EXACT`.
|
||||
- Object-storage region and bandwidth.
|
||||
- Worker CPU and disk profile.
|
||||
|
||||
## 9. Error Model
|
||||
|
||||
Use `application/problem+json` for all non-2xx responses.
|
||||
|
||||
Validation error example:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "https://api.example.com/problems/validation-error",
|
||||
"title": "Invalid request",
|
||||
"status": 400,
|
||||
"detail": "segmentDurationSeconds must be between 1 and 3600",
|
||||
"instance": "/v1/video-assets/vid_01JZABCD123/clip-jobs",
|
||||
"errors": [
|
||||
{
|
||||
"field": "segmentDurationSeconds",
|
||||
"reason": "must be between 1 and 3600"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Status mapping:
|
||||
|
||||
| Status | Meaning |
|
||||
| --- | --- |
|
||||
| `400` | Malformed JSON or invalid request fields |
|
||||
| `401` | Missing or invalid token |
|
||||
| `403` | Authenticated but not allowed for tenant/asset/job |
|
||||
| `404` | Asset, job, or clip not found |
|
||||
| `409` | Invalid state transition, duplicate active job, conflict |
|
||||
| `413` | File too large |
|
||||
| `415` | Unsupported media type |
|
||||
| `422` | Uploaded object is not a processable video |
|
||||
| `429` | Rate or quota exceeded |
|
||||
| `500` | Unexpected server error |
|
||||
| `503` | Storage, queue, or processing capacity temporarily unavailable |
|
||||
|
||||
## 10. Testing Strategy
|
||||
|
||||
Unit tests:
|
||||
|
||||
- Request validation.
|
||||
- Domain state transitions.
|
||||
- Idempotency-key behavior.
|
||||
- Object-key generation.
|
||||
- Job progress calculations.
|
||||
|
||||
Controller/API tests:
|
||||
|
||||
- Auth success/failure.
|
||||
- Tenant isolation.
|
||||
- Request and response schemas.
|
||||
- `application/problem+json` error mapping.
|
||||
- Idempotency behavior.
|
||||
|
||||
Integration tests:
|
||||
|
||||
- PostgreSQL via Testcontainers.
|
||||
- Queue via Testcontainers or local emulator.
|
||||
- S3-compatible object storage via Testcontainers.
|
||||
- Worker duplicate message handling.
|
||||
- DLQ path.
|
||||
|
||||
FFmpeg tests:
|
||||
|
||||
- Short video shorter than 8 seconds.
|
||||
- Video exactly 8 seconds.
|
||||
- Long video with final partial clip.
|
||||
- Video with no audio.
|
||||
- Video with variable frame rate.
|
||||
- Corrupt file.
|
||||
- Unsupported codec.
|
||||
- `FAST` mode duration tolerance.
|
||||
- `EXACT` mode boundary accuracy.
|
||||
|
||||
Load tests:
|
||||
|
||||
- Upload-session creation throughput.
|
||||
- Job creation throughput.
|
||||
- Queue-to-worker throughput.
|
||||
- Worker CPU/disk saturation.
|
||||
- Object-storage download/upload throughput.
|
||||
|
||||
End-to-end test:
|
||||
|
||||
```text
|
||||
Create asset
|
||||
-> upload fixture video
|
||||
-> complete upload
|
||||
-> create clip job
|
||||
-> worker processes job
|
||||
-> poll job status
|
||||
-> list clips
|
||||
-> create signed download URL
|
||||
-> verify clip object exists
|
||||
```
|
||||
|
||||
## 11. Deployment And Operations
|
||||
|
||||
Kubernetes:
|
||||
|
||||
- Deploy API and worker separately.
|
||||
- API has horizontal autoscaling by CPU/request latency.
|
||||
- Workers scale by queue depth, queue age, and CPU.
|
||||
- Use resource requests and limits.
|
||||
- Define ephemeral storage requests/limits for workers.
|
||||
- Use separate service accounts and least-privilege IAM.
|
||||
|
||||
Health checks:
|
||||
|
||||
- API readiness: database, queue publisher, storage signer availability.
|
||||
- API liveness: process health.
|
||||
- Worker readiness: database, queue consumer, storage, FFmpeg binary check.
|
||||
- Worker liveness: process health, not job success.
|
||||
- Startup probe for slow cold starts.
|
||||
|
||||
Kubernetes readiness and liveness probe reference: <https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/>
|
||||
|
||||
Observability:
|
||||
|
||||
- Expose Actuator health and metrics.
|
||||
- Export metrics through Micrometer.
|
||||
- Use OpenTelemetry tracing for API requests and worker jobs.
|
||||
- Correlate logs by `traceId`, `assetId`, `jobId`, and `tenantId`.
|
||||
|
||||
Spring Boot Actuator and observability references:
|
||||
|
||||
- <https://docs.spring.io/spring-boot/reference/actuator/index.html>
|
||||
- <https://docs.spring.io/spring-boot/reference/actuator/observability.html>
|
||||
|
||||
Alerts:
|
||||
|
||||
- Queue age too high.
|
||||
- DLQ messages present.
|
||||
- Job failure rate above threshold.
|
||||
- Worker disk usage high.
|
||||
- FFmpeg exit-code spike.
|
||||
- Object storage errors.
|
||||
- API 5xx rate.
|
||||
- Signed URL generation failures.
|
||||
|
||||
Operational jobs:
|
||||
|
||||
- Abort expired multipart uploads.
|
||||
- Delete expired original videos.
|
||||
- Delete expired clips.
|
||||
- Remove orphaned staging objects.
|
||||
- Redrive DLQ messages after fixes.
|
||||
|
||||
## 12. Project Structure
|
||||
|
||||
```text
|
||||
com.example.videoclips
|
||||
api/
|
||||
VideoAssetController
|
||||
ClipJobController
|
||||
dto/
|
||||
error/
|
||||
application/
|
||||
CreateVideoAssetUseCase
|
||||
CompleteUploadUseCase
|
||||
CreateClipJobUseCase
|
||||
CancelClipJobUseCase
|
||||
GetClipJobUseCase
|
||||
domain/
|
||||
VideoAsset
|
||||
UploadSession
|
||||
ClipJob
|
||||
Clip
|
||||
JobStatus
|
||||
AccuracyMode
|
||||
persistence/
|
||||
entity/
|
||||
repository/
|
||||
mapper/
|
||||
storage/
|
||||
ObjectStoragePort
|
||||
S3ObjectStorageAdapter
|
||||
queue/
|
||||
ClipJobPublisher
|
||||
ClipJobConsumer
|
||||
ClipJobMessage
|
||||
processing/
|
||||
FfmpegClipper
|
||||
FfprobeAnalyzer
|
||||
ProcessRunner
|
||||
ClipManifest
|
||||
config/
|
||||
SecurityConfig
|
||||
StorageConfig
|
||||
QueueConfig
|
||||
ObservabilityConfig
|
||||
observability/
|
||||
```
|
||||
|
||||
Keep the domain independent from Spring framework details. Adapters implement storage, queue, persistence, and FFmpeg process concerns.
|
||||
|
||||
## 13. Implementation Milestones
|
||||
|
||||
### MVP
|
||||
|
||||
1. Create asset and upload-session API.
|
||||
2. Direct S3 multipart upload support.
|
||||
3. Complete upload endpoint.
|
||||
4. Create clip job endpoint.
|
||||
5. Queue publisher and single worker.
|
||||
6. FFmpeg `FAST` mode.
|
||||
7. Job status and clip listing.
|
||||
8. Basic integration tests.
|
||||
|
||||
### Production Hardening
|
||||
|
||||
1. `EXACT` clipping mode.
|
||||
2. Idempotency keys.
|
||||
3. Retry and DLQ handling.
|
||||
4. Tenant quotas.
|
||||
5. Full `application/problem+json` error model.
|
||||
6. Cleanup lifecycle jobs.
|
||||
7. Security review.
|
||||
|
||||
### Performance Tuning
|
||||
|
||||
1. Worker CPU/disk benchmarks.
|
||||
2. FFmpeg preset benchmarks.
|
||||
3. Object-storage bandwidth tests.
|
||||
4. Queue visibility-timeout tuning.
|
||||
5. Autoscaling policies.
|
||||
6. Cost model by video minute.
|
||||
|
||||
### Operational Readiness
|
||||
|
||||
1. Dashboards.
|
||||
2. Alerts.
|
||||
3. Runbooks.
|
||||
4. Backup and restore validation.
|
||||
5. DLQ redrive procedure.
|
||||
6. Load-test signoff.
|
||||
|
||||
## 14. Open Questions
|
||||
|
||||
- Must clips be exactly 8 seconds, or is keyframe-aligned clipping acceptable?
|
||||
- What is the maximum input file size?
|
||||
- What input containers and codecs must be supported?
|
||||
- What output codec and container are required?
|
||||
- Which cloud provider and object storage will be used?
|
||||
- Which auth provider will issue JWTs?
|
||||
- What is expected upload volume per minute?
|
||||
- What is expected average and maximum video duration?
|
||||
- How long should originals and generated clips be retained?
|
||||
- Should clip access always use signed URLs?
|
||||
- Are webhook notifications required?
|
||||
- Do jobs need per-tenant priority?
|
||||
- Are videos user-generated and therefore subject to malware/content scanning?
|
||||
|
||||
## Recommended Default
|
||||
|
||||
Use Spring Boot 4.1.x, Java 25 LTS where dependency support is confirmed, PostgreSQL, S3 multipart upload, SQS with DLQ, separate Kubernetes API and worker deployments, FFmpeg in worker containers, `FAST` and `EXACT` clipping modes, Spring Boot Actuator/Micrometer/OpenTelemetry, and Testcontainers-backed integration tests.
|
||||
46
pom.xml
46
pom.xml
|
|
@ -4,14 +4,54 @@
|
|||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>3.3.2</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
<groupId>org.example</groupId>
|
||||
<artifactId>video-editing</artifactId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
<name>video-editing</name>
|
||||
|
||||
<properties>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
<java.version>21</java.version>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
</properties>
|
||||
|
||||
</project>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.jayway.jsonpath</groupId>
|
||||
<artifactId>json-path</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
package org.example;
|
||||
|
||||
public class Main {
|
||||
public static void main(String[] args) {
|
||||
System.out.println("Hello world!");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package org.example.videoclips;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
|
||||
import org.springframework.scheduling.annotation.EnableAsync;
|
||||
|
||||
@SpringBootApplication
|
||||
@EnableAsync
|
||||
@ConfigurationPropertiesScan
|
||||
public class VideoClippingApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(VideoClippingApplication.class, args);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
package org.example.videoclips.api;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.ConstraintViolationException;
|
||||
import org.example.videoclips.application.NotFoundException;
|
||||
import org.example.videoclips.application.PayloadTooLargeException;
|
||||
import org.example.videoclips.application.StateConflictException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.HttpStatusCode;
|
||||
import org.springframework.http.ProblemDetail;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.validation.FieldError;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.context.request.ServletWebRequest;
|
||||
import org.springframework.web.context.request.WebRequest;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestControllerAdvice
|
||||
public class ApiExceptionHandler extends ResponseEntityExceptionHandler {
|
||||
|
||||
@ExceptionHandler(NotFoundException.class)
|
||||
ProblemDetail handleNotFound(NotFoundException ex, HttpServletRequest request) {
|
||||
return problem(HttpStatus.NOT_FOUND, "not-found", ex.getMessage(), request.getRequestURI(), null);
|
||||
}
|
||||
|
||||
@ExceptionHandler(StateConflictException.class)
|
||||
ProblemDetail handleConflict(StateConflictException ex, HttpServletRequest request) {
|
||||
return problem(HttpStatus.CONFLICT, "state-conflict", ex.getMessage(), request.getRequestURI(), null);
|
||||
}
|
||||
|
||||
@ExceptionHandler(PayloadTooLargeException.class)
|
||||
ProblemDetail handlePayloadTooLarge(PayloadTooLargeException ex, HttpServletRequest request) {
|
||||
return problem(HttpStatus.PAYLOAD_TOO_LARGE, "file-too-large", ex.getMessage(), request.getRequestURI(), null);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ResponseEntity<Object> handleMethodArgumentNotValid(
|
||||
MethodArgumentNotValidException ex,
|
||||
org.springframework.http.HttpHeaders headers,
|
||||
HttpStatusCode status,
|
||||
WebRequest request
|
||||
) {
|
||||
String path = request instanceof ServletWebRequest servletWebRequest
|
||||
? servletWebRequest.getRequest().getRequestURI()
|
||||
: "";
|
||||
List<Map<String, String>> errors = ex.getBindingResult()
|
||||
.getFieldErrors()
|
||||
.stream()
|
||||
.map(this::fieldError)
|
||||
.toList();
|
||||
return ResponseEntity.badRequest()
|
||||
.body(problem(HttpStatus.BAD_REQUEST, "validation-error", "Invalid request", path, errors));
|
||||
}
|
||||
|
||||
@ExceptionHandler(ConstraintViolationException.class)
|
||||
ProblemDetail handleConstraint(ConstraintViolationException ex, HttpServletRequest request) {
|
||||
return problem(HttpStatus.BAD_REQUEST, "validation-error", ex.getMessage(), request.getRequestURI(), null);
|
||||
}
|
||||
|
||||
private Map<String, String> fieldError(FieldError error) {
|
||||
return Map.of(
|
||||
"field", error.getField(),
|
||||
"reason", error.getDefaultMessage() == null ? "invalid" : error.getDefaultMessage()
|
||||
);
|
||||
}
|
||||
|
||||
private ProblemDetail problem(
|
||||
HttpStatus status,
|
||||
String type,
|
||||
String detail,
|
||||
String instance,
|
||||
List<Map<String, String>> errors
|
||||
) {
|
||||
ProblemDetail pd = ProblemDetail.forStatusAndDetail(status, detail);
|
||||
pd.setType(URI.create("https://api.example.com/problems/" + type));
|
||||
pd.setTitle(status.getReasonPhrase());
|
||||
pd.setInstance(URI.create(instance));
|
||||
if (errors != null && !errors.isEmpty()) {
|
||||
pd.setProperty("errors", errors);
|
||||
}
|
||||
return pd;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
package org.example.videoclips.api;
|
||||
|
||||
import org.example.videoclips.api.dto.SignedUrlResponse;
|
||||
import org.example.videoclips.application.VideoAssetService;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/v1/clips")
|
||||
public class ClipController {
|
||||
|
||||
private final VideoAssetService videoAssetService;
|
||||
|
||||
public ClipController(VideoAssetService videoAssetService) {
|
||||
this.videoAssetService = videoAssetService;
|
||||
}
|
||||
|
||||
@PostMapping("/{clipId}/download-url")
|
||||
public SignedUrlResponse createClipDownloadUrl(@PathVariable String clipId) {
|
||||
return videoAssetService.createClipDownloadUrl(clipId);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
package org.example.videoclips.api;
|
||||
|
||||
import org.example.videoclips.application.VideoAssetService;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/v1/clip-jobs")
|
||||
public class ClipJobController {
|
||||
|
||||
private final VideoAssetService videoAssetService;
|
||||
|
||||
public ClipJobController(VideoAssetService videoAssetService) {
|
||||
this.videoAssetService = videoAssetService;
|
||||
}
|
||||
|
||||
@GetMapping("/{jobId}")
|
||||
public Object getJob(@PathVariable String jobId) {
|
||||
return videoAssetService.getJob(jobId);
|
||||
}
|
||||
|
||||
@GetMapping("/{jobId}/clips")
|
||||
public Object listClips(@PathVariable String jobId) {
|
||||
return videoAssetService.listClips(jobId);
|
||||
}
|
||||
|
||||
@GetMapping("/{jobId}/events")
|
||||
public Object listJobEvents(@PathVariable String jobId) {
|
||||
return videoAssetService.listJobEvents(jobId);
|
||||
}
|
||||
|
||||
@PostMapping("/{jobId}:cancel")
|
||||
public Object cancelJob(@PathVariable String jobId) {
|
||||
return videoAssetService.cancelJob(jobId);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
package org.example.videoclips.api;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import org.example.videoclips.api.dto.ClipJobRequest;
|
||||
import org.example.videoclips.api.dto.CompleteUploadRequest;
|
||||
import org.example.videoclips.api.dto.CreateVideoAssetRequest;
|
||||
import org.example.videoclips.application.VideoAssetService;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/v1/video-assets")
|
||||
public class VideoAssetController {
|
||||
|
||||
private final VideoAssetService videoAssetService;
|
||||
|
||||
public VideoAssetController(VideoAssetService videoAssetService) {
|
||||
this.videoAssetService = videoAssetService;
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
public Object createAsset(
|
||||
@Valid @RequestBody CreateVideoAssetRequest request,
|
||||
@RequestHeader(value = "Idempotency-Key", required = false) String idempotencyKey
|
||||
) {
|
||||
return videoAssetService.createAsset(request, idempotencyKey);
|
||||
}
|
||||
|
||||
@PostMapping("/{assetId}/uploads:complete")
|
||||
public Object completeUpload(@PathVariable String assetId, @Valid @RequestBody CompleteUploadRequest request) {
|
||||
return videoAssetService.completeUpload(assetId, request);
|
||||
}
|
||||
|
||||
@GetMapping("/{assetId}")
|
||||
public Object getAsset(@PathVariable String assetId) {
|
||||
return videoAssetService.getAssetResponse(assetId);
|
||||
}
|
||||
|
||||
@GetMapping("/{assetId}/clip-jobs")
|
||||
public Object listAssetJobs(@PathVariable String assetId) {
|
||||
return videoAssetService.listAssetJobs(assetId);
|
||||
}
|
||||
|
||||
@GetMapping("/{assetId}/events")
|
||||
public Object listAssetEvents(@PathVariable String assetId) {
|
||||
return videoAssetService.listAssetEvents(assetId);
|
||||
}
|
||||
|
||||
@PostMapping("/{assetId}/clip-jobs")
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
public Object createClipJob(
|
||||
@PathVariable String assetId,
|
||||
@Valid @RequestBody ClipJobRequest request,
|
||||
@RequestHeader(value = "Idempotency-Key", required = false) String idempotencyKey
|
||||
) {
|
||||
return videoAssetService.createClipJob(assetId, request, idempotencyKey);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{assetId}")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public void deleteAsset(@PathVariable String assetId) {
|
||||
videoAssetService.deleteAsset(assetId);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
package org.example.videoclips.api.dto;
|
||||
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import org.example.videoclips.domain.AccuracyMode;
|
||||
|
||||
public record ClipJobRequest(
|
||||
@NotNull @Min(1) @Max(3600) Integer segmentDurationSeconds,
|
||||
@NotNull AccuracyMode accuracyMode,
|
||||
@NotBlank String outputContainer,
|
||||
Boolean overwriteExisting
|
||||
) {
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
package org.example.videoclips.api.dto;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record CompleteUploadRequest(
|
||||
@NotBlank String uploadId,
|
||||
@NotEmpty List<@Valid UploadPartRequest> parts,
|
||||
@Min(1) long sourceDurationSeconds
|
||||
) {
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package org.example.videoclips.api.dto;
|
||||
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public record CreateVideoAssetRequest(
|
||||
@NotBlank @Size(max = 255) String fileName,
|
||||
@NotBlank @Pattern(regexp = "^video/.+$", message = "must be a video media type") String contentType,
|
||||
@Min(1) long contentLengthBytes,
|
||||
@Pattern(regexp = "^[a-fA-F0-9]{64}$", message = "must be a SHA-256 hex string")
|
||||
String checksumSha256,
|
||||
@Size(max = 100) String clipProfile
|
||||
) {
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package org.example.videoclips.api.dto;
|
||||
|
||||
public record SignedUrlResponse(
|
||||
String clipId,
|
||||
String downloadUrl,
|
||||
String expiresAt
|
||||
) {
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package org.example.videoclips.api.dto;
|
||||
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record UploadPartRequest(
|
||||
@Min(1) int partNumber,
|
||||
@NotBlank String etag
|
||||
) {
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package org.example.videoclips.application;
|
||||
|
||||
public class NotFoundException extends RuntimeException {
|
||||
|
||||
public NotFoundException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package org.example.videoclips.application;
|
||||
|
||||
public class PayloadTooLargeException extends RuntimeException {
|
||||
|
||||
public PayloadTooLargeException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package org.example.videoclips.application;
|
||||
|
||||
public class StateConflictException extends RuntimeException {
|
||||
|
||||
public StateConflictException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,365 @@
|
|||
package org.example.videoclips.application;
|
||||
|
||||
import org.example.videoclips.api.dto.ClipJobRequest;
|
||||
import org.example.videoclips.api.dto.CompleteUploadRequest;
|
||||
import org.example.videoclips.api.dto.CreateVideoAssetRequest;
|
||||
import org.example.videoclips.api.dto.SignedUrlResponse;
|
||||
import org.example.videoclips.config.VideoClippingProperties;
|
||||
import org.example.videoclips.domain.AccuracyMode;
|
||||
import org.example.videoclips.domain.Clip;
|
||||
import org.example.videoclips.domain.ClipJob;
|
||||
import org.example.videoclips.domain.ClipJobStatus;
|
||||
import org.example.videoclips.domain.JobEvent;
|
||||
import org.example.videoclips.domain.UploadSession;
|
||||
import org.example.videoclips.domain.VideoAsset;
|
||||
import org.example.videoclips.infrastructure.InMemoryVideoAssetRepository;
|
||||
import org.example.videoclips.queue.ClipJobQueuePort;
|
||||
import org.example.videoclips.storage.ObjectStoragePort;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
public class VideoAssetService {
|
||||
|
||||
private final InMemoryVideoAssetRepository repository;
|
||||
private final ObjectStoragePort objectStoragePort;
|
||||
private final ClipJobQueuePort clipJobQueuePort;
|
||||
private final VideoClippingProperties properties;
|
||||
|
||||
public VideoAssetService(
|
||||
InMemoryVideoAssetRepository repository,
|
||||
ObjectStoragePort objectStoragePort,
|
||||
ClipJobQueuePort clipJobQueuePort,
|
||||
VideoClippingProperties properties
|
||||
) {
|
||||
this.repository = repository;
|
||||
this.objectStoragePort = objectStoragePort;
|
||||
this.clipJobQueuePort = clipJobQueuePort;
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
public Map<String, Object> createAsset(CreateVideoAssetRequest request, String idempotencyKey) {
|
||||
if (request.contentLengthBytes() > properties.getMaxFileSizeBytes()) {
|
||||
throw new PayloadTooLargeException("contentLengthBytes exceeds configured max file size");
|
||||
}
|
||||
|
||||
String dedupeKey = normalizeKey(idempotencyKey, "asset:" + request.fileName() + ":" + request.contentLengthBytes());
|
||||
Map<String, Object> existing = repository.findAssetResponseByIdempotencyKey(dedupeKey);
|
||||
if (existing != null) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
String assetId = nextId("vid");
|
||||
String uploadId = nextId("upl");
|
||||
Instant now = Instant.now();
|
||||
|
||||
VideoAsset asset = new VideoAsset(
|
||||
assetId,
|
||||
request.fileName(),
|
||||
request.contentType(),
|
||||
request.contentLengthBytes(),
|
||||
request.checksumSha256(),
|
||||
request.clipProfile() == null || request.clipProfile().isBlank() ? "mp4-h264-aac" : request.clipProfile(),
|
||||
VideoAsset.Status.PENDING_UPLOAD,
|
||||
uploadId,
|
||||
null,
|
||||
now,
|
||||
null
|
||||
);
|
||||
|
||||
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));
|
||||
ObjectStoragePort.UploadSessionDescriptor upload = objectStoragePort.createMultipartUpload(
|
||||
uploadId,
|
||||
partCount,
|
||||
partSizeBytes,
|
||||
now.plus(properties.getUploadUrlTtlMinutes(), ChronoUnit.MINUTES)
|
||||
);
|
||||
appendJobEvent(assetId, "ASSET_CREATED", "Upload session created");
|
||||
|
||||
Map<String, Object> response = Map.of(
|
||||
"assetId", assetId,
|
||||
"status", asset.status().name(),
|
||||
"upload", Map.of(
|
||||
"uploadId", upload.uploadId(),
|
||||
"mode", upload.mode(),
|
||||
"partSizeBytes", upload.partSizeBytes(),
|
||||
"expiresAt", upload.expiresAt().toString(),
|
||||
"parts", upload.parts().stream().map(part -> Map.of(
|
||||
"partNumber", part.partNumber(),
|
||||
"url", part.url()
|
||||
)).toList()
|
||||
),
|
||||
"createdAt", now.toString()
|
||||
);
|
||||
repository.storeAssetResponseByIdempotencyKey(dedupeKey, response);
|
||||
return response;
|
||||
}
|
||||
|
||||
public Map<String, Object> completeUpload(String assetId, CompleteUploadRequest request) {
|
||||
VideoAsset asset = getAsset(assetId);
|
||||
if (!asset.uploadId().equals(request.uploadId())) {
|
||||
throw new StateConflictException("uploadId does not match the asset");
|
||||
}
|
||||
if (asset.status() != VideoAsset.Status.PENDING_UPLOAD) {
|
||||
throw new StateConflictException("asset upload is already completed");
|
||||
}
|
||||
|
||||
VideoAsset updated = asset.withUploadCompleted(request.sourceDurationSeconds(), Instant.now());
|
||||
repository.saveAsset(updated);
|
||||
repository.saveUploadSession(new UploadSession(request.uploadId(), assetId, UploadSession.Status.COMPLETED, Instant.now()));
|
||||
appendJobEvent(assetId, "UPLOAD_COMPLETED", "Video upload completed");
|
||||
|
||||
return Map.of(
|
||||
"assetId", updated.id(),
|
||||
"status", updated.status().name(),
|
||||
"uploadedAt", updated.uploadedAt().toString(),
|
||||
"sourceDurationSeconds", updated.sourceDurationSeconds()
|
||||
);
|
||||
}
|
||||
|
||||
public Map<String, Object> createClipJob(String assetId, ClipJobRequest request, String idempotencyKey) {
|
||||
VideoAsset asset = getAsset(assetId);
|
||||
if (asset.status() != VideoAsset.Status.UPLOADED && asset.status() != VideoAsset.Status.READY) {
|
||||
throw new StateConflictException("asset must be uploaded before a clip job can be created");
|
||||
}
|
||||
if (!"mp4".equalsIgnoreCase(request.outputContainer())) {
|
||||
throw new StateConflictException("only mp4 output is supported in this implementation");
|
||||
}
|
||||
|
||||
String dedupeKey = normalizeKey(idempotencyKey, "job:" + assetId + ":" + request.segmentDurationSeconds() + ":" + request.accuracyMode());
|
||||
Map<String, Object> existing = repository.findJobResponseByIdempotencyKey(dedupeKey);
|
||||
if (existing != null) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
boolean overwriteExisting = Boolean.TRUE.equals(request.overwriteExisting());
|
||||
if (!overwriteExisting && repository.hasActiveJobForAsset(assetId)) {
|
||||
throw new StateConflictException("an active clip job already exists for this asset");
|
||||
}
|
||||
|
||||
ClipJob job = new ClipJob(
|
||||
nextId("job"),
|
||||
assetId,
|
||||
ClipJobStatus.QUEUED,
|
||||
request.accuracyMode() == null ? AccuracyMode.FAST : request.accuracyMode(),
|
||||
request.segmentDurationSeconds(),
|
||||
0,
|
||||
0,
|
||||
Instant.now(),
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
repository.saveJob(job);
|
||||
repository.saveAsset(asset.withStatus(VideoAsset.Status.PROCESSING));
|
||||
appendEvent(job.id(), "QUEUED", "Clip job queued");
|
||||
|
||||
Map<String, Object> response = toJobResponse(job);
|
||||
repository.storeJobResponseByIdempotencyKey(dedupeKey, response);
|
||||
clipJobQueuePort.publish(job.id());
|
||||
return response;
|
||||
}
|
||||
|
||||
public Map<String, Object> getJob(String jobId) {
|
||||
return toJobResponse(getJobEntity(jobId));
|
||||
}
|
||||
|
||||
public Map<String, Object> getAssetResponse(String assetId) {
|
||||
VideoAsset asset = getAsset(assetId);
|
||||
return toAssetResponse(asset);
|
||||
}
|
||||
|
||||
public Map<String, Object> listAssetJobs(String assetId) {
|
||||
getAsset(assetId);
|
||||
List<Map<String, Object>> jobs = repository.findJobsByAssetId(assetId)
|
||||
.stream()
|
||||
.sorted(Comparator.comparing(ClipJob::createdAt))
|
||||
.map(this::toJobResponse)
|
||||
.toList();
|
||||
return Map.of("assetId", assetId, "jobs", jobs);
|
||||
}
|
||||
|
||||
public Map<String, Object> listAssetEvents(String assetId) {
|
||||
getAsset(assetId);
|
||||
List<Map<String, String>> events = repository.findEventsByAggregateId(assetId).stream()
|
||||
.sorted(Comparator.comparing(JobEvent::createdAt))
|
||||
.map(event -> Map.of(
|
||||
"eventId", event.id(),
|
||||
"eventType", event.eventType(),
|
||||
"message", event.message(),
|
||||
"createdAt", event.createdAt().toString()
|
||||
))
|
||||
.toList();
|
||||
return Map.of("assetId", assetId, "events", events);
|
||||
}
|
||||
|
||||
public Map<String, Object> listClips(String jobId) {
|
||||
ClipJob job = getJobEntity(jobId);
|
||||
List<Map<String, Object>> clips = repository.findClipsByJobId(jobId)
|
||||
.stream()
|
||||
.sorted(Comparator.comparingInt(Clip::clipIndex))
|
||||
.map(this::toClipResponse)
|
||||
.toList();
|
||||
return Map.of("jobId", job.id(), "clips", clips);
|
||||
}
|
||||
|
||||
public Map<String, Object> listJobEvents(String jobId) {
|
||||
getJobEntity(jobId);
|
||||
List<Map<String, String>> events = repository.findEventsByAggregateId(jobId).stream()
|
||||
.sorted(java.util.Comparator.comparing(JobEvent::createdAt))
|
||||
.map(event -> Map.of(
|
||||
"eventId", event.id(),
|
||||
"eventType", event.eventType(),
|
||||
"message", event.message(),
|
||||
"createdAt", event.createdAt().toString()
|
||||
))
|
||||
.toList();
|
||||
return Map.of("jobId", jobId, "events", events);
|
||||
}
|
||||
|
||||
public Map<String, Object> cancelJob(String jobId) {
|
||||
ClipJob job = getJobEntity(jobId);
|
||||
if (job.status() == ClipJobStatus.SUCCEEDED || job.status() == ClipJobStatus.FAILED || job.status() == ClipJobStatus.CANCELLED) {
|
||||
throw new StateConflictException("job can no longer be cancelled");
|
||||
}
|
||||
ClipJob updated = job.withStatus(ClipJobStatus.CANCEL_REQUESTED).withFinishedAt(Instant.now());
|
||||
repository.saveJob(updated);
|
||||
appendEvent(jobId, "CANCEL_REQUESTED", "Cancellation requested");
|
||||
return toJobResponse(updated);
|
||||
}
|
||||
|
||||
public void deleteAsset(String assetId) {
|
||||
VideoAsset asset = getAsset(assetId);
|
||||
repository.saveAsset(asset.withDeleted());
|
||||
}
|
||||
|
||||
public SignedUrlResponse createClipDownloadUrl(String clipId) {
|
||||
Clip clip = repository.findClipById(clipId);
|
||||
if (clip == null) {
|
||||
throw new NotFoundException("clip not found: " + clipId);
|
||||
}
|
||||
Instant expiresAt = Instant.now().plus(properties.getDownloadUrlTtlMinutes(), ChronoUnit.MINUTES);
|
||||
return new SignedUrlResponse(
|
||||
clipId,
|
||||
objectStoragePort.createClipDownloadUrl(clipId, expiresAt),
|
||||
expiresAt.toString()
|
||||
);
|
||||
}
|
||||
|
||||
public void markJobRunning(String jobId) {
|
||||
ClipJob job = getJobEntity(jobId);
|
||||
repository.saveJob(job.withStatus(ClipJobStatus.RUNNING).withStartedAt(Instant.now()));
|
||||
appendEvent(jobId, "STARTED", "Clip processing started");
|
||||
}
|
||||
|
||||
public void markJobProgress(String jobId, int progress) {
|
||||
ClipJob job = getJobEntity(jobId);
|
||||
repository.saveJob(job.withProgress(progress));
|
||||
appendEvent(jobId, "PROGRESS", "Job progress is " + progress + "%");
|
||||
}
|
||||
|
||||
public void completeJob(String jobId, List<Clip> clips) {
|
||||
ClipJob job = getJobEntity(jobId);
|
||||
clips.forEach(repository::saveClip);
|
||||
repository.saveJob(job.withStatus(ClipJobStatus.SUCCEEDED).withProgress(100).withFinishedAt(Instant.now()));
|
||||
VideoAsset asset = getAsset(job.assetId());
|
||||
repository.saveAsset(asset.withStatus(VideoAsset.Status.READY));
|
||||
appendEvent(jobId, "SUCCEEDED", "Generated " + clips.size() + " clips");
|
||||
}
|
||||
|
||||
public void failJob(String jobId, String message) {
|
||||
ClipJob job = getJobEntity(jobId);
|
||||
repository.saveJob(job.withStatus(ClipJobStatus.FAILED).withErrorMessage(message).withFinishedAt(Instant.now()));
|
||||
VideoAsset asset = getAsset(job.assetId());
|
||||
repository.saveAsset(asset.withStatus(VideoAsset.Status.UPLOADED));
|
||||
appendEvent(jobId, "FAILED", message);
|
||||
}
|
||||
|
||||
public boolean isCancellationRequested(String jobId) {
|
||||
return getJobEntity(jobId).status() == ClipJobStatus.CANCEL_REQUESTED;
|
||||
}
|
||||
|
||||
public VideoAsset getAsset(String assetId) {
|
||||
VideoAsset asset = repository.findAssetById(assetId);
|
||||
if (asset == null || asset.status() == VideoAsset.Status.DELETED) {
|
||||
throw new NotFoundException("asset not found: " + assetId);
|
||||
}
|
||||
return asset;
|
||||
}
|
||||
|
||||
private ClipJob getJobEntity(String jobId) {
|
||||
ClipJob job = repository.findJobById(jobId);
|
||||
if (job == null) {
|
||||
throw new NotFoundException("job not found: " + jobId);
|
||||
}
|
||||
return job;
|
||||
}
|
||||
|
||||
private Map<String, Object> toJobResponse(ClipJob job) {
|
||||
Map<String, Object> response = new LinkedHashMap<>();
|
||||
response.put("jobId", job.id());
|
||||
response.put("assetId", job.assetId());
|
||||
response.put("status", job.status().name());
|
||||
response.put("accuracyMode", job.accuracyMode().name());
|
||||
response.put("segmentDurationSeconds", job.segmentDurationSeconds());
|
||||
response.put("progressPercent", job.progressPercent());
|
||||
response.put("createdAt", job.createdAt().toString());
|
||||
response.put("startedAt", job.startedAt() == null ? null : job.startedAt().toString());
|
||||
response.put("finishedAt", job.finishedAt() == null ? null : job.finishedAt().toString());
|
||||
response.put("errorMessage", job.errorMessage());
|
||||
return response;
|
||||
}
|
||||
|
||||
private Map<String, Object> toAssetResponse(VideoAsset asset) {
|
||||
Map<String, Object> response = new LinkedHashMap<>();
|
||||
response.put("assetId", asset.id());
|
||||
response.put("fileName", asset.fileName());
|
||||
response.put("contentType", asset.contentType());
|
||||
response.put("contentLengthBytes", asset.contentLengthBytes());
|
||||
response.put("clipProfile", asset.clipProfile());
|
||||
response.put("status", asset.status().name());
|
||||
response.put("uploadId", asset.uploadId());
|
||||
response.put("sourceDurationSeconds", asset.sourceDurationSeconds());
|
||||
response.put("createdAt", asset.createdAt().toString());
|
||||
response.put("uploadedAt", asset.uploadedAt() == null ? null : asset.uploadedAt().toString());
|
||||
return response;
|
||||
}
|
||||
|
||||
private Map<String, Object> toClipResponse(Clip clip) {
|
||||
return Map.of(
|
||||
"clipId", clip.id(),
|
||||
"clipIndex", clip.clipIndex(),
|
||||
"startSeconds", clip.startSeconds(),
|
||||
"durationSeconds", clip.durationSeconds(),
|
||||
"contentType", clip.contentType(),
|
||||
"contentLengthBytes", clip.contentLengthBytes(),
|
||||
"createdAt", clip.createdAt().toString()
|
||||
);
|
||||
}
|
||||
|
||||
private String nextId(String prefix) {
|
||||
return prefix + "_" + UUID.randomUUID().toString().replace("-", "").substring(0, 12);
|
||||
}
|
||||
|
||||
private String normalizeKey(String providedKey, String fallback) {
|
||||
return providedKey == null || providedKey.isBlank() ? fallback : providedKey;
|
||||
}
|
||||
|
||||
private void appendJobEvent(String aggregateId, String eventType, String message) {
|
||||
repository.saveJobEvent(new JobEvent(nextId("evt"), aggregateId, eventType, message, Instant.now()));
|
||||
}
|
||||
|
||||
private void appendEvent(String jobId, String eventType, String message) {
|
||||
repository.saveJobEvent(new JobEvent(nextId("evt"), jobId, eventType, message, Instant.now()));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
package org.example.videoclips.config;
|
||||
|
||||
import jakarta.validation.constraints.Min;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
|
||||
@Validated
|
||||
@ConfigurationProperties(prefix = "video-clipping")
|
||||
public class VideoClippingProperties {
|
||||
|
||||
@Min(1)
|
||||
private long maxFileSizeBytes = 50L * 1024 * 1024 * 1024;
|
||||
|
||||
@Min(5 * 1024 * 1024)
|
||||
private long multipartPartSizeBytes = 100L * 1024 * 1024;
|
||||
|
||||
@Min(1)
|
||||
private int uploadUrlTtlMinutes = 60;
|
||||
|
||||
@Min(1)
|
||||
private int downloadUrlTtlMinutes = 15;
|
||||
|
||||
public long getMaxFileSizeBytes() {
|
||||
return maxFileSizeBytes;
|
||||
}
|
||||
|
||||
public void setMaxFileSizeBytes(long maxFileSizeBytes) {
|
||||
this.maxFileSizeBytes = maxFileSizeBytes;
|
||||
}
|
||||
|
||||
public long getMultipartPartSizeBytes() {
|
||||
return multipartPartSizeBytes;
|
||||
}
|
||||
|
||||
public void setMultipartPartSizeBytes(long multipartPartSizeBytes) {
|
||||
this.multipartPartSizeBytes = multipartPartSizeBytes;
|
||||
}
|
||||
|
||||
public int getUploadUrlTtlMinutes() {
|
||||
return uploadUrlTtlMinutes;
|
||||
}
|
||||
|
||||
public void setUploadUrlTtlMinutes(int uploadUrlTtlMinutes) {
|
||||
this.uploadUrlTtlMinutes = uploadUrlTtlMinutes;
|
||||
}
|
||||
|
||||
public int getDownloadUrlTtlMinutes() {
|
||||
return downloadUrlTtlMinutes;
|
||||
}
|
||||
|
||||
public void setDownloadUrlTtlMinutes(int downloadUrlTtlMinutes) {
|
||||
this.downloadUrlTtlMinutes = downloadUrlTtlMinutes;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package org.example.videoclips.domain;
|
||||
|
||||
public enum AccuracyMode {
|
||||
FAST,
|
||||
EXACT
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package org.example.videoclips.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record Clip(
|
||||
String id,
|
||||
String jobId,
|
||||
String assetId,
|
||||
int clipIndex,
|
||||
long startSeconds,
|
||||
long durationSeconds,
|
||||
String contentType,
|
||||
long contentLengthBytes,
|
||||
Instant createdAt
|
||||
) {
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
package org.example.videoclips.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record ClipJob(
|
||||
String id,
|
||||
String assetId,
|
||||
ClipJobStatus status,
|
||||
AccuracyMode accuracyMode,
|
||||
int segmentDurationSeconds,
|
||||
int progressPercent,
|
||||
int attemptCount,
|
||||
Instant createdAt,
|
||||
Instant startedAt,
|
||||
Instant finishedAt,
|
||||
String errorMessage
|
||||
) {
|
||||
public ClipJob withStatus(ClipJobStatus newStatus) {
|
||||
return new ClipJob(id, assetId, newStatus, accuracyMode, segmentDurationSeconds, progressPercent, attemptCount,
|
||||
createdAt, startedAt, finishedAt, errorMessage);
|
||||
}
|
||||
|
||||
public ClipJob withProgress(int progress) {
|
||||
return new ClipJob(id, assetId, status, accuracyMode, segmentDurationSeconds, progress, attemptCount,
|
||||
createdAt, startedAt, finishedAt, errorMessage);
|
||||
}
|
||||
|
||||
public ClipJob withStartedAt(Instant startedAtValue) {
|
||||
return new ClipJob(id, assetId, status, accuracyMode, segmentDurationSeconds, progressPercent, attemptCount,
|
||||
createdAt, startedAtValue, finishedAt, errorMessage);
|
||||
}
|
||||
|
||||
public ClipJob withFinishedAt(Instant finishedAtValue) {
|
||||
return new ClipJob(id, assetId, status, accuracyMode, segmentDurationSeconds, progressPercent, attemptCount,
|
||||
createdAt, startedAt, finishedAtValue, errorMessage);
|
||||
}
|
||||
|
||||
public ClipJob withErrorMessage(String message) {
|
||||
return new ClipJob(id, assetId, status, accuracyMode, segmentDurationSeconds, progressPercent, attemptCount,
|
||||
createdAt, startedAt, finishedAt, message);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package org.example.videoclips.domain;
|
||||
|
||||
public enum ClipJobStatus {
|
||||
QUEUED,
|
||||
RUNNING,
|
||||
SUCCEEDED,
|
||||
FAILED,
|
||||
CANCEL_REQUESTED,
|
||||
CANCELLED
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
package org.example.videoclips.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record JobEvent(
|
||||
String id,
|
||||
String jobId,
|
||||
String eventType,
|
||||
String message,
|
||||
Instant createdAt
|
||||
) {
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
package org.example.videoclips.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record UploadSession(
|
||||
String id,
|
||||
String assetId,
|
||||
Status status,
|
||||
Instant completedAt
|
||||
) {
|
||||
public enum Status {
|
||||
OPEN,
|
||||
COMPLETED,
|
||||
ABORTED,
|
||||
EXPIRED
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
package org.example.videoclips.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record VideoAsset(
|
||||
String id,
|
||||
String fileName,
|
||||
String contentType,
|
||||
long contentLengthBytes,
|
||||
String checksumSha256,
|
||||
String clipProfile,
|
||||
Status status,
|
||||
String uploadId,
|
||||
Long sourceDurationSeconds,
|
||||
Instant createdAt,
|
||||
Instant uploadedAt
|
||||
) {
|
||||
public enum Status {
|
||||
PENDING_UPLOAD,
|
||||
UPLOADED,
|
||||
PROCESSING,
|
||||
READY,
|
||||
DELETED
|
||||
}
|
||||
|
||||
public VideoAsset withUploadCompleted(long durationSeconds, Instant uploadedAt) {
|
||||
return new VideoAsset(id, fileName, contentType, contentLengthBytes, checksumSha256, clipProfile,
|
||||
Status.UPLOADED, uploadId, durationSeconds, createdAt, uploadedAt);
|
||||
}
|
||||
|
||||
public VideoAsset withStatus(Status status) {
|
||||
return new VideoAsset(id, fileName, contentType, contentLengthBytes, checksumSha256, clipProfile,
|
||||
status, uploadId, sourceDurationSeconds, createdAt, uploadedAt);
|
||||
}
|
||||
|
||||
public VideoAsset withDeleted() {
|
||||
return withStatus(Status.DELETED);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
package org.example.videoclips.infrastructure;
|
||||
|
||||
import org.example.videoclips.domain.Clip;
|
||||
import org.example.videoclips.domain.ClipJob;
|
||||
import org.example.videoclips.domain.ClipJobStatus;
|
||||
import org.example.videoclips.domain.JobEvent;
|
||||
import org.example.videoclips.domain.UploadSession;
|
||||
import org.example.videoclips.domain.VideoAsset;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
@Repository
|
||||
public class InMemoryVideoAssetRepository {
|
||||
|
||||
private final Map<String, VideoAsset> assets = new ConcurrentHashMap<>();
|
||||
private final Map<String, UploadSession> uploadSessions = new ConcurrentHashMap<>();
|
||||
private final Map<String, ClipJob> jobs = new ConcurrentHashMap<>();
|
||||
private final Map<String, Clip> clips = new ConcurrentHashMap<>();
|
||||
private final Map<String, JobEvent> jobEvents = new ConcurrentHashMap<>();
|
||||
private final Map<String, Map<String, Object>> assetResponsesByIdempotencyKey = new ConcurrentHashMap<>();
|
||||
private final Map<String, Map<String, Object>> jobResponsesByIdempotencyKey = new ConcurrentHashMap<>();
|
||||
|
||||
public void saveAsset(VideoAsset asset) {
|
||||
assets.put(asset.id(), asset);
|
||||
}
|
||||
|
||||
public VideoAsset findAssetById(String assetId) {
|
||||
return assets.get(assetId);
|
||||
}
|
||||
|
||||
public void saveUploadSession(UploadSession uploadSession) {
|
||||
uploadSessions.put(uploadSession.id(), uploadSession);
|
||||
}
|
||||
|
||||
public void saveJob(ClipJob job) {
|
||||
jobs.put(job.id(), job);
|
||||
}
|
||||
|
||||
public ClipJob findJobById(String jobId) {
|
||||
return jobs.get(jobId);
|
||||
}
|
||||
|
||||
public List<ClipJob> findJobsByAssetId(String assetId) {
|
||||
List<ClipJob> result = new ArrayList<>();
|
||||
for (ClipJob job : jobs.values()) {
|
||||
if (job.assetId().equals(assetId)) {
|
||||
result.add(job);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public void saveClip(Clip clip) {
|
||||
clips.put(clip.id(), clip);
|
||||
}
|
||||
|
||||
public List<Clip> findClipsByJobId(String jobId) {
|
||||
List<Clip> result = new ArrayList<>();
|
||||
for (Clip clip : clips.values()) {
|
||||
if (clip.jobId().equals(jobId)) {
|
||||
result.add(clip);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public Clip findClipById(String clipId) {
|
||||
return clips.get(clipId);
|
||||
}
|
||||
|
||||
public void saveJobEvent(JobEvent event) {
|
||||
jobEvents.put(event.id(), event);
|
||||
}
|
||||
|
||||
public List<JobEvent> findEventsByAggregateId(String aggregateId) {
|
||||
List<JobEvent> result = new ArrayList<>();
|
||||
for (JobEvent event : jobEvents.values()) {
|
||||
if (event.jobId().equals(aggregateId)) {
|
||||
result.add(event);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public boolean hasActiveJobForAsset(String assetId) {
|
||||
return jobs.values().stream().anyMatch(job ->
|
||||
job.assetId().equals(assetId)
|
||||
&& job.status() != ClipJobStatus.SUCCEEDED
|
||||
&& job.status() != ClipJobStatus.FAILED
|
||||
&& job.status() != ClipJobStatus.CANCELLED);
|
||||
}
|
||||
|
||||
public Map<String, Object> findAssetResponseByIdempotencyKey(String key) {
|
||||
return assetResponsesByIdempotencyKey.get(key);
|
||||
}
|
||||
|
||||
public void storeAssetResponseByIdempotencyKey(String key, Map<String, Object> response) {
|
||||
assetResponsesByIdempotencyKey.put(key, response);
|
||||
}
|
||||
|
||||
public Map<String, Object> findJobResponseByIdempotencyKey(String key) {
|
||||
return jobResponsesByIdempotencyKey.get(key);
|
||||
}
|
||||
|
||||
public void storeJobResponseByIdempotencyKey(String key, Map<String, Object> response) {
|
||||
jobResponsesByIdempotencyKey.put(key, response);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
package org.example.videoclips.processing;
|
||||
|
||||
import org.example.videoclips.domain.Clip;
|
||||
import org.example.videoclips.domain.ClipJob;
|
||||
import org.example.videoclips.domain.ClipJobStatus;
|
||||
import org.example.videoclips.domain.VideoAsset;
|
||||
import org.example.videoclips.domain.JobEvent;
|
||||
import org.example.videoclips.infrastructure.InMemoryVideoAssetRepository;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
@Component
|
||||
public class ClipProcessor {
|
||||
|
||||
private final InMemoryVideoAssetRepository repository;
|
||||
private final VideoClipperPort videoClipperPort;
|
||||
|
||||
public ClipProcessor(InMemoryVideoAssetRepository repository, VideoClipperPort videoClipperPort) {
|
||||
this.repository = repository;
|
||||
this.videoClipperPort = videoClipperPort;
|
||||
}
|
||||
|
||||
@Async
|
||||
public void process(String jobId) {
|
||||
try {
|
||||
ClipJob job = requireJob(jobId);
|
||||
repository.saveJob(job.withStatus(ClipJobStatus.RUNNING).withStartedAt(Instant.now()));
|
||||
recordEvent(jobId, "STARTED", "Background worker started");
|
||||
job = requireJob(jobId);
|
||||
VideoAsset asset = requireAsset(job.assetId());
|
||||
long totalDuration = asset.sourceDurationSeconds() == null ? 8L : asset.sourceDurationSeconds();
|
||||
int segmentDuration = job.segmentDurationSeconds();
|
||||
if (requireJob(jobId).status() == ClipJobStatus.CANCEL_REQUESTED) {
|
||||
repository.saveJob(requireJob(jobId).withStatus(ClipJobStatus.CANCELLED).withFinishedAt(Instant.now()));
|
||||
repository.saveAsset(requireAsset(asset.id()).withStatus(VideoAsset.Status.UPLOADED));
|
||||
recordEvent(jobId, "CANCELLED", "Job was cancelled before clip generation");
|
||||
return;
|
||||
}
|
||||
List<Clip> clips = videoClipperPort.generateClips(
|
||||
jobId,
|
||||
asset.id(),
|
||||
totalDuration,
|
||||
segmentDuration,
|
||||
job.accuracyMode()
|
||||
);
|
||||
for (int index = 0; index < clips.size(); index++) {
|
||||
int progress = (int) Math.min(99, ((index + 1L) * 100) / clips.size());
|
||||
repository.saveJob(requireJob(jobId).withProgress(progress));
|
||||
recordEvent(jobId, "PROGRESS", "Prepared clip " + (index + 1) + " of " + clips.size());
|
||||
}
|
||||
clips.forEach(repository::saveClip);
|
||||
repository.saveJob(requireJob(jobId).withStatus(ClipJobStatus.SUCCEEDED).withProgress(100).withFinishedAt(Instant.now()));
|
||||
repository.saveAsset(requireAsset(asset.id()).withStatus(VideoAsset.Status.READY));
|
||||
recordEvent(jobId, "SUCCEEDED", "Generated " + clips.size() + " clips");
|
||||
} catch (Exception ex) {
|
||||
ClipJob job = repository.findJobById(jobId);
|
||||
if (job != null) {
|
||||
repository.saveJob(job.withStatus(ClipJobStatus.FAILED).withErrorMessage(ex.getMessage()).withFinishedAt(Instant.now()));
|
||||
VideoAsset asset = repository.findAssetById(job.assetId());
|
||||
if (asset != null) {
|
||||
repository.saveAsset(asset.withStatus(VideoAsset.Status.UPLOADED));
|
||||
}
|
||||
recordEvent(jobId, "FAILED", ex.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private ClipJob requireJob(String jobId) {
|
||||
return repository.findJobById(jobId);
|
||||
}
|
||||
|
||||
private VideoAsset requireAsset(String assetId) {
|
||||
return repository.findAssetById(assetId);
|
||||
}
|
||||
|
||||
private void recordEvent(String jobId, String eventType, String message) {
|
||||
repository.saveJobEvent(new JobEvent(
|
||||
"evt_" + java.util.UUID.randomUUID().toString().replace("-", "").substring(0, 12),
|
||||
jobId,
|
||||
eventType,
|
||||
message,
|
||||
Instant.now()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
package org.example.videoclips.processing;
|
||||
|
||||
import org.example.videoclips.domain.AccuracyMode;
|
||||
import org.example.videoclips.domain.Clip;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@Component
|
||||
public class StubVideoClipperAdapter implements VideoClipperPort {
|
||||
|
||||
@Override
|
||||
public List<Clip> generateClips(
|
||||
String jobId,
|
||||
String assetId,
|
||||
long sourceDurationSeconds,
|
||||
int segmentDurationSeconds,
|
||||
AccuracyMode accuracyMode
|
||||
) {
|
||||
List<Clip> clips = new ArrayList<>();
|
||||
long start = 0;
|
||||
int index = 0;
|
||||
long bytesPerSecond = accuracyMode == AccuracyMode.EXACT ? 1_280_000L : 1_024_000L;
|
||||
while (start < sourceDurationSeconds) {
|
||||
long duration = Math.min(segmentDurationSeconds, sourceDurationSeconds - start);
|
||||
clips.add(new Clip(
|
||||
"clip_" + UUID.randomUUID().toString().replace("-", "").substring(0, 12),
|
||||
jobId,
|
||||
assetId,
|
||||
index,
|
||||
start,
|
||||
duration,
|
||||
"video/mp4",
|
||||
duration * bytesPerSecond,
|
||||
Instant.now()
|
||||
));
|
||||
start += segmentDurationSeconds;
|
||||
index++;
|
||||
}
|
||||
return clips;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package org.example.videoclips.processing;
|
||||
|
||||
import org.example.videoclips.domain.AccuracyMode;
|
||||
import org.example.videoclips.domain.Clip;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface VideoClipperPort {
|
||||
|
||||
List<Clip> generateClips(String jobId, String assetId, long sourceDurationSeconds, int segmentDurationSeconds, AccuracyMode accuracyMode);
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package org.example.videoclips.queue;
|
||||
|
||||
public interface ClipJobQueuePort {
|
||||
|
||||
void publish(String jobId);
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
package org.example.videoclips.queue;
|
||||
|
||||
import org.example.videoclips.processing.ClipProcessor;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class InMemoryClipJobQueueAdapter implements ClipJobQueuePort {
|
||||
|
||||
private final ClipProcessor clipProcessor;
|
||||
|
||||
public InMemoryClipJobQueueAdapter(ClipProcessor clipProcessor) {
|
||||
this.clipProcessor = clipProcessor;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Async
|
||||
public void publish(String jobId) {
|
||||
clipProcessor.process(jobId);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
package org.example.videoclips.storage;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
@Component
|
||||
public class InMemoryObjectStorageAdapter implements ObjectStoragePort {
|
||||
|
||||
@Override
|
||||
public UploadSessionDescriptor createMultipartUpload(String uploadId, int partCount, long partSizeBytes, Instant expiresAt) {
|
||||
List<UploadPartDescriptor> parts = java.util.stream.IntStream.rangeClosed(1, partCount)
|
||||
.mapToObj(partNumber -> new UploadPartDescriptor(
|
||||
partNumber,
|
||||
"https://storage.example/uploads/" + uploadId + "/parts/" + partNumber
|
||||
))
|
||||
.toList();
|
||||
return new UploadSessionDescriptor(uploadId, "multipart", partSizeBytes, expiresAt, parts);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String createClipDownloadUrl(String clipId, Instant expiresAt) {
|
||||
return "https://storage.example/clips/" + clipId + "?expiresAt=" + expiresAt.toString() + "&signature=demo";
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
package org.example.videoclips.storage;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
public interface ObjectStoragePort {
|
||||
|
||||
UploadSessionDescriptor createMultipartUpload(String uploadId, int partCount, long partSizeBytes, Instant expiresAt);
|
||||
|
||||
String createClipDownloadUrl(String clipId, Instant expiresAt);
|
||||
|
||||
record UploadPartDescriptor(int partNumber, String url) {
|
||||
}
|
||||
|
||||
record UploadSessionDescriptor(
|
||||
String uploadId,
|
||||
String mode,
|
||||
long partSizeBytes,
|
||||
Instant expiresAt,
|
||||
List<UploadPartDescriptor> parts
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
spring.application.name=video-clipping-service
|
||||
spring.mvc.problemdetails.enabled=true
|
||||
management.endpoints.web.exposure.include=health,info,metrics
|
||||
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
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
package org.example.videoclips.api;
|
||||
|
||||
import com.jayway.jsonpath.JsonPath;
|
||||
|
||||
final class JsonTestHelper {
|
||||
|
||||
private JsonTestHelper() {
|
||||
}
|
||||
|
||||
static String read(String json, String path) {
|
||||
return JsonPath.read(json, path);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
package org.example.videoclips.api;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import static org.hamcrest.Matchers.greaterThanOrEqualTo;
|
||||
import static org.hamcrest.Matchers.isOneOf;
|
||||
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.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
class VideoAssetControllerTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Test
|
||||
void createsAssetCompletesUploadAndProcessesClips() throws Exception {
|
||||
String assetResponse = mockMvc.perform(post("/v1/video-assets")
|
||||
.header("Idempotency-Key", "asset-key-1")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"fileName": "demo.mp4",
|
||||
"contentType": "video/mp4",
|
||||
"contentLengthBytes": 4096,
|
||||
"checksumSha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"clipProfile": "mp4-h264-aac"
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.assetId").exists())
|
||||
.andReturn()
|
||||
.getResponse()
|
||||
.getContentAsString();
|
||||
|
||||
String assetId = JsonTestHelper.read(assetResponse, "$.assetId");
|
||||
String uploadId = JsonTestHelper.read(assetResponse, "$.upload.uploadId");
|
||||
|
||||
mockMvc.perform(post("/v1/video-assets/{assetId}/uploads:complete", assetId)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"uploadId": "%s",
|
||||
"sourceDurationSeconds": 17,
|
||||
"parts": [
|
||||
{
|
||||
"partNumber": 1,
|
||||
"etag": "\\"etag-1\\""
|
||||
}
|
||||
]
|
||||
}
|
||||
""".formatted(uploadId)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.status").value("UPLOADED"));
|
||||
|
||||
mockMvc.perform(get("/v1/video-assets/{assetId}", assetId))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.assetId").value(assetId))
|
||||
.andExpect(jsonPath("$.status").value("UPLOADED"));
|
||||
|
||||
mockMvc.perform(get("/v1/video-assets/{assetId}/events", assetId))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.events.length()", greaterThanOrEqualTo(2)));
|
||||
|
||||
String jobResponse = mockMvc.perform(post("/v1/video-assets/{assetId}/clip-jobs", assetId)
|
||||
.header("Idempotency-Key", "job-key-1")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"segmentDurationSeconds": 8,
|
||||
"accuracyMode": "FAST",
|
||||
"outputContainer": "mp4",
|
||||
"overwriteExisting": false
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.jobId").exists())
|
||||
.andReturn()
|
||||
.getResponse()
|
||||
.getContentAsString();
|
||||
|
||||
String jobId = JsonTestHelper.read(jobResponse, "$.jobId");
|
||||
|
||||
mockMvc.perform(get("/v1/video-assets/{assetId}/clip-jobs", assetId))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.jobs.length()", greaterThanOrEqualTo(1)));
|
||||
|
||||
mockMvc.perform(get("/v1/clip-jobs/{jobId}", jobId))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.assetId").value(assetId))
|
||||
.andExpect(jsonPath("$.status", isOneOf("QUEUED", "RUNNING", "SUCCEEDED")));
|
||||
|
||||
for (int attempt = 0; attempt < 20; attempt++) {
|
||||
String statusBody = mockMvc.perform(get("/v1/clip-jobs/{jobId}", jobId))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn()
|
||||
.getResponse()
|
||||
.getContentAsString();
|
||||
if ("SUCCEEDED".equals(JsonTestHelper.read(statusBody, "$.status"))) {
|
||||
break;
|
||||
}
|
||||
Thread.sleep(50L);
|
||||
}
|
||||
|
||||
mockMvc.perform(get("/v1/clip-jobs/{jobId}/clips", jobId))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.clips.length()", greaterThanOrEqualTo(3)));
|
||||
|
||||
mockMvc.perform(get("/v1/clip-jobs/{jobId}/events", jobId))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.events.length()", greaterThanOrEqualTo(1)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsInvalidCreateRequest() throws Exception {
|
||||
mockMvc.perform(post("/v1/video-assets")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"fileName": "",
|
||||
"contentType": "application/json",
|
||||
"contentLengthBytes": 0
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.type").value("https://api.example.com/problems/validation-error"))
|
||||
.andExpect(jsonPath("$.title").value("Bad Request"))
|
||||
.andExpect(jsonPath("$.errors").isArray());
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsNotFoundProblemForMissingClip() throws Exception {
|
||||
mockMvc.perform(post("/v1/clips/{clipId}/download-url", "clip_missing")
|
||||
.contentType(MediaType.APPLICATION_JSON))
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(jsonPath("$.type").value("https://api.example.com/problems/not-found"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsOversizedAssetRequest() throws Exception {
|
||||
mockMvc.perform(post("/v1/video-assets")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"fileName": "huge.mp4",
|
||||
"contentType": "video/mp4",
|
||||
"contentLengthBytes": 999999999999,
|
||||
"checksumSha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isPayloadTooLarge())
|
||||
.andExpect(jsonPath("$.type").value("https://api.example.com/problems/file-too-large"));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue