Job Costing #7 – Uploading Files Part 1

This post is part of a series on an ongoing project.

After I’ve built a static version of my app, I’m going to start adding in functional pieces. The first piece I’m going to add is the file upload component. And, to keep things simple, I’m going to start with only one file upload component (the Project Sheet file).

The JSON API

I’ll start by defining the JSON API that the backend is going to send to the frontend in response to service requests. I’m keeping this structure consistent across the other services that will come later. Each response JSON Object has either a ‘success’ or ‘error’ object, indicating an application level success or error response.

Defining this in Kotlin will only take a few lines.

interface Response

class SuccessResponse(
        @JsonProperty("success") val success: SuccessContent
): Response

interface SuccessContent {
    fun toResponse() = SuccessResponse(this)
}

class ErrorResponse(
        @JsonProperty("error") val error: ErrorContent
): Response

class ErrorContent(
        @JsonProperty("message") val message: String,
        @JsonProperty("detail") val detail: String
) {
    fun toResponse() = ErrorResponse(this)
}

File Upload Service

To add the file upload service to my Spring app, I’ll create two new Kotlin files. One file to hold all logic regarding storing files locally on disk, and one file for the REST controller to handle the file upload service requests. I’ll start with the file storage logic file, in a file called ‘files.kt’.

private val fileDirectory = File("/var/tmp/.jobcost")

fun getFile(name: String) = File(fileDirectory, name)

fun initFiles() {
    if(!fileDirectory.exists())
        fileDirectory.mkdirs()
}

fun saveFile(fileBytes: ByteArray): String {
    val fileName = getFileName(fileBytes)
    FileOutputStream(getFile(fileName)).use { out ->
        out.write(fileBytes)
        out.flush()
    }
    return fileName
}

private fun getFileName(fileBytes: ByteArray): String {
    return DigestUtils.md5DigestAsHex(fileBytes)
}

The files are stored under the ‘/var/tmp’ directory in an app specific folder. The initFiles() function is called at app startup and simply makes sure the directory is created. The saveFile() function saves the file content (the byte array) to disk, and the getFileName() function creates a name for the file.

This last part deserves some special mention. I give the name of the file the hexadecimal representation of the MD5 hash of the file’s contents. I do not use the file name of the local file. There’s a few reasons for this.

First, I don’t want the app logic to be dependent on any issues with how the user names the file (case sensitivity, etc). Secondly, the client says that these files often change as they work through the errors and warnings reported. In other words, it is common that the user runs the app the first time, then uses the text output to go and correct some of the input data, and then re-run the app. The user iteratively goes through this process until the data is clean enough to import into the financial software. I don’t want to require that the user clears a file and re-uploads the same file every time the file changes. This naming convention will allow the frontend to verify that the local file matches the server file, instead of relying on timestamps.

Anyway, the other file is the REST controller to service file upload requests. This file is pretty simple, and mostly calls the functions in the above file.

@RestController
class FileUpload {

    private val logger: Logger = Logger.getLogger(FileUpload::class.qualifiedName)

    @RequestMapping(value = ["/jobcost/upload"], method = [RequestMethod.OPTIONS])
    @CrossOrigin(origins = ["http://localhost:3000"])
    fun options(): ResponseEntity<*>? {
        return ResponseEntity.ok().allow(HttpMethod.POST, HttpMethod.OPTIONS).build<Any>()
    }

    @PostMapping("/jobcost/upload")
    @CrossOrigin(origins = ["http://localhost:3000"])
    fun handleFileUpload(@RequestParam("file") file: MultipartFile): Response {
        return tryToSaveFile(file)
    }

    private fun tryToSaveFile(file: MultipartFile): Response {
        return try {
            val fileName = saveFile(file.bytes)
            return UploadFileSuccess(fileName, file.size).toResponse()
        } catch(e: Exception) {
            logAndReturnErrorDueToException("Unable to save file '${file.originalFilename}'", e, logger)
        }
    }

    class UploadFileSuccess(
            @JsonProperty("fileName") val fileName: String,
            @JsonProperty("size") val size: Long
    ): SuccessContent
}

This defines two request mappings, one for OPTIONS and one for POST. Both also define CORS support solely to support development – remember this isn’t needed when I publish the app in a single deployable JAR file.

Note that this file defines its own SuccessContent object, UploadFileSuccess. In response, it will send back to the frontend a ‘success’ JSON object that has the name and size of the file.

Wrap Up

That’s the complete backend code to support uploading files. Tomorrow I’ll dive into the frontend code.

Leave a comment