Skip to content

Golang HTTP Server (part 1)

Posted on:18 ottobre 2024 at 14:00

In this project i try to create an HTTP server from scratch in Go. For this first release I focused on handling simple GET request without body. The full project is available on the GitHubt repository.

Let’s dive into every package.

Http

This package exposes a simple interface that defines an HttpResponse that every handler must implement. It also exposes a template that follows the standard of an HTTP response.

package http

type HttpResponse struct {
	StatusCode uint16
	Body       string
}

var HttpResponseTemplate string = "HTTP/1.1 {statusCode} {message}\r\nContent-Type: text/plain\r\nContent-Length: {ContentLength}\r\n\r\n{body}"

Tree

This package is central for handling the routing of each request to the correct handler.

type Node struct {
	Name    string
	Child   []*Node
	IsLeaf  bool
	Handler RouteHandlerFunction
}

The Node struct allows building a routing tree where each node corresponds to a part of a URL. The tree structure makes it efficient to match incoming requests with the correct handler based on the path hierarchy.

It is composed of:

// Add a node in the tree.
func (tree *Node) AddNode(path []string, handler RouteHandlerFunction) {
	if len(path) > 0 {
		var i int
		for i = 0; i < len(tree.Child); i++ {
			if tree.Child[i].Name == path[0] {
				break
			}
		}
		if i == len(tree.Child) {
			tree.Child = append(tree.Child, &Node{Name: path[0], Child: make([]*Node, 0), IsLeaf: false})
		}
		tree.Child[i].AddNode(path[1:], handler)
	}
	tree.Handler = (handler)
	tree
}

This function is designed to add a new node (or set of nodes) to the Node tree, representing a URL path, and assign it a route handler.

The function checks if there are still elements left in the path slice. If there are, it tries to match the first part of the path (i.e., path[0]) with the current node’s children.

The loop goes through the existing children (tree.Child), looking for a child with the same Name as path[0].

If a match is found, the function proceeds to the next child node in the tree. If no match is found, it creates a new node for path[0], and recursively adds the next segment of the path (path[1:]) to this new child node.

Once all the path segments are processed, the handler is assigned to the current node using tree.Handler = handler.

This method ensures that nodes are added efficiently and can handle paths of arbitrary depth, making it ideal for organizing route handlers in a tree structure.

Example:

Assume you want to route the URL /users/profile:

  1. The path would be split into ["users", "profile"].
  2. The AddNode function would first look for a node named “users”. If it doesn’t exist, it creates it.
  3. It then recursively looks for “profile” under the “users” node. If not found, it adds it.
  4. The route handler is then assigned to the “profile” node.
func (tree *Node) Search(path []string) *Node {
	if len(path) == 0 && len(tree.Child) == 0 {
		return tree
	}

	var i int
	for i = 0; i < len(tree.Child); i++ {
		if tree.Child[i].Name == path[0] || tree.Child[i].Name == "*" {
			break
		}
	}

	if i == len(tree.Child) {
		return nil
	}

	return tree.Child[i].Search(path[1:])
}

The Search method breaks down the URL into steps to traverse the tree to find a match. If it reaches the end with a leaf, it returns the handler function; otherwise, it returns nil.

In summary:

Routing

This section is responsible for initializing the path tree and assigning the various handler functions defined in the code.

type RouteHandlerFunction func(request string) http.HttpResponse

type Router struct {
	tree *Node
}

func New() *Router {
	router := Router{}
	router.tree = InitTree()

	// add routes
	router.tree.AddNode(strings.Split("", "/")[:1], func(req string) http.HttpResponse { return http.HttpResponse{StatusCode: 200, Body: ""} })
	router.tree.AddNode(strings.Split("/hello", "/")[1:], func(req string) http.HttpResponse { return http.HttpResponse{StatusCode: 200, Body: ""} })
	router.tree.AddNode(strings.Split("/echo/*", "/")[1:], func(req string) http.HttpResponse {
		pattern := "/[a-zA-Z0-9]+$"
		re, err := regexp.Compile(pattern)

		if err != nil {
			return http.HttpResponse{StatusCode: 400, Body: ""}
		}

		matches := re.FindStringSubmatch(strings.Split(req, " ")[1])

		if len(matches) > 0 {
			return http.HttpResponse{StatusCode: 200, Body: strings.ReplaceAll(matches[0], "/", "")}
		}
		return http.HttpResponse{StatusCode: 400, Body: ""}
	})

	router.tree.AddNode(strings.Split("/file/*", "/")[1:], func(req string) http.HttpResponse {
		pattern := "/[a-zA-Z0-9]+$"
		re, err := regexp.Compile(pattern)

		if err != nil {
			return http.HttpResponse{StatusCode: 404, Body: ""}
		}

		matches := re.FindStringSubmatch(strings.Split(req, " ")[1])
		if len(matches) == 0 {
			return http.HttpResponse{StatusCode: 404, Body: ""}
		}

		basePath, _ := os.Getwd()
		basePath += "/assets/"
		basePath += matches[0]

		data, err := os.ReadFile(basePath)

		if err != nil {
			log.Printf("Failed reading file %s", err)
			return http.HttpResponse{StatusCode: 404, Body: ""}
		}

		return http.HttpResponse{StatusCode: 200, Body: string(data)}
	})

	router.tree.PrintTree("")
	return &router

}

RouteHandlerFunction defines the structure of the handler function, and the Router struct defines the root of the path tree.

The New method initializes the tree with the following routes:

func (router *Router) Route(request string) (*RouteHandlerFunction, error) {
path := strings.Split(request, "/")
node := router.tree.Search(path[1:])

    if node == nil || !node.IsLeaf {
    	return nil, errors.New("no route found")
    }

    return &node.Handler, nil

}

Route splits the URL into different segments and passes it to the tree’s search method, returning the handler if found or throwing an error.

Server

This is the main package that handles the TCP connection.

type Server struct {
	listener net.Listener
	router   *routing.Router
}

func (server *Server) init(port uint16) {
	l, err := net.Listen("tcp", fmt.Sprintf("0.0.0.0:%d", port))
	log.Printf("[Server] Server listing on %s\n", fmt.Sprintf("%d", port))

	if err != nil {
		os.Exit(1)
	}
	server.listener = l
	server.router = routing.New()
}

The Server struct defines the properties of the server, which has a TCP listener and a router. The init method creates a TCP socket listening on the chosen port and initializes the router.

func (server Server) handleConnection(con net.Conn) {
	defer con.Close()
	req := make([]byte, 1024)
	_, err := con.Read(req)

	if err != nil {
		con.Write([]byte("HTTP/1.1 400 Bad Request\r\n\r\n"))
	}

	response, err := server.handleRequestLine(string(req))

	if err != nil {
		con.Write([]byte("HTTP/1.1 400 Bad Request\r\n\r\n"))
	}

	con.Write([]byte(response))
}

The handleConnection accept a new connection from the listener, and takes care of reading the data coming from the TCP connection and translating it into a request-line that will then be passed to the processor. In this version there is no validator that checks if the request-line respects the standard.

The handleConnection method accepts a new connection from the listener and handles reading the data coming from the TCP connection. It translates this data into a request line that will then be passed to the processor. In this version, there is no validation to check whether the request line adheres to the standard.

// A request-line begins with a method token
// followed by a single space (SP)
// the request-target, and another single space (SP)
// and ends with the protocol version
func (server Server) handleRequestLine(request string) (string, error) {

	// split line by SP
	requestParts := strings.Split(request, " ")
	if requestParts[0] != "GET" {
		return "HTTP/1.1 404 Not Found\r\n\r\n", nil
	}

	handler, err := server.router.Route(requestParts[1])


	if err != nil {
		return "HTTP/1.1 404 Not Found\r\n\r\n", nil
	}

	return execRouteHandler(handler, request), nil
}

The handleRequestLine for the Server struct method is responsible for processing the initial line of an HTTP request, determining how to respond based on the request method and target.

The request line is split into parts using a space (” ”) as the delimiter. This is necessary to separate the HTTP method, request target, and protocol version.After this operation, requestParts will contain:

The method calls Route on the server’s router, passing the request target requestParts[1]. The router is expected to return a handler function for the specified request target.

handler will contain the function to execute for the matched route. If the Route method encounters an error (for example, if no route matches the request target), err will be non-nil.

If the request method is “GET” and a valid handler is found, the method calls execRouteHandler(handler, request). This function executes the route handler for the request and constructs the appropriate HTTP response string based on the handler’s output.

Finally, it returns the generated response and a nil error.

func main() {
	args := os.Args
	if len(args[1:]) < 1 {
		log.Fatal("Please provide a port")
	}
	port, err := strconv.ParseInt(args[1], 10, 16)

	if err != nil {
		log.Fatal(err)
	}

	log.Printf("[Server] Logs from your program will appear here!\n")

	var server Server
	server.init(uint16(port))

	defer func() {
		server.listener.Close()
	}()

	for {
		con, err := server.listener.Accept()

		if err != nil {
			log.Fatalln("[Server] Error accepting connection: ", err.Error())
			os.Exit(1)
		}
		go server.handleConnection(con)
	}
}

The main function initializes a server, listens for incoming connections, and handles each connection in a concurrent manner.

The handleConnection(con) method of the server is called in a new goroutine go server.handleConnection(con). This allows the server to handle multiple connections concurrently without blocking.

Each connection is processed in its own goroutine, enabling the server to serve multiple clients at the same time.

Next steps

As a next step, consider extending the server to support additional HTTP methods, improve error handling, and implement request validation to conform to HTTP standards.

Conclusion

In this project, we successfully built a basic HTTP server from scratch using Go. By focusing on handling simple GET requests, we explored essential concepts such as routing, request handling, and server architecture.

We began by defining a simple HttpResponse structure to standardize our server’s responses. Next, we developed a tree-based routing mechanism using the Node struct, which allows for efficient URL path matching. This approach not only facilitates hierarchical routing but also ensures that our server can easily be extended to handle more complex routes in the future.

The Router struct plays a crucial role in initializing the routing tree and assigning handler functions to different URL paths. This separation of concerns enhances maintainability and readability in our code.

Our Server struct handles the TCP connection, reading incoming requests, and providing appropriate responses based on the request line. The use of goroutines allows for concurrent request processing, enabling our server to handle multiple clients simultaneously without blocking.

This foundational project demonstrates the power of Go in building efficient and scalable web servers and serves as a stepping stone for more advanced web development projects.

Thank you for following along with this journey of creating a basic HTTP server in Go. I hope it inspires you to explore further into Go’s capabilities !