Servidores HTTP desempenham um papel fundamental na distribuição de conteúdo e serviços na internet. A implementação de um servidor HTTP simples pode ser uma excelente oportunidade para entender os princípios básicos da comunicação web e explorar uma linguagem moderna como Rust.

Neste post vamos ver fazer um simples servidor utilizando tcp que servirá arquivos estáticos, obviamente um servidor HTTP real é muito mais complexo do que isso.

Como funciona o HTTP?

HTTP

O HTTP (Hypertext Transfer Protocol) é um protocolo de aplicação que opera sobre o TCP e é amplamente utilizado para transferir dados na web. Ele segue um modelo cliente-servidor, onde o cliente envia requisições para o servidor e o servidor responde com os recursos solicitados.

Caso queira ler a respeito temos um artigo completo sobre ele.

TCP

TCP

O TCP é um protocolo de comunicação confiável e orientado à conexão, que opera na camada de transporte do modelo OSI. Ele é responsável por estabelecer conexões entre dois hosts na internet e garantir a entrega ordenada e confiável de dados entre eles.

O TCP usa um processo de três vias (handshake) para estabelecer uma conexão entre um cliente e um servidor. Esse handshake consiste em três etapas: SYN, SYN-ACK e ACK. Após o estabelecimento da conexão, os dados são transferidos em pacotes, que são reagrupados e reordenados no destino para garantir a integridade e a ordem dos dados.

Por que Rust?

Por que Rust?

Rust é uma linguagem de programação moderna e eficiente, valorizada por sua segurança de memória e desempenho. A escolha de Rust neste projeto, no entanto, é bastante simples: decidi explorá-la como parte do meu processo de aprendizado. Tenho me interessado bastante por essa linguagem ultimamente e optei por ela. Isso me leva a uma sugestão: por que não refazer este exercício utilizando sua linguagem favorita?

Por onde começamos?

Antes de começarmos, se você nunca teve contato com Rust, recomendo dar uma olhada em sua excelente documentação ou neste curso que a comunidade preparou, ele vai te ajudar a entender o Rust de uma forma fácil.

Mãos no código

Agora, estamos todos prontos para iniciarmos, bora começar.

Iniciando um projeto Rust

Vou partir do pressuposto que você ja possui o Rust instalado e configurado em sua máquina, caso não tenha dê uma olha aqui, onde ensino a instalar o asdf e instalar várias linguagens, incluindo o Rust.

Com tudo feito, vamos criar um novo projeto utilizando o Cargo:

cargo new http-server

Teremos então o diretório http-server criado com a estrutura básica de um projeto Rust, agora é só abrir esse diretório com a sua IDE favorita.

Lidando com Conexões TCP

O coração do nosso servidor é a capacidade de lidar com entradas conexões TCP. Utilizamos o módulo std::net::TcpListener para associar o servidor a um determinado endereço IP e porta. A função bind() nos permite vincular o servidor a uma interface de rede e uma porta específicas, e então começamos a escutar por conexões com incoming().

use std::net::TcpListener;

// Constants for server configuration
const HOST: &str = "127.0.0.1";
const PORT: &str = "8477";

fn main() {
    // Bind to the host and port
    let endpoint = format!("{}:{}", HOST, PORT);
    let listener = TcpListener::bind(endpoint).unwrap();

    for _ in listener.incoming() {
        println!("Connection established!")
    }
}

Com isso definimos duas constantes HOST, que será o ip do localhost e PORT que será a porta que nosso servidor ficará escutando. Com uma simples implementação podemos testar rodando:

cargo run

E acessando o endereço http://127.0.0.1:8477/ em qualquer navegador, não teremos nenhuma página exibida, mas veremos no terminal:

Connection established!

Tendo então com sucesso estabelecido uma conexão TCP.

Analisando Requisições HTTP

Dentro da função handle_connection(), lemos os dados da requisição HTTP do cliente e os interpretamos. Primeiro, lemos os dados da requisição em um buffer e os convertemos em uma string.

use std::io::prelude::*;
use std::net::{TcpListener, TcpStream};

// Constants for server configuration
const HOST: &str = "127.0.0.1";
const PORT: &str = "8477";

fn main() {
    // Bind to the host and port
    let endpoint = format!("{}:{}", HOST, PORT);
    let listener = TcpListener::bind(endpoint).unwrap();
    println!("Web server is listening at port {}", PORT);

    // Accept incoming connections
    for incoming_stream in listener.incoming() {
        let mut stream = incoming_stream.unwrap();
        handle_connection(&mut stream);
    }
}

fn handle_connection(stream: &mut TcpStream) {
    // Buffer to read the incoming request
    let mut buffer = [0; 1024];
    stream.read(&mut buffer).unwrap();

    // Convert the request buffer to a string
    let request_str = String::from_utf8_lossy(&buffer);

    println!("Request: {}", request_str);
}

Esta string contém toda a informação sobre a requisição HTTP, incluindo o método, o caminho, os cabeçalhos e o corpo, se houver. Um exemplo:

Request: GET / HTTP/1.1
Host: 127.0.0.1:8477
Connection: keep-alive
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Accept-Encoding: gzip, deflate, br
Accept-Language: pt-BR,pt;q=0.9,en-IN;q=0.8,en;q=0.7,en-US;q=0.6,ja;q=0.5

Obviamente isso irá variar de acordo com sistema operacional e navegador que esteja utilizando, mas repare na primeira linha:

Request: GET / HTTP/1.1
...

Podemos ver que temos uma requisição HTTP GET no path /, tentarmos acessar http://127.0.0.1:8477/123 , teremos

Request: GET /123 HTTP/1.1
...

Lidando com as requisições HTTP

Vamos agora pegar as requisições e retornar uma resposta, para isso basta adicionarmos HTTP/1.1 200 OK\r\n\r\n antes de enviarmos nossa resposta, note que o número 200 é o status code, que significa sucesso.

use std::io::prelude::*;
use std::net::{TcpListener, TcpStream};

// Constants for server configuration
const HOST: &str = "127.0.0.1";
const PORT: &str = "8477";

fn main() {
    // Bind to the host and port
    let endpoint = format!("{}:{}", HOST, PORT);
    let listener = TcpListener::bind(endpoint).unwrap();
    println!("Web server is listening at port {}", PORT);

    // Accept incoming connections
    for incoming_stream in listener.incoming() {
        let mut stream = incoming_stream.unwrap();
        handle_connection(&mut stream);
    }
}

fn handle_connection(stream: &mut TcpStream) {
    // Buffer to read the incoming request
    let mut buffer = [0; 1024];
    stream.read(&mut buffer).unwrap();

    let response = "HTTP/1.1 200 OK\r\n\r\n";
    stream.write(response.as_bytes()).unwrap();
    stream.flush().unwrap();
}

Com isso teremos em nosso navegador uma página em branco, mas inspecionando e indo na guia de rede veremos um status code 200.

Status 200

Servindo Arquivos Estáticos

Por mais que tenhamos feito algo no passo anterior ainda assim falta exibir no navegador alguma coisa, nossa bela mensagem de Hello, world!.

Na raiz do projeto vamos criar um diretório www e dentro dessa pasta um arquivo index.html, dentro dele cole:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Hello, world!</title>
</head>
<body>
  Hello, world!
</body>
</html>

E no nosso arquivo main.rs vamos adicionar dois métodos: parse_request_path e serve_requested_file, o primeiro irá pegar o path e o segundo o arquivo, tendo assim um simples servidor HTTP que irá servir arquivos estáticos, vamos atualizar nosso arquivo pela última vez:

use std::fs;
use std::io::prelude::*;
use std::net::{TcpListener, TcpStream};
use std::path::Path;

// Constants for server configuration
const HOST: &str = "127.0.0.1";
const PORT: &str = "8477";
const ROOT_DIR: &str = "www";

fn main() {
    // Bind to the host and port
    let endpoint = format!("{}:{}", HOST, PORT);
    let listener = TcpListener::bind(endpoint).unwrap();
    println!("Web server is listening at port {}", PORT);

    // Accept incoming connections
    for incoming_stream in listener.incoming() {
        let mut stream = incoming_stream.unwrap();
        handle_connection(&mut stream);
    }
}

fn handle_connection(stream: &mut TcpStream) {
    // Buffer to read the incoming request
    let mut buffer = [0; 1024];
    stream.read(&mut buffer).unwrap();

    // Convert the request buffer to a string
    let request_str = String::from_utf8_lossy(&buffer);

    // Parse the request path
    let request_path = parse_request_path(&request_str);

    // Serve the requested file
    serve_requested_file(&request_path, stream);
}

fn parse_request_path(request: &str) -> String {
    // Extract the path part of the request
    request.split_whitespace().nth(1).unwrap_or("/").to_string()
}

fn serve_requested_file(file_path: &str, stream: &mut TcpStream) {
    // Construct the full file path, if "/" the use index.html
    let file_path = if file_path == "/" {
        format!("{}/index.html", ROOT_DIR)
    } else {
        format!("{}/{}", ROOT_DIR, &file_path[1..])
    };

    let path = Path::new(&file_path);

    // Generate the HTTP response
    let response = match fs::read_to_string(&path) {
        Ok(contents) => format!(
            "HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{}",
            contents.len(),
            contents
        ),
        Err(_) => {
            let not_found = "404 Not Found.";
            format!(
                "HTTP/1.1 404 NOT FOUND\r\nContent-Length: {}\r\n\r\n{}",
                not_found.len(),
                not_found
            )
        }
    };

    // Send the response over the TCP stream
    stream.write(response.as_bytes()).unwrap();
    stream.flush().unwrap();
}

Note que utilizamos mais dois pacotes:

  • std::path::Path para conseguirmos pegar o caminho do arquivo.
  • std::fs Para conseguirmos ler os arquivos.

Também criamos uma nova constante chamada ROOT_DIR que passamos o caminho onde iremos colocar os arquivos.

parse_request_path

A função parse_request_path irá extrair o path da requisição HTTP.

serve_requested_file

A função serve_requested_file pega o path e tenta ler o aquivo dentro, onde retornará duas possíveis respostas:

  1. Caso encontre o arquivo: Responderá com status code 200 e o arquivo como conteúdo.
  2. Caso não encontre o arquivo: Responderá com status code 404 e o texto 404 Not Found como conteúdo.

Considerações finais

Agora que entendemos como criar um servidor HTTP simples em Rust, as possibilidades são infinitas. Podemos explorar mais recursos da linguagem e do protocolo HTTP para criar servidores mais avançados e robustos. No entanto, este é um ótimo ponto de partida.

Repositório com o código completo.