Resumable file uploader: Creating http handlers

Welcome to tutorial no. 3 in our Resumable file uploader series.

The previous tutorials provided an introduction about the tus protocol and we also created the DB CRUD methods.

In this tutorial, we will create the http handlers to support the POST, PATCH and HEAD http methods.

This tutorial has the following sections

  • POST http handler
  • HEAD http handler
  • PATCH http handler
    • File validation
    • Upload complete validation
    • Upload offset validation
    • Content length validation
    • File patch

POST http handler

Before we create the POST handler, we need a directory to store the files. For simplicity, we are going to create a directory named fileserver inside the home directory to store the files.

const dirName="fileserver"

func createFileDir() (string, error) {  
    u, err := user.Current()
    if err != nil {
        log.Println("Error while fetching user home directory", err)
        return "", err
    }
    home := u.HomeDir
    dirPath := path.Join(home, dirName)
    err = os.MkdirAll(dirPath, 0744)
    if err != nil {
        log.Println("Error while creating file server directory", err)
        return "", err
    }
    return dirPath, nil
}

In the above function, we get the current user's name and home directory and append dirName constant to create the directory. This function will return the path of the newly created directory or errors if any.

This function will be called from main and the dirPath returned from this function will be used by the POST file handler to create the file.

Now that we have the directory ready, let's move to the POST http handler. We will name this handler createFileHandler. The POST http handler is used to create a new file and return the location of the newly created file in the Location header. It is mandatory for the request to contain a Upload-Length header indicating the entire file size.

func (fh fileHandler) createFileHandler(w http.ResponseWriter, r *http.Request) {  
    ul, err := strconv.Atoi(r.Header.Get("Upload-Length"))
    if err != nil {
        e := "Improper upload length"
        log.Printf("%s %s", e, err)
        w.WriteHeader(http.StatusBadRequest)
        w.Write([]byte(e))
        return
    }
    log.Printf("upload length %d\n", ul)
    io := 0
    uc := false
    f := file{
        offset:         &io,
        uploadLength:   ul,
        uploadComplete: &uc,
    }
    fileID, err := fh.createFile(f)
    if err != nil {
        e := "Error creating file in DB"
        log.Printf("%s %s\n", e, err)
        w.WriteHeader(http.StatusInternalServerError)
        return
    }

    filePath := path.Join(fh.dirPath, fileID)
    file, err := os.Create(filePath)
    if err != nil {
        e := "Error creating file in filesystem"
        log.Printf("%s %s\n", e, err)
        w.WriteHeader(http.StatusInternalServerError)
        return
    }
    defer file.Close()
    w.Header().Set("Location", fmt.Sprintf("localhost:8080/files/%s", fileID))
    w.WriteHeader(http.StatusCreated)
    return
}

In line no. 2 we check whether the Upload-Length header is valid. If not we return a Bad Request response.

If the Upload-Length is valid, we create a file in the DB with the provided upload length and with initial offset 0 and upload complete false. Then we create the file in the filesystem and return the location of the file in the Location http header and a 201 created response code.

The dirPath field containing the path to store the file should be added to the fileHandler struct. This field will be updated with the dirPath returned from createFileDir() function later from main(). The updated fileHandler struct is provided below.

type fileHandler struct {  
    db      *sql.DB
    dirPath string
}



HEAD http handler

When a HEAD request is received, we are supposed to return the offset of the file if it exists. If the file does not exist, then we should return a 404 not found response. We will name this handler as fileDetailsHandler.

func (fh fileHandler) fileDetailsHandler(w http.ResponseWriter, r *http.Request) {  
    vars := mux.Vars(r)
    fID := vars["fileID"]
    file, err := fh.File(fID)
    if err != nil {
        w.WriteHeader(http.StatusNotFound)
        return
    }
    log.Println("going to write upload offset to output")
    w.Header().Set("Upload-Offset", strconv.Itoa(*file.offset))
    w.WriteHeader(http.StatusOK)
    return
}

We will use mux router to route the http requests. Please run the command go get github.com/gorilla/mux to fetch the mux router from github.

In line no 3. we get the fileID from the request URL using mux router.

For the purpose of understanding, I have provided the code which will call the above fileDetailsHandler. We will be writing the below line in the main function later.

r.HandleFunc("/files/{fileID:[0-9]+}", fh.fileDetailsHandler).Methods("HEAD")  

This handler will be called when the URL has a valid integer fileID. [0-9]+ is a regular expression which matches one or more digits. If the fileID is valid, it will be stored with the key fileID in a map of type map[string]string . This map can be retrieved by calling the Vars function of the mux router. This is how we get the fileID in line no. 3.

After getting the fileID, we check whether the file exists by calling the File method in line no. 4. Remember we wrote this File Method in the last tutorial. If the file is valid, we return the response with the Upload-Offset header. If not we return a http.StatusNotFound response.

PATCH http handler

The only remaining handler is the PATCH http handler. There are few validations to be done in the PATCH request before we move to the actual file patching. Let's do them first.

File validation

The first step is to make sure the file trying to be uploaded actually exists.

func (fh fileHandler) filePatchHandler(w http.ResponseWriter, r *http.Request) {  
log.Println("going to patch file")  
    vars := mux.Vars(r)
    fID := vars["fileID"]
    file, err := fh.File(fID)
    if err != nil {
        w.WriteHeader(http.StatusNotFound)
        return
    }
}

The above code is similar to the one we wrote in the head http handler. It validates whether the file exists.

Upload complete validation

The next step is to check whether the file has already been uploaded completely.

if *file.uploadComplete == true {  
        e := "Upload already completed" //change to string
        w.WriteHeader(http.StatusUnprocessableEntity)
        w.Write([]byte(e))
        return
    }

If the upload is already complete, we return a StatusUnprocessableEntity status.

Upload offset validation

Each patch request should contain a Upload-Offset header field indicating the current offset of the data and the actual data to be patched to the file should be present in the message body.

off, err := strconv.Atoi(r.Header.Get("Upload-Offset"))  
if err != nil {  
    log.Println("Improper upload offset", err)
    w.WriteHeader(http.StatusBadRequest)
    return
}
log.Printf("Upload offset %d\n", off)  
if *file.offset != off {  
    e := fmt.Sprintf("Expected Offset %d got offset %d", *file.offset, off) 
    w.WriteHeader(http.StatusConflict)
    w.Write([]byte(e))
    return
}

In the above code, we first check whether the Upload-Offset in the request header is valid. If it is not, we return a StatusBadRequest.

In line no. 8, we compare the offset in the table *file.Offset with the one present in the header off. They are expected to be equal. Let's take the example of a file with upload length 250 bytes. If 100 bytes are already uploaded, the upload offset in the database will be 100. Now the server will expect a request with Upload-offset header 100. If they are not equal, we return a StatusConflict header.

Content length validation

The next step is validating the content-length.

clh := r.Header.Get("Content-Length")  
cl, err := strconv.Atoi(clh)  
if err != nil {  
    log.Println("unknown content length")
    w.WriteHeader(http.StatusInternalServerError)
    return
}

if cl != (file.uploadLength - *file.offset) {  
    e := fmt.Sprintf("Content length doesn't not match upload length.Expected content length %d got %d", file.uploadLength-*file.offset, cl)
    log.Println(e)
    w.WriteHeader(http.StatusBadRequest)
    w.Write([]byte(e))
    return
}

Let's say a file is 250 bytes length and the current offset is 150. This indicates that there is 100 more bytes to be uploaded. Hence the Content-Length of the patch request should be exactly 100. This validation is done in line no. 9 of the above code.



File patch

Now comes the fun part. We have done all our validations and ready to patch the file.

body, err := ioutil.ReadAll(r.Body)  
if err != nil {  
    log.Printf("Received file partially %s\n", err)
    log.Println("Size of received file ", len(body))
}
fp := fmt.Sprintf("%s/%s", fh.dirPath, fID)  
f, err := os.OpenFile(fp, os.O_APPEND|os.O_WRONLY, 0644)  
if err != nil {  
    log.Printf("unable to open file %s\n", err)
    w.WriteHeader(http.StatusInternalServerError)
    return
}
defer f.Close()

n, err := f.WriteAt(body, int64(off))  
if err != nil {  
    log.Printf("unable to write %s", err)
    w.WriteHeader(http.StatusInternalServerError)
    return
}
log.Println("number of bytes written ", n)  
no := *file.offset + n  
file.offset = &no

uo := strconv.Itoa(*file.offset)  
w.Header().Set("Upload-Offset", uo)  
if *file.offset == file.uploadLength {  
    log.Println("upload completed successfully")
    *file.uploadComplete = true
}

err = fh.updateFile(file)  
if err != nil {  
    log.Println("Error while updating file", err)
    w.WriteHeader(http.StatusInternalServerError)
    return
}
log.Println("going to send succesfully uploaded response")  
w.WriteHeader(http.StatusNoContent)  

We start reading the message body in line no.1 of the above code. The ReadAll function returns the data it has read until EOF or there is an error. EOF is not considered as an error as ReadAll is expected to read from the source until EOF.

Let's say the patch request disconnects before it is complete. When this happens, ReadAll will return a unexpected EOF error. Usually generic web servers will discard the request if it is incomplete. But we are creating a resumable file uploader and we shouldn't do it. We should patch the file with the data we have received till now.

The length of data received is printed in line no. 4.

In line no. 7 we open the file in append mode if it already exists or create a new file if it doesn't exist.

In line no. 15 we write the request body to the file at the offset provided in the request header. In line no. 23 we update the offset of the file by adding the number of bytes written. In line no. 26 we write the updated offset to the response header.

In line no. 27 we check whether the current offset is equal to the upload lenght. If this is the case then the upload has completed. We set the uploadComplete flag to true.

Finally in line no. 32 we write the updated file details to the database and return a StatusNoContent header indicating that the request is successful.

The entire code along with the main function available in github at https://github.com/golangbot/tusserver. We will need the Postgres driver to run the code. Please fetch the postgres driver running the command go get github.com/lib/pq in the terminal before running the program.

That's about it. We have a working resumable file uploader. In the next tutorial we will test this uploader using curl and dd commands and also discuss about the possible enhancements.

Next tutorial - Testing the server using curl and dd commands

Have a good day.

Like my tutorials? Please show your support by donating. Your donations will help me create more awesome tutorials.

Naveen Ramanathan

Naveen Ramanathan has more than 11 years of programming experience in backend and mobile app development. If you would like to hire him, please mail to naveen[at]golangbot[dot]com