File upload and download in Hilla web apps

René Wilby | Apr 19, 2024 min read

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.