WIP: Submit transcoding jobs via a HTTP API #6

Draft
ansv7779 wants to merge 22 commits from api-submission into master
12 changed files with 184 additions and 3 deletions
Showing only changes of commit 69c3e86360 - Show all commits

View File

@ -1,9 +1,15 @@
package se.su.dsv.whisperapi;
import org.springframework.jdbc.core.simple.JdbcClient;
import se.su.dsv.whisperapi.core.OutputFormat;
import se.su.dsv.whisperapi.core.TransactionRepository;
import se.su.dsv.whisperapi.core.Transcription;
import java.net.URI;
import java.security.Principal;
import java.util.Optional;
import java.util.UUID;
public class JDBCTransactionRepository implements TransactionRepository {
private final JdbcClient jdbcClient;
@ -23,4 +29,34 @@ public class JDBCTransactionRepository implements TransactionRepository {
.param("output_format", transcription.outputFormat().name())
.update();
}
@Override
public Optional<Transcription> findByOwnerAndId(Principal owner, UUID uuid) {
return jdbcClient.sql("""
SELECT id, owner, callback_uri, output_format
FROM transcriptions
WHERE id = :id AND owner = :owner
""")
.param("id", uuid)
.param("owner", owner.getName())
.query((rs, rowNum) -> {
UUID id = UUID.fromString(rs.getString("id"));
URI callbackUri = URI.create(rs.getString("callback_uri"));
OutputFormat outputFormat = OutputFormat.valueOf(rs.getString("output_format"));
return new Transcription(id, owner, callbackUri, outputFormat);
})
.optional();
}
@Override
public void addFileToTranscription(Transcription transcription, String filename) {
jdbcClient.sql("""
INSERT INTO transcriptions_files (transcription_id, filename)
VALUES (:transcription_id, :filename)
ON DUPLICATE KEY UPDATE filename = :filename
""")
.param("transcription_id", transcription.id())
.param("filename", filename)
.update();
}
}

View File

@ -2,6 +2,7 @@ package se.su.dsv.whisperapi;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.core.annotation.Order;
import org.springframework.jdbc.core.simple.JdbcClient;
@ -12,6 +13,7 @@ import se.su.dsv.whisperapi.core.TransactionRepository;
import se.su.dsv.whisperapi.core.TranscriptionService;
@SpringBootApplication
@EnableConfigurationProperties(WhisperFrontendConfiguration.class)
public class WhisperApiApplication {
public static void main(String[] args) {
@ -39,8 +41,11 @@ public class WhisperApiApplication {
}
@Bean
public TranscriptionService transcriptionService(TransactionRepository transcriptionRepository) {
return new TranscriptionService(transcriptionRepository);
public TranscriptionService transcriptionService(
TransactionRepository transcriptionRepository,
WhisperFrontendConfiguration config)
{
return new TranscriptionService(transcriptionRepository, config.transcriptionFilesDirectory());
}
@Bean

View File

@ -0,0 +1,11 @@
package se.su.dsv.whisperapi;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.nio.file.Path;
@ConfigurationProperties(prefix = "whisper.frontend")
public record WhisperFrontendConfiguration(
Path transcriptionFilesDirectory)
{
}

View File

@ -1,17 +1,25 @@
package se.su.dsv.whisperapi.api;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
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.RestController;
import se.su.dsv.whisperapi.core.CreateTranscription;
import se.su.dsv.whisperapi.core.OutputFormat;
import se.su.dsv.whisperapi.core.Transcription;
import se.su.dsv.whisperapi.core.TranscriptionFile;
import se.su.dsv.whisperapi.core.TranscriptionService;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.URISyntaxException;
import java.security.Principal;
import java.util.UUID;
/**
* For submitting jobs programmatically via a JSON HTTP API.
@ -21,6 +29,7 @@ import java.security.Principal;
@RestController
@RequestMapping(consumes = "application/json", produces = "application/json")
public class ApiController {
private static final System.Logger LOG = System.getLogger(ApiController.class.getName());
private final TranscriptionService transcriptionService;
@ -54,4 +63,33 @@ public class ApiController {
default -> throw new InvalidOutputFormat(outputFormat);
};
}
/**
* Attach a file to be transcribed in the specified job. Multiple files can be attached to a single job.
* They must be unique based on filename, same filename will overwrite the existing file.
*/
@PutMapping(value = "/api/transcriptions/{id}/file", consumes = "*/*")
public ResponseEntity<Void> uploadFileToBeTranscribed(
Principal owner,
@PathVariable("id") String id,
@RequestHeader("X-Filename") String filename,
InputStream fileData)
{
try {
if (filename == null || filename.isBlank()) {
throw new MissingFilename();
}
UUID uuid = UUID.fromString(id);
Transcription transcription = transcriptionService.getTranscription(owner, uuid)
.orElseThrow(() -> new TranscriptionNotFound(id));
transcriptionService.addFileToBeTranscribed(transcription, new TranscriptionFile(filename, fileData));
return ResponseEntity.accepted().build();
} catch (IllegalArgumentException ignored) {
// Invalid UUID
throw new TranscriptionNotFound(id);
} catch (IOException e) {
LOG.log(System.Logger.Level.ERROR, "Failed to save file", e);
throw new FileUploadFailed();
}
}
}

View File

@ -0,0 +1,14 @@
package se.su.dsv.whisperapi.api;
import org.springframework.http.HttpStatus;
import org.springframework.web.ErrorResponseException;
import java.net.URI;
public class FileUploadFailed extends ErrorResponseException {
public FileUploadFailed() {
super(HttpStatus.INTERNAL_SERVER_ERROR);
setType(URI.create("https://gitea.dsv.su.se/DMC/whisper-frontend/wiki/Errors#file-upload-failed"));
setTitle("File upload failed");
}
}

View File

@ -0,0 +1,15 @@
package se.su.dsv.whisperapi.api;
import org.springframework.http.HttpStatus;
import org.springframework.web.ErrorResponseException;
import java.net.URI;
public class MissingFilename extends ErrorResponseException {
public MissingFilename() {
super(HttpStatus.BAD_REQUEST);
setType(URI.create("https://gitea.dsv.su.se/DMC/whisper-frontend/wiki/Errors#missing-filename"));
setTitle("Missing filename");
setDetail("A filename must be specified in the X-Filename header");
}
}

View File

@ -0,0 +1,15 @@
package se.su.dsv.whisperapi.api;
import org.springframework.http.HttpStatus;
import org.springframework.web.ErrorResponseException;
import java.net.URI;
public class TranscriptionNotFound extends ErrorResponseException {
public TranscriptionNotFound(String id) {
super(HttpStatus.BAD_REQUEST);
setType(URI.create("https://gitea.dsv.su.se/DMC/whisper-frontend/wiki/Errors#transcription-file-not-found"));
setTitle("Transcription not found");
setDetail("Transcription with id '" + id + "' not found");
}
}

View File

@ -1,5 +1,13 @@
package se.su.dsv.whisperapi.core;
import java.security.Principal;
import java.util.Optional;
import java.util.UUID;
public interface TransactionRepository {
void save(Transcription transcription);
Optional<Transcription> findByOwnerAndId(Principal owner, UUID uuid);
void addFileToTranscription(Transcription transcription, String filename);
}

View File

@ -0,0 +1,6 @@
package se.su.dsv.whisperapi.core;
import java.io.InputStream;
public record TranscriptionFile(String filename, InputStream data) {
}

View File

@ -1,12 +1,20 @@
package se.su.dsv.whisperapi.core;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.security.Principal;
import java.util.Optional;
import java.util.UUID;
public class TranscriptionService {
private final TransactionRepository transactionRepository;
private final Path fileDirectory;
public TranscriptionService(TransactionRepository transactionRepository) {
public TranscriptionService(TransactionRepository transactionRepository, Path fileDirectory) {
this.transactionRepository = transactionRepository;
this.fileDirectory = fileDirectory;
}
@ -20,4 +28,21 @@ public class TranscriptionService {
transactionRepository.save(transcription);
return transcription;
}
public Optional<Transcription> getTranscription(Principal owner, UUID uuid) {
return transactionRepository.findByOwnerAndId(owner, uuid);
}
public void addFileToBeTranscribed(Transcription transcription, TranscriptionFile file)
throws IOException
{
Path fileToBeTranscribed = fileDirectory.resolve(transcription.id().toString()).resolve(file.filename());
Files.createDirectories(fileToBeTranscribed.getParent());
try (var out = Files.newOutputStream(fileToBeTranscribed, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
var in = file.data())
{
in.transferTo(out);
transactionRepository.addFileToTranscription(transcription, file.filename());
}
}
}
ansv7779 marked this conversation as resolved
Review

Avoid using the filename provided by the user as a security measure.

Avoid using the filename provided by the user as a security measure.
Review

Fixed in 515b2aa642

Fixed in https://gitea.dsv.su.se/DMC/whisper-frontend/commit/515b2aa642d3bdce23a27f96ae532f2a1ea29543

View File

@ -16,3 +16,4 @@ spring.mvc.problemdetails.enabled=true
# Disable logging of resolved exceptions or every ErrorResponseException (ProblemDetails)
# that is correctly handled will be logged as a warning
spring.mvc.log-resolved-exception=false
whisper.frontend.transcription-files-directory=/tmp

View File

@ -0,0 +1,7 @@
CREATE TABLE transcriptions_files (
transcription_id UUID NOT NULL,
filename VARCHAR(255) NOT NULL,
PRIMARY KEY (transcription_id, filename),
CONSTRAINT FK_transcriptions_files_transcription
FOREIGN KEY (transcription_id) REFERENCES transcriptions (id) ON DELETE CASCADE
);