Naive upload server

In this lesson, we will build a simple HTTP server that can accept a file upload from the user and store the file in the server. We will use the net/http package to build the server and the io package to handle the file upload.

You probably have done something similar, but let’s do it again to refresh our memory.

Setting up the server

Let’s start with the initialization of simple HTTP server that can accept a file uploaded by user and store the file in the server.

package main

import (
    "context"
    "errors"
    "fmt"
    "io"
    "log"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"
)

func main() {
    if err := run(); err != nil {
        log.Fatal(err)
    }
}

func run() error {
    signalCtx, stop := signal.NotifyContext(
        context.Background(),
        os.Interrupt,
        syscall.SIGTERM,
    )
    defer stop()

    httpServer := &http.Server{
        Addr:              ":8080",
        Handler:           newHTTPHandler(),
        ReadHeaderTimeout: 5 * time.Second,
        IdleTimeout:       60 * time.Second,
    }
    serverErrors := make(chan error, 1)
    go func() { serverErrors <- httpServer.ListenAndServe() }()

    select {
    case <-signalCtx.Done():
        log.Printf("shutdown requested: %v", signalCtx.Err())
    case err := <-serverErrors:
        if !errors.Is(err, http.ErrServerClosed) {
            return fmt.Errorf("serve HTTP: %w", err)
        }
        return nil
    }

    shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
    if err := httpServer.Shutdown(shutdownCtx); err != nil {
        return errors.Join(
            fmt.Errorf("graceful HTTP shutdown: %w", err),
            httpServer.Close(),
        )
    }
    return nil
}

func newHTTPHandler() http.Handler {
    mux := http.NewServeMux()
    mux.Handle("POST /api/v1/binary", binaryUpload())
    return mux
}

func binaryUpload() http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        defer r.Body.Close()

        f, err := os.CreateTemp("", "upload-*")
        if err != nil {
            log.Printf("create temporary upload: %v", err)
            http.Error(w, "could not create upload", http.StatusInternalServerError)
            return
        }
        name := f.Name()
        defer os.Remove(name)
        defer f.Close()

        data, err := io.ReadAll(r.Body)
        if err != nil {
            log.Printf("read upload body: %v", err)
            http.Error(w, "could not read upload", http.StatusBadRequest)
            return
        }

        n, err := f.Write(data)
        if err != nil {
            log.Printf("write upload: %v", err)
            http.Error(w, "could not store upload", http.StatusInternalServerError)
            return
        }
        if n != len(data) {
            log.Printf("write upload: %v", io.ErrShortWrite)
            http.Error(w, "could not store upload", http.StatusInternalServerError)
            return
        }

        log.Printf("file uploaded: written_size=%d stored_file=%s", n, name)
        w.WriteHeader(http.StatusOK)
    }
}

Let’s break down the implementation above:

signalCtx, stop := signal.NotifyContext(
  context.Background(),
  os.Interrupt,
  syscall.SIGTERM,
)
defer stop()

This context is canceled when the process receives SIGINT or SIGTERM. The server then stops accepting new requests and gives active handlers time to finish.

httpServer := &http.Server{
  Addr:              ":8080",
  Handler:           newHTTPHandler(),
  ReadHeaderTimeout: 5 * time.Second,
  IdleTimeout:       60 * time.Second,
}

This creates an HTTP server on port 8080 with the handler defined below. The header and idle timeouts prevent connections from remaining open indefinitely while still allowing an upload body to stream without a fixed whole-request deadline.

serverErrors := make(chan error, 1)
go func() { serverErrors <- httpServer.ListenAndServe() }()

select {
case <-signalCtx.Done():
  log.Printf("shutdown requested: %v", signalCtx.Err())
case err := <-serverErrors:
  if !errors.Is(err, http.ErrServerClosed) {
    return fmt.Errorf("serve HTTP: %w", err)
  }
  return nil
}

The server runs in a goroutine while run waits for either an operating-system signal or a server error. Returning the error allows main to report it only after lifecycle cleanup; calling log.Fatal inside the goroutine would exit immediately and skip that cleanup.

shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := httpServer.Shutdown(shutdownCtx); err != nil {
  return errors.Join(
    fmt.Errorf("graceful HTTP shutdown: %w", err),
    httpServer.Close(),
  )
}

Shutdown stops new connections and waits up to 30 seconds for active handlers. If that deadline expires, the code calls Close to terminate the remaining connections and returns both errors to main.

Handling the simple file upload

When a file is being uploaded or when you make an API call to a HTTP server, data is sent in the form of stream of bytes. Thus, what we need to do is just to read the stream of bytes and write it to a file.

Here is the same binaryUpload handler from the complete program:

func binaryUpload() http.HandlerFunc {
  return func(w http.ResponseWriter, r *http.Request) {
    defer r.Body.Close()

    f, err := os.CreateTemp("", "upload-*")
    if err != nil {
      log.Printf("create temporary upload: %v", err)
      http.Error(w, "could not create upload", http.StatusInternalServerError)
      return
    }
    name := f.Name()
    defer os.Remove(name)
    defer f.Close()

    data, err := io.ReadAll(r.Body)
    if err != nil {
      log.Printf("read upload body: %v", err)
      http.Error(w, "could not read upload", http.StatusBadRequest)
      return
    }

    n, err := f.Write(data)
    if err != nil {
      log.Printf("write upload: %v", err)
      http.Error(w, "could not store upload", http.StatusInternalServerError)
      return
    }
    if n != len(data) {
      log.Printf("write upload: %v", io.ErrShortWrite)
      http.Error(w, "could not store upload", http.StatusInternalServerError)
      return
    }

    log.Printf("file uploaded: written_size=%d stored_file=%s", n, name)
    w.WriteHeader(http.StatusOK)
  }
}

Let’s break down the implementation above:

defer r.Body.Close()

Since r.Body is an io.ReadCloser, we need to make sure that we close the body after we are done with it. We use defer to make sure that the body is closed after the function is done.

f, err := os.CreateTemp("", "upload-*")
if err != nil {
  log.Printf("create temporary upload: %v", err)
  http.Error(w, "could not create upload", http.StatusInternalServerError)
  return
}
name := f.Name()
defer os.Remove(name)
defer f.Close()

os.CreateTemp creates a uniquely named file in the operating system’s temporary directory. A creation failure is a server-side storage problem, so the handler returns 500 Internal Server Error. The deferred calls close and remove the file when the request finishes so this introductory example does not consume disk space permanently.

data, err := io.ReadAll(r.Body)
if err != nil {
  log.Printf("read upload body: %v", err)
  http.Error(w, "could not read upload", http.StatusBadRequest)
  return
}

If you ever read a file and then do some processing against the file (e.g. read how many lines in the file), you typically will read the entire content of the file into the computer memory and then do your things, right? This is exactly what io.ReadAll does. It reads the entire content of the r.Body into memory (stored in data) so that you can write the content later to the target file. If reading the request fails, the handler returns a non-success response instead of continuing with incomplete data.

:::warn This might not be a good idea if you are dealing with a large file. I will show you how this can be bad for performance and how to fix it in the next lesson. For now, please bare with me. :::

n, err := f.Write(data)
if err != nil {
  log.Printf("write upload: %v", err)
  http.Error(w, "could not store upload", http.StatusInternalServerError)
  return
}
if n != len(data) {
  log.Printf("write upload: %v", io.ErrShortWrite)
  http.Error(w, "could not store upload", http.StatusInternalServerError)
  return
}
log.Printf("file uploaded: written_size=%d stored_file=%s", n, name)

This writes the bytes to the temporary file. Both a returned error and a short write produce 500 Internal Server Error; the handler reports success only after the complete body is stored.

To use this upload handler, let’s define the newHTTPHandler function:


func newHTTPHandler() http.Handler {
  mux := http.NewServeMux()
  mux.Handle("POST /api/v1/binary", binaryUpload())
  return mux
}

Including the HTTP method in the Go 1.22+ route pattern makes other methods return 405 Method Not Allowed automatically.

Now you have a simple HTTP server that can accept a file upload from the user and store the file in the server. Run your server and try to upload a file to the server. You can use curl to upload a file to the server.

On the example below, we are uploading a file named file.pdf to the server:

curl --fail-with-body 'http://localhost:8080/api/v1/binary' \
  --header 'Content-Type: application/pdf' \
  --data-binary '@/path/to/file.pdf'

Nice! You might have noticed something off in the implementation above. If yes, that’s good! But if you still don’t see it, don’t worry! On the next lesson, we will discuss how the implementation above can be bad for performance and how to fix it. See ya!