DEV Community

Cover image for Making a HTTP server in Go
Enzo Enrico
Enzo Enrico

Posted on

Making a HTTP server in Go

I've stumbled upon Codecrafters a while ago, and the idea really caught my attention. Making real world projects to learn, instead of following tutorials is everything I could've asked for when I was getting started, so just so I don't let the opportunity get away, I'm following their free project on writing a HTTP server.
I'm choosing Go for this project, I've been meaning to learn it fully for a good while now

Starting the project

Codecrafters has one of the coolest features I've seen for validating tests, once you commit your code to their repo, tests are automatically ran and your progress is validated. So, for this first step, all we have to do is un-comment some of the provided code to create our TCP server



import (
    "fmt"
    "net"
    "os"
)

func main() {
    fmt.Println("Logs from your program will appear here!")
    l, err := net.Listen("tcp", "0.0.0.0:4221")
    if err != nil {
        fmt.Println("Failed to bind to port 4221")
        os.Exit(1)
    }

    _, err = l.Accept()
    if err != nil {
        fmt.Println("Error accepting connection: ", err.Error())
        os.Exit(1)
    }
}



Enter fullscreen mode Exit fullscreen mode

And everything goes smoothly!

Terminal Screenshot

For this second step, we're returning a response from our server.
Codecrafters has this really cool "Task" card that explains everything you need to know to implement the next step

Codecrafters screenshot

So, we add a variable for our connected client and send him a Status 200



    //added conn for handling responses
    con, err := l.Accept()
    if err != nil {
        fmt.Println("Error accepting connection: ", err.Error())
        os.Exit(1)
    }

    //variables for returning response
    var success string = "HTTP/1.1 200 OK\r\n"
    var ending string = "\r\n"

    //write into the connection
    con.Write([]byte(success + ending))


Enter fullscreen mode Exit fullscreen mode

Extracting URL Paths

Now, we need to give a proper response based on the path that is being accessed on our server.
We can identify the path using the received request


 HTTP/1.1 200 OK 

Enter fullscreen mode Exit fullscreen mode

And since we're expanding the project scope, I've decided to create a utility package for handling operations like parsing and returning responses


 /utils/handler.go 

Enter fullscreen mode Exit fullscreen mode

 go
package utils

import "net"

type Handler struct {
    Conn net.Conn
}

func (h *Handler) Success() {
    //variables for returning response
    var success string = "HTTP/1.1 200 OK\r\n"
    var ending string = "\r\n"

    //write into the connection
    h.Conn.Write([]byte(success + ending))
}

func (h *Handler) NotFound() {
    //variables for returning response
    var notFound string = "HTTP/1.1 404 Not Found\r\n"
    var ending string = "\r\n"

    //write into the connection
    h.Conn.Write([]byte(notFound + ending))

}

func (h *Handler) Parse(req []byte) {
    h.Conn.Read(req)
}



Enter fullscreen mode Exit fullscreen mode

And in the main file , we add our connection to our handler and check the path of the request to return our responses


 server.go 

Enter fullscreen mode Exit fullscreen mode


    //initialize the handler
    handler := utils.Handler{Conn: con}
    req := make([]byte, 1024)

    //parses the request to a readable format
    handler.Parse(req)

    fmt.Println("Request: ", string(req))

    //path checking
    if !strings.HasPrefix(string(req), "GET / HTTP/1.1") {
        handler.NotFound()
        return
    }
    handler.Success()
    return


Enter fullscreen mode Exit fullscreen mode

Returning a response

Once again, I feel like I need to refactor my code, not only for adding some essentials I've forgotten but also to practice my Clean Code πŸ˜†
In the main file, we add a function to handle incoming connections


 server.go 

Enter fullscreen mode Exit fullscreen mode


func main() {
    fmt.Println("Starting server on port 4221")

    l, err := net.Listen("tcp", "0.0.0.0:4221")
    if err != nil {
        fmt.Println("Failed to bind to port 4221")
        os.Exit(1)
    }

    //if handled thread is closed / finished, close the listener
    defer l.Close()

    //keep listening to requests
    for {
        con, err := l.Accept()
        if err != nil {
            fmt.Println("Error accepting connection: ", err.Error())
            os.Exit(1)
        }
        go handleConnection(con)
    }
}

func handleConnection(con net.Conn) {
    //if returned, close connection
    defer con.Close()

    //initialize the handler
    handler := utils.Handler{Conn: con}
    req := make([]byte, 1024)

    //parse the request
    urlPath := handler.GetURLPath(req)
    fmt.Println("URL Path: ", urlPath)

    if urlPath == "/" {
        handler.Success()
        return
    }
    handler.NotFound()
    return
}



Enter fullscreen mode Exit fullscreen mode

And in the handler we've written in the previous step, we add a function to get the URL path of a incoming request


 /utils/handler.go 

Enter fullscreen mode Exit fullscreen mode


func (h *Handler) GetURLPath(req []byte) string {
    parsed := h.Parse(req)
    firstheader := strings.Split(parsed[0], " ")
    return firstheader[1]
}


Enter fullscreen mode Exit fullscreen mode

For this step, we need to implement the /echo/:message endpoint, for returning whatever message is sent through the URL to the response body, for now, we can add a simple if statement checking the path


 /app/server.go 

Enter fullscreen mode Exit fullscreen mode

 go
    //parse the request
    urlPath := handler.GetURLPath(req)
    fmt.Println("URL Path: ", urlPath)

    if urlPath == "/" {
        handler.Success()
        return
    }else if strings.Split(urlPath, "/")[1] == "echo" {
        msg := strings.Split(urlPath, "/")[2]
        handler.SendBody(msg)
        return
    }
    handler.NotFound()
    return


Enter fullscreen mode Exit fullscreen mode

Reading the User-Agent header

In this step we need to get the data from the 'User-Agent' header and return it in the body of our response
First of all, we add a route to our server, and while we're at it, we can refactor the 'router' to something a little more readable


 server.go 

Enter fullscreen mode Exit fullscreen mode


    switch strings.Split(urlPath, "/")[1] {
    case "echo":
        msg := strings.Split(urlPath, "/")[2]
        handler.SendBody(msg, len(msg))
        return
    case "user-agent":
// not the prettiest code ever, but hey, it works
        d := handler.UserAgent(req)
        d_msg := strings.Split(d, ": ")[1]
        d_len := len(d_msg)
        fmt.Println(d_msg)
        fmt.Println(d_len)
        handler.SendBody(d_msg, d_len)
        return
    default:
        handler.NotFound()
    }


Enter fullscreen mode Exit fullscreen mode

And we add a new function in our handler to get the needed header value


 /utils/handler.go 

Enter fullscreen mode Exit fullscreen mode


// UserAgent returns the User-Agent header from the request.
func (h *Handler) UserAgent(req []byte) string {
    // dont need to read the connection again
    parsed := strings.Split(string(req), "\r\n")
    for i := range parsed {
        if strings.Contains(parsed[i], "User-Agent") {
            return parsed[i]
        }
    }
    return ""
}
}


Enter fullscreen mode Exit fullscreen mode

We also update our SendBody function to get the correct content-length based on a parameter



func (h *Handler) SendBody(message string) {
    //variables for returning response
    var success string = "HTTP/1.1 200 OK\r\n"
    var contentType string = "Content-Type: text/plain\r\n"
    var contentLength string = fmt.Sprintf("Content-Length: %d\r\n", len(message))
    var ending string = "\r\n"

    returnBody := success + contentType + contentLength + ending + message

    //write into the connection
    h.Conn.Write([]byte(returnBody))
}
}


Enter fullscreen mode Exit fullscreen mode

Handling concurency

The next step would be handling multiple connections at the same time, our code already does that! In our main function, we call the handleConnection function using the go keyword, creating a goroutine for each connection


 server.go 

Enter fullscreen mode Exit fullscreen mode



    //if handled thread is closed / finished, close the listener
    defer l.Close()

    //keep listening to requests
    for {
        con, err := l.Accept()
        if err != nil {
            fmt.Println("Error accepting connection: ", err.Error())
            os.Exit(1)
        }
        go handleConnection(con)
    }
}


Enter fullscreen mode Exit fullscreen mode

Sending Files

In this activity, we implement the /files/:file endpoint, this route returns a file in the body of the response to the user (if the file is found)
For achieving this, we write a function in our /utils/handler.go for sending the file


 /utils/handler.go 

Enter fullscreen mode Exit fullscreen mode


func (h *Handler) SendFile(data []byte) {
    //variables for returning response
    var success string = "HTTP/1.1 200 OK\r\n"
        // make sure to get the correct content-type
    var contentType string = "Content-Type: application/octet-stream\r\n"
    var contentLength string = fmt.Sprintf("Content-Length: %v\r\n", len(data))
    var ending string = "\r\n"
    returnBody := success + contentType + contentLength + ending + string(data)
    //write into the connection
    h.Conn.Write([]byte(returnBody))

}


Enter fullscreen mode Exit fullscreen mode

And in our main server file, we add a route to our switch statement


 server.go 

Enter fullscreen mode Exit fullscreen mode


    case "files":
        filename := strings.Split(urlPath, "/")[2]
        //get the folder to the file
                filepath := os.Args[2]
        fmt.Printf("DIR: %s\n", filepath)
                //create folder if it dosent exist
        if _, err := os.ReadDir(filepath); err != nil {
            fmt.Println("Creating directory...")
            os.Mkdir(filepath, 0755)
        }
        fmt.Println("Reading: ", filepath+filename)
        data, err := os.ReadFile(filepath + filename)
        if err != nil {
            fmt.Println("Error on reading file")
            handler.NotFound()
            return
        }
        fmt.Println("Sending file...")
        handler.SendFile(data)


Enter fullscreen mode Exit fullscreen mode

Reading the request body

Our last challenge is reading the body of a post request
Using our /files/:fileName endpoint, we should read a POST request, create a file with the file name provided in the URL. In this created file we add the data provided in the post request
For this to be achieved, we add a function to our /utils/handler.go file to get the contents of the request body, method and a modification to our SendBody function


 /utils/handler.go 

Enter fullscreen mode Exit fullscreen mode


func (h *Handler) GetBody(req []byte) string {
    parsed := h.Parse(req)

    return parsed[len(parsed)-1]
}

func (h *Handler) GetMethod(req []byte) string {
    parsed := h.Parse(req)
    firstheader := strings.Split(parsed[0], " ")
    return firstheader[0]
}

func (h *Handler) SendBody(message string, msgLen int, stcode int) {
    //variables for returning response
    var success string = fmt.Sprintf("HTTP/1.1 %v %v\r\n", stcode, http.StatusText(stcode))
    var contentType string = "Content-Type: text/plain\r\n"
    var contentLength string = fmt.Sprintf("Content-Length: %v\r\n", msgLen)
    var ending string = "\r\n"

    returnBody := success + contentType + contentLength + ending + message

    //write into the connection
    h.Conn.Write([]byte(returnBody))
}



Enter fullscreen mode Exit fullscreen mode

And that's it!! We've made an HTTP server from (almost) scratch, using nothing but the std library in Golang

Image description

I'll definitely make more of those Codecrafters challenges in the future, so stay tuned to see!

Top comments (0)