WIP: Submit transcoding jobs via a HTTP API #6

Draft
ansv7779 wants to merge 22 commits from api-submission into master
11 changed files with 187 additions and 8 deletions
Showing only changes of commit 19a3b3c9d1 - Show all commits

View File

@ -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();
}
}
} }

View File

@ -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();

View File

@ -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();
} }
} }

View File

@ -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 {
} }
} }

View 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;
};
}
}

View File

@ -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;
}
}

View 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 {}
}

View 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;
}
}

View File

@ -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);
} }

View File

@ -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);
}
} }

View 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)
);