In the former Vaadin Discord channel and in the current Vaadin Forum, the question of how to implement file uploads and downloads in web apps based on the Hilla framework has come up frequently. I would like to use a small example to show a possible solution. If you are curious, you can watch the result as a video at the end of the article.
Separation of Concerns
The Hilla framework does a great job in making communication between the frontend and backend type-safe and simple. Communication between the frontend and backend takes place via a dedicated HTTP client and corresponding backend services. JSON is used as the data exchange format. Uploading and downloading files could also be implemented with this type of communication, but would have some disadvantages, including the serialization and deserialization of the files to be transferred.
Fortunately, there are alternatives. At its core, a Hilla application is a classic Spring Boot project. This means that countless functions from the powerful Spring ecosystem are available in the backend. In the context of Spring, the handling of file uploads and downloads is very easy to implement, for example using a RestController.
Backend preparations
In this example, a file is represented by the following entity File
:
@Entity
@Table(name = "files")
public class File {
@Id
@GeneratedValue(strategy= GenerationType.IDENTITY)
Long id;
@NotBlank
private String filename;
@NotBlank
private String originalFilename;
@NotBlank
private String contentType;
@NotNull
private Long size;
@NotNull
private LocalDate created;
// Getter and Setter omitted
}
However, this entity only contains the metadata for a file, but not the file itself. The metadata is persisted in a suitable Spring Data JPA repository called FileRepository
:
public interface FileRepository extends JpaRepository<File, Long>, JpaSpecificationExecutor<File> {
Optional<File> findOneByFilename(String filename);
}
Writing and reading a file to and from the file system and deleting a file from the file system is done via a FileStorageService
and a FileHandler
:
public class FileHandler {
private static final Logger logger = LoggerFactory.getLogger(FileHandler.class);
public static byte[] readFileFromFilesystem(String filename) {
try (FileInputStream fis = new FileInputStream(filename)) {
return fis.readAllBytes();
} catch (IOException e) {
logger.error("Could not read file from filesystem.", e);
throw new RuntimeException(e);
}
}
public static void writeFileToFilesystem(InputStream in, String filename) {
FileOutputStream fos;
try {
fos = new FileOutputStream(filename);
fos.write(in.readAllBytes());
fos.close();
} catch (IOException e) {
logger.error("Could not write file to filesystem.", e);
throw new RuntimeException(e);
}
}
public static void deleteFileFromFilesystem(String filename) {
try {
Files.deleteIfExists(Paths.get(filename));
} catch (IOException e) {
logger.error("Could not delete file from filesystem.", e);
throw new RuntimeException(e);
}
}
}
@Component
public class FileStorageService {
@Value("${storage.file.path}")
private String path;
private final Logger logger = LoggerFactory.getLogger(FileStorageService.class);
public void save(InputStream is, String filename) {
logger.debug("Saving file '" + filename + "' to path '" + path + "'");
FileHandler.writeFileToFilesystem(is, path + "/" + filename);
}
public byte[] read(String filename) {
logger.debug("Reading file '" + filename + "' from path '" + path + "'");
return FileHandler.readFileFromFilesystem(path + "/" + filename);
}
public void delete(String filename) {
logger.debug("Deleting file '" + filename + "' from path '" + path + "'");
FileHandler.deleteFileFromFilesystem(path + "/" + filename);
}
}
The actual storage path of the files in the file system is determined by the configuration storage.file.path
.
A separate FileService
simplifies the handling of files and ensures, for example, that when a file is uploaded, the metadata is persisted via the FileRepository
and the actual file is stored in the file system using the FileStorageService
.
@Component
public class FileService {
@Autowired
FileRepository fileRepository;
@Autowired
FileStorageService fileStorageService;
private final Logger logger = LoggerFactory.getLogger(FileService.class);
public File save(MultipartFile multipartFile) {
File file = new File(multipartFile);
logger.debug("Saving " + file + " to database.");
fileRepository.save(file);
try {
fileStorageService.save(multipartFile.getInputStream(), file.getFilename());
} catch (IOException e) {
throw new RuntimeException(e);
}
return file;
}
public Resource readFile(String filename) {
return new ByteArrayResource(fileStorageService.read(filename));
}
public Optional<File> read(String filename) {
logger.debug("Reading file with file '" + filename + "' from database.");
return fileRepository.findOneByFilename(filename);
}
}
Spring RestController
We provide a classic Spring RestController called FileController
for uploading and downloading a file:
@RestController
@RequestMapping("/api")
public class FileController {
private final Logger logger = LoggerFactory.getLogger(FileController.class);
@Autowired
FileService fileService;
@PostMapping("/files")
public ResponseEntity<File> upload(@RequestParam("file") MultipartFile multipartFile) {
logger.debug("Uploading file '" + multipartFile.getOriginalFilename() + "'");
try {
File file = fileService.save(multipartFile);
return ResponseEntity.ok().body(file);
} catch (Exception e) {
logger.error("Error uploading file.", e);
return ResponseEntity.internalServerError().build();
}
}
@GetMapping("/files/{filename:.+}")
public ResponseEntity<Resource> download(@PathVariable String filename) {
logger.debug("Downloading file '" + filename + "'");
Optional<File> file = fileService.read(filename);
if (file.isPresent()) {
Resource resource = fileService.readFile(filename);
if (resource != null) {
return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"" + file.get().getOriginalFilename() + "\"").body(resource);
}
}
return ResponseEntity.notFound().build();
}
}
A file is uploaded via HTTP-POST using the path /api/files
and downloaded via HTTP-Get using the path /api/files/{filename:.+}
.
Browser-Callable
For the metadata of a file, we create our own Hilla-specific endpoint called FileBrowserCallable
:
@BrowserCallable
@AnonymousAllowed
public class FileBrowserCallable extends CrudRepositoryService<File, Long, FileRepository> { }
The annotation @BrowserCallable
ensures that Hilla automatically generates a type-safe TypeScript client and associated files, which we can then use in the frontend.
Frontend
The frontend of our example application uses the new File-Router of Hilla. The file src/main/frontend/views/files/@index.tsx
contains an Upload component, which is used for the actual upload:
<Upload
accept='application/pdf,.pdf'
maxFiles={1}
maxFileSize={10485760}
target='/api/files'
className='w-full'
onUploadSuccess={() => {
autoGridRef.current?.refresh();
}}
/>
In this case, the Upload
component only allows the upload of PDF files. The upload takes place via the path /api/files
and thus directly to the upload
method of the FileController
shown above.
The view also contains an AutoGrid component for displaying the metadata of all uploaded files. The previously generated files are used and take care of all communication with the backend:
<AutoGrid ref={autoGridRef} model={FileModel} service={FileBrowserCallable} />
In addition, the AutoGrid
component has a ContextMenu, which can be used to download and delete files:
const autoGridRef = useRef<AutoGridRef>(null);
const renderMenu = ({ context }: MenuProps) => {
const grid = autoGridRef.current?.grid;
if (!grid) {
return null;
}
const { sourceEvent } = context.detail as { sourceEvent: Event };
const eventContext = grid.getEventContext(sourceEvent);
const file = eventContext.item;
if (!file) {
return null;
}
return (
<ListBox>
<Item
onClick={async () => {
if (file?.id) {
try {
await FileBrowserCallable.delete(file.id);
if (autoGridRef.current) {
autoGridRef.current.refresh();
}
} catch (e) {
alert(e);
}
}
}}
>
Delete
</Item>
<Item
onClick={() => {
window.open(`/api/files/${file.filename}`, '_blank');
}}
>
Download
</Item>
</ListBox>
);
};
// ...
<ContextMenu renderer={renderMenu} className='w-full'>
<AutoGrid ref={autoGridRef} model={FileModel} service={FileBrowserCallable} />
</ContextMenu>
As you can see, the download is based on the file name via the path /api/files/${file.filename}
and with a simple HTML link that opens the file in a new window or tab for download. The download thus takes place via the download
method of the FileController
shown above.
With the help of the Upload
and AutoGrid
components, files can now be uploaded, downloaded and deleted as required.
EntityListener
In order to ensure that when a file is deleted via the ContextMenu
, the associated file in the file system is also deleted in addition to the metadata, the use of an EntityListener
is advisable:
@Component
public class FileListener {
@Autowired
FileStorageService fileStorageService;
@PostRemove
private void afterDelete(File file) {
fileStorageService.delete(file.getFilename());
}
}
The FileListener
is registered to the entity File
using the annotation @EntityListeners
:
@Entity
@EntityListeners(FileListener.class)
@Table(name = "files")
public class File {
// ...
}
Summary
Uploading and downloading files is very easy to implement in web apps based on the Hilla framework. It is important to use the strengths of the frameworks and tools involved correctly. Hilla is great at displaying and handling the metadata of a file. Spring, on the other hand, handles the actual file. Together, this results in flexible options for adapting file uploads and downloads to the respective needs of your own application.
The source code for the example is available at GitHub.