In a traditional microservice premise, a client application (e.g., an Angular frontend) rarely hits a backend service directly. Usually, a middleware (e.g., an Express application) sits between the backend and client, facilitating the communication, as a backend for frontend. In this post, we’ll explore how Express can facilitate a file upload with a reactive Spring backend and an Angular frontend.
Create an upload service with Spring Boot
Generate a Spring Boot application with Spring Initializr; include spring-boot-starter-webflux
and spring-boot-configuration-processor
as the dependencies.
Create an interface that describes the methods to upload and fetch the files.
// src/main/java/dev/mflash/guides/upload/service/StorageService.java
public interface StorageService {
void init();
void store(List<FilePart> files);
Stream<Path> loadAll();
Path load(String filename);
Resource loadAsResource(String filename);
void deleteAll();
}
Implement this interface to read and write to a filesystem.
// src/main/java/dev/mflash/guides/upload/service/FileSystemStorageService.java
public @Service class FileSystemStorageService implements StorageService {
private final Path rootDir;
public FileSystemStorageService(StorageProperties storageProperties) {
this.rootDir = Paths.get(storageProperties.getLocation());
}
public @Override void init() {
try {
Files.createDirectories(rootDir);
catch (Exception e) {
} throw new StorageException("Could not initialize storage", e);
}
}
public @Override void store(List<FilePart> files) {
if (files.size() > 0) {
.forEach(file -> {
filesString filename = StringUtils.cleanPath(Objects.requireNonNull(file.filename()));
if (filename.contains("..")) {
throw new StorageException("Cannot store file with relative path outside current directory " + filename);
}.transferTo(Paths.get(this.rootDir.toString(), filename));
file
});else {
} throw new StorageException("Invalid request payload");
}
}
public @Override Stream<Path> loadAll() {
try {
return Files.walk(this.rootDir, 1)
-> !path.equals(this.rootDir))
.filter(path this.rootDir::relativize);
.map(catch (IOException e) {
} throw new StorageException("Failed to read stored files", e);
}
}
public @Override Path load(String filename) {
return this.rootDir.resolve(filename);
}
public @Override Resource loadAsResource(String filename) {
try {
Path file = load(filename);
Resource resource = new UrlResource(file.toUri());
if (resource.exists() || resource.isReadable()) {
return resource;
else {
} throw new StorageException("Could not read file: " + filename);
}catch (MalformedURLException e) {
} throw new StorageException("Could not read file: " + filename, e);
}
}
public @Override void deleteAll() {
FileSystemUtils.deleteRecursively(rootDir.toFile());
} }
This service
- creates a directory to write files to with
init
method - write files with
store
method - returns a list of uploaded files with
loadAll
method - returns a requested file by name with
loadResource
method, and - cleans up the upload directory with
deleteAll
method
Create a handler to accept a request and prepare the response.
// src/main/java/dev/mflash/guides/upload/handler/FileSystemStorageHandler.java
public @Controller class FileSystemStorageHandler {
private final StorageService storageService;
public FileSystemStorageHandler(StorageService storageService) {
this.storageService = storageService;
}
public Mono<ServerResponse> listAllFiles(ServerRequest request) {
return ServerResponse.ok().contentType(MediaType.APPLICATION_JSON)
BodyInserters.fromValue(storageService.loadAll()));
.body(
}
public Mono<ServerResponse> getFile(ServerRequest request) {
String fileName = request.queryParam("fileName").get();
try {
return ServerResponse.ok().contentType(MediaType.APPLICATION_OCTET_STREAM)
HttpHeaders.CONTENT_DISPOSITION, String.format("attachment; filename=\"%s\"", fileName)).body(
.header(BodyInserters.fromValue(storageService.loadAsResource(fileName))
);catch (StorageException e) {
} return ServerResponse.notFound().build();
}
}
public Mono<ServerResponse> uploadFile(ServerRequest request) {
return request.multipartData().flatMap(parts -> {
try {
storageService.get("data").parallelStream().map(part -> (FilePart) part).collect(Collectors.toList()));
.store(partsreturn ServerResponse.ok().contentType(MediaType.APPLICATION_JSON).body(
BodyInserters.fromValue(Map.of("status", "Successfully uploaded"))
);catch (Exception e) {
} return ServerResponse.status(HttpStatus.INTERNAL_SERVER_ERROR).contentType(MediaType.APPLICATION_JSON).body(
BodyInserters.fromValue(Map.of("status", e.getLocalizedMessage()))
);
}
});
} }
Configure the routes
Note that the handler methods don’t have any endpoint information. In a reactive Spring Boot application, this information (also called routes) can be provided through a RouterFunction
bean as follows.
// src/main/java/dev/mflash/guides/upload/configuration/FileSystemStorageRouter.java
public @Configuration class FileSystemStorageRouter {
public @Bean RouterFunction<ServerResponse> storageRouter(FileSystemStorageHandler storageHandler) {
return RouterFunctions
RequestPredicates.GET("/file"), storageHandler::listAllFiles)
.route(RequestPredicates.GET("/file/download"), storageHandler::getFile)
.andRoute(RequestPredicates.POST("/file").and(RequestPredicates.accept(MediaType.MULTIPART_FORM_DATA)),
.andRoute(::uploadFile);
storageHandler
} }
Multiple routes can be configured through the same bean, with the type of route and request content-type. The actual requests are simply passed to the handler methods which prepare and return a response.
Configure CORS
Say, we decide to run Express on the port 8080
. We’ll have to provide a CORS filter to the Spring Boot application so that it may accept the requests from Express. This can be done by overriding the addCorsMappings
method of the WebFluxConfigurer
interface.
// src/main/java/dev/mflash/guides/upload/Launcher.java
public @SpringBootApplication class Launcher implements WebFluxConfigurer {
public static void main(String[] args) {
SpringApplication.run(Launcher.class, args);
}
public @Override void addCorsMappings(CorsRegistry registry) {
.addMapping("/**").allowedOrigins("http://localhost:8080");
registry
} }
Since Express will be running at the port 8080
, let’s configure a different port for the Spring application by editing application.yml
file.
server:
port: 8079
Create an Express middleware
Generate a Node.js application by executing the following command.
yarn init -y
We’ll use
morgan
for request loggingmulter
to handlemultipart/form-data
form-data
to build urlendcoded form-dataaxios
to send the requests to the Spring Boot backendcors
to enable CORS on the requestsbody-parser
to parse the request body
Add all these dependencies through the following command.
yarn add express morgan multer form-data axios cors body-parser
Create a file server.js
and add the following code.
// middleware/server.js
const app = express()
app.use(cors())
app.use(bodyParser.json())
app.use(bodyParser.urlencoded({ extended: true }))
app.use(morgan('combined'))
const port = process.env.PORT || 8080
const tempDir = 'tmp'
const context = '/file'
const backend = `http://localhost:8079${context}`
// configure multer storage
const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, `${tempDir}/`),
filename: (req, file, cb) => cb(null, file.originalname)
})
// create / reset temp directory
const outPath = path.resolve(process.cwd(), tempDir)
if (fs.existsSync(outPath)) {
fs.rmdirSync(outPath, { recursive: true })
}fs.mkdirSync(outPath)
const upload = multer({ storage: storage })
// get list of uploaded files
app.get(context, async (req, res) => {
const response = await axios.get(backend)
res.json(response.data)
})
// download a file by name
app.get(`${context}/download`, async (req, res) => {
if (req.query && req.query.fileName) {
const response = await axios.get(`${backend}/download?fileName=${req.query.fileName}`)
res.set({ ...response.headers }).send(response.data)
}
})
// upload some files
app.post(context, upload.array('data'), async (req, res) => {
try {
const data = req.files
if (data) {
const form = new FormData()
data.forEach(file => form.append('data', fs.createReadStream(__dirname + '/' + file.destination + file.filename)))
const response = await axios.post(backend, form, { headers: form.getHeaders() })
res.json(response.data)
}catch (err) {
} res.status(500).send(err);
}
})
app.listen(port, () => console.log(`App started on port ${port}`))
This application
- creates the routes that correspond to the endpoints of the Spring Boot application
- passes that request to the Spring Boot application with
axios
in each route, and - configures
multer
to save the incoming files for the upload in atmp
directory.
Configure a launch script to start Express.
"main": "server.js",
"scripts": {
"start": "node ."
},
By executing yarn start
, this Express application gets up and running on port 8080
.
Reuse the Angular application for the upload from the post Uploading files with Spring Boot and Angular. Launch it using yarn start
. You’ll be greeted by a form to upload the files which will appear in the upload directory configured by the Spring Boot application.
Source code