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)
}
}
And everything goes smoothly!
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
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))
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
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
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)
}
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
//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
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
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
}
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
func (h *Handler) GetURLPath(req []byte) string {
parsed := h.Parse(req)
firstheader := strings.Split(parsed[0], " ")
return firstheader[1]
}
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
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
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
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()
}
And we add a new function in our handler to get the needed header value
/utils/handler.go
// 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 ""
}
}
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))
}
}
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
//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)
}
}
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
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))
}
And in our main server file, we add a route to our switch statement
server.go
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)
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
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))
}
And that's it!! We've made an HTTP server from (almost) scratch, using nothing but the std library in Golang
I'll definitely make more of those Codecrafters challenges in the future, so stay tuned to see!
Top comments (0)