WIP: Submit transcoding jobs via a HTTP API #6
@ -1,11 +1,14 @@
|
|||||||
package se.su.dsv.whisperapi;
|
package se.su.dsv.whisperapi;
|
||||||
|
|
||||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||||
|
import se.su.dsv.whisperapi.core.Job;
|
||||||
|
import se.su.dsv.whisperapi.core.JobCompletion;
|
||||||
import se.su.dsv.whisperapi.core.OutputFormat;
|
import se.su.dsv.whisperapi.core.OutputFormat;
|
||||||
import se.su.dsv.whisperapi.core.TranscriptionRepository;
|
import se.su.dsv.whisperapi.core.TranscriptionRepository;
|
||||||
import se.su.dsv.whisperapi.core.Transcription;
|
import se.su.dsv.whisperapi.core.Transcription;
|
||||||
|
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
|
import java.nio.file.Path;
|
||||||
import java.security.Principal;
|
import java.security.Principal;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@ -72,4 +75,72 @@ public class JDBCTranscriptionRepository implements TranscriptionRepository {
|
|||||||
.query(String.class)
|
.query(String.class)
|
||||||
.list();
|
.list();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void createNewJob(Transcription transcription, Job job) {
|
||||||
|
jdbcClient.sql("""
|
||||||
|
INSERT INTO jobs (id, transcription_id, result_file_absolute_path, error_message)
|
||||||
|
VALUES (:id, :transcription_id, :result_file, :error_message)
|
||||||
|
""")
|
||||||
|
.param("id", job.id())
|
||||||
|
.param("transcription_id", transcription.id())
|
||||||
|
.param("result_file", switch (job.status()) {
|
||||||
|
case Job.Status.Completed(JobCompletion.Success(Path resultFile)) ->
|
||||||
|
resultFile.toAbsolutePath().toString();
|
||||||
|
default -> null;
|
||||||
|
})
|
||||||
|
.param("error_message", switch (job.status()) {
|
||||||
|
case Job.Status.Completed(JobCompletion.Failure(String errorMessage)) -> errorMessage;
|
||||||
|
default -> null;
|
||||||
|
})
|
||||||
|
.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<Job> findJobById(UUID jobId) {
|
||||||
|
return jdbcClient.sql("""
|
||||||
|
SELECT id, result_file_absolute_path, error_message
|
||||||
|
FROM jobs
|
||||||
|
WHERE id = :id
|
||||||
|
""")
|
||||||
|
.param("id", jobId)
|
||||||
|
.query((rs, rowNum) -> {
|
||||||
|
UUID id = UUID.fromString(rs.getString("id"));
|
||||||
|
|
||||||
|
String resultFileAbsolutePath = rs.getString("result_file_absolute_path");
|
||||||
|
if (!rs.wasNull()) {
|
||||||
|
return new Job(id, new Job.Status.Completed(new JobCompletion.Success(Path.of(resultFileAbsolutePath))));
|
||||||
|
}
|
||||||
|
|
||||||
|
String errorMessage = rs.getString("error_message");
|
||||||
|
if (!rs.wasNull()) {
|
||||||
|
return new Job(id, new Job.Status.Completed(new JobCompletion.Failure(errorMessage)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Job(id, new Job.Status.Pending());
|
||||||
|
})
|
||||||
|
.optional();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setJobCompleted(Job job, JobCompletion jobCompletion) {
|
||||||
|
switch (jobCompletion) {
|
||||||
|
case JobCompletion.Success(Path resultFile) -> jdbcClient.sql("""
|
||||||
|
UPDATE jobs
|
||||||
|
SET result_file_absolute_path = :result_file
|
||||||
|
WHERE id = :id
|
||||||
|
""")
|
||||||
|
.param("id", job.id())
|
||||||
|
.param("result_file", resultFile.toAbsolutePath().toString())
|
||||||
|
.update();
|
||||||
|
case JobCompletion.Failure(String errorMessage) -> jdbcClient.sql("""
|
||||||
|
UPDATE jobs
|
||||||
|
SET error_message = :error_message
|
||||||
|
WHERE id = :id
|
||||||
|
""")
|
||||||
|
.param("id", job.id())
|
||||||
|
.param("error_message", errorMessage)
|
||||||
|
.update();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -25,7 +25,9 @@ public class WhisperApiApplication {
|
|||||||
public SecurityFilterChain apiSecurity(HttpSecurity http) throws Exception {
|
public SecurityFilterChain apiSecurity(HttpSecurity http) throws Exception {
|
||||||
return http
|
return http
|
||||||
.securityMatcher("/api/**")
|
.securityMatcher("/api/**")
|
||||||
.authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated())
|
.authorizeHttpRequests(authorize -> authorize
|
||||||
|
.requestMatchers("/api/transcriptions/job/callback/**").permitAll()
|
||||||
|
.anyRequest().authenticated())
|
||||||
.httpBasic(Customizer.withDefaults())
|
.httpBasic(Customizer.withDefaults())
|
||||||
.csrf(csrf -> csrf.disable())
|
.csrf(csrf -> csrf.disable())
|
||||||
.build();
|
.build();
|
||||||
|
@ -10,6 +10,9 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
|||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
import org.springframework.web.util.UriComponentsBuilder;
|
import org.springframework.web.util.UriComponentsBuilder;
|
||||||
import se.su.dsv.whisperapi.core.CreateTranscription;
|
import se.su.dsv.whisperapi.core.CreateTranscription;
|
||||||
|
import se.su.dsv.whisperapi.core.JobAlreadyCompleted;
|
||||||
|
import se.su.dsv.whisperapi.core.JobCompletion;
|
||||||
|
import se.su.dsv.whisperapi.core.JobNotFound;
|
||||||
import se.su.dsv.whisperapi.core.OutputFormat;
|
import se.su.dsv.whisperapi.core.OutputFormat;
|
||||||
import se.su.dsv.whisperapi.core.Transcription;
|
import se.su.dsv.whisperapi.core.Transcription;
|
||||||
import se.su.dsv.whisperapi.core.TranscriptionFile;
|
import se.su.dsv.whisperapi.core.TranscriptionFile;
|
||||||
@ -19,6 +22,7 @@ import java.io.IOException;
|
|||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.net.URISyntaxException;
|
import java.net.URISyntaxException;
|
||||||
|
import java.nio.file.Paths;
|
||||||
import java.security.Principal;
|
import java.security.Principal;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@ -106,7 +110,6 @@ public class ApiController {
|
|||||||
transcriptionService.submitTranscriptionJob(transcription, jobId -> uriComponentsBuilder.cloneBuilder()
|
transcriptionService.submitTranscriptionJob(transcription, jobId -> uriComponentsBuilder.cloneBuilder()
|
||||||
.path("api")
|
.path("api")
|
||||||
.pathSegment("transcriptions")
|
.pathSegment("transcriptions")
|
||||||
.pathSegment(id)
|
|
||||||
.pathSegment("job")
|
.pathSegment("job")
|
||||||
.pathSegment("callback")
|
.pathSegment("callback")
|
||||||
.pathSegment(jobId.toString())
|
.pathSegment(jobId.toString())
|
||||||
@ -115,12 +118,23 @@ public class ApiController {
|
|||||||
return ResponseEntity.accepted().build();
|
return ResponseEntity.accepted().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/api/transcriptions/{id}/job/callback/{jobId}")
|
@PostMapping("/api/transcriptions/job/callback/{jobId}")
|
||||||
public ResponseEntity<JobCallbackResponse> jobCallback(
|
public ResponseEntity<Void> jobCallback(
|
||||||
@PathVariable("id") String transcriptionId,
|
|
||||||
@PathVariable("jobId") String jobId,
|
@PathVariable("jobId") String jobId,
|
||||||
@RequestBody JobCallbackResponse jobCallbackResponse)
|
@RequestBody JobCallbackResponse jobCallbackResponse)
|
||||||
{
|
{
|
||||||
return ResponseEntity.ok(jobCallbackResponse);
|
try {
|
||||||
|
JobCompletion jobCompletion = switch (jobCallbackResponse) {
|
||||||
|
|
||||||
|
case JobCallbackResponse.Failure failure -> new JobCompletion.Failure(failure.errorMessage());
|
||||||
|
case JobCallbackResponse.Success success -> new JobCompletion.Success(Paths.get(success.resultFileAbsolutePath()));
|
||||||
|
};
|
||||||
|
transcriptionService.markJobAsCompleted(UUID.fromString(jobId), jobCompletion);
|
||||||
|
} catch (JobAlreadyCompleted | JobNotFound ignored) {
|
||||||
|
// do nothing
|
||||||
|
// since the callback is public we give no details to the caller
|
||||||
|
// every known error is treated as 200 OK
|
||||||
|
}
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -10,9 +10,13 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo;
|
|||||||
@JsonSubTypes.Type(value = JobCallbackResponse.Failure.class, name = "Failure")
|
@JsonSubTypes.Type(value = JobCallbackResponse.Failure.class, name = "Failure")
|
||||||
})
|
})
|
||||||
public sealed interface JobCallbackResponse {
|
public sealed interface JobCallbackResponse {
|
||||||
record Success(@JsonProperty("resultfile") String resultFileAbsolutePath) implements JobCallbackResponse {
|
record Success(
|
||||||
|
@JsonProperty(value = "resultfile", required = true) String resultFileAbsolutePath)
|
||||||
|
implements JobCallbackResponse {
|
||||||
}
|
}
|
||||||
|
|
||||||
record Failure(@JsonProperty("errormessage") String errorMessage) implements JobCallbackResponse {
|
record Failure(
|
||||||
|
@JsonProperty(value = "errormessage", required = true) String errorMessage)
|
||||||
|
implements JobCallbackResponse {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
17
src/main/java/se/su/dsv/whisperapi/core/Job.java
Normal file
17
src/main/java/se/su/dsv/whisperapi/core/Job.java
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
package se.su.dsv.whisperapi.core;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record Job(UUID id, Status status) {
|
||||||
|
public sealed interface Status {
|
||||||
|
record Pending() implements Status {}
|
||||||
|
record Completed(JobCompletion completion) implements Status {}
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isCompleted() {
|
||||||
|
return switch (status) {
|
||||||
|
case Status.Completed(var ignored) -> true;
|
||||||
|
case Status.Pending() -> false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,13 @@
|
|||||||
|
package se.su.dsv.whisperapi.core;
|
||||||
|
|
||||||
|
public class JobAlreadyCompleted extends Throwable {
|
||||||
|
private final Job job;
|
||||||
|
|
||||||
|
JobAlreadyCompleted(Job job) {
|
||||||
|
this.job = job;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Job job() {
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
}
|
12
src/main/java/se/su/dsv/whisperapi/core/JobCompletion.java
Normal file
12
src/main/java/se/su/dsv/whisperapi/core/JobCompletion.java
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
package se.su.dsv.whisperapi.core;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
public sealed interface JobCompletion {
|
||||||
|
record Success(Path resultFileAbsolutePath) implements JobCompletion {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param errorMessage intended for end users but will probably be highly technical in nature
|
||||||
|
*/
|
||||||
|
record Failure(String errorMessage) implements JobCompletion {}
|
||||||
|
}
|
15
src/main/java/se/su/dsv/whisperapi/core/JobNotFound.java
Normal file
15
src/main/java/se/su/dsv/whisperapi/core/JobNotFound.java
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
package se.su.dsv.whisperapi.core;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public class JobNotFound extends Exception {
|
||||||
|
private final UUID jobId;
|
||||||
|
|
||||||
|
JobNotFound(UUID jobId) {
|
||||||
|
this.jobId = jobId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public UUID jobId() {
|
||||||
|
return jobId;
|
||||||
|
}
|
||||||
|
}
|
@ -17,4 +17,13 @@ public interface TranscriptionRepository {
|
|||||||
* to the transcription.
|
* to the transcription.
|
||||||
*/
|
*/
|
||||||
List<String> getFiles(Transcription transcription);
|
List<String> getFiles(Transcription transcription);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param transcription the transcription this specific job is a part of
|
||||||
|
*/
|
||||||
|
void createNewJob(Transcription transcription, Job job);
|
||||||
|
|
||||||
|
Optional<Job> findJobById(UUID jobId);
|
||||||
|
|
||||||
|
void setJobCompleted(Job job, JobCompletion jobCompletion);
|
||||||
}
|
}
|
||||||
|
@ -80,6 +80,8 @@ public class TranscriptionService {
|
|||||||
|
|
||||||
try (var out = Files.newOutputStream(jobFile, StandardOpenOption.CREATE_NEW)) {
|
try (var out = Files.newOutputStream(jobFile, StandardOpenOption.CREATE_NEW)) {
|
||||||
objectMapper.writeValue(out, whisperJob);
|
objectMapper.writeValue(out, whisperJob);
|
||||||
|
Job job = new Job(jobId, new Job.Status.Pending());
|
||||||
|
transcriptionRepository.createNewJob(transcription, job);
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
throw new UncheckedIOException(e);
|
throw new UncheckedIOException(e);
|
||||||
}
|
}
|
||||||
@ -95,4 +97,15 @@ public class TranscriptionService {
|
|||||||
case JSON -> "json";
|
case JSON -> "json";
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void markJobAsCompleted(UUID jobId, JobCompletion jobCompletion)
|
||||||
|
throws JobNotFound, JobAlreadyCompleted
|
||||||
|
{
|
||||||
|
Job job = transcriptionRepository.findJobById(jobId)
|
||||||
|
.orElseThrow(() -> new JobNotFound(jobId));
|
||||||
|
if (job.isCompleted()) {
|
||||||
|
throw new JobAlreadyCompleted(job);
|
||||||
|
}
|
||||||
|
transcriptionRepository.setJobCompleted(job, jobCompletion);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
9
src/main/resources/db/migration/V3__jobs.sql
Normal file
9
src/main/resources/db/migration/V3__jobs.sql
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
CREATE TABLE jobs (
|
||||||
|
id UUID NOT NULL,
|
||||||
|
transcription_id UUID NOT NULL,
|
||||||
|
result_file_absolute_path VARCHAR(255),
|
||||||
|
error_message TEXT,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
CONSTRAINT FK_jobs_transcription
|
||||||
|
FOREIGN KEY (transcription_id) REFERENCES transcriptions(id)
|
||||||
|
);
|
Loading…
x
Reference in New Issue
Block a user