Servidor HTTP - Parte 2

Processando a requisição

Enzo Soares

Publicado em 27 de Março de 2025 às 16:21

Tarde!

Da última vez, paramos logo após enviar uma resposta ao cliente depois de receber a requisição. Nosso trabalho hoje é separar componentes de uma requisição e colocar em estruturas de dados próprias. Vamos lá!

Os componentes que vamos separar são:

  1. O método: normalmente é um verbo como GET, POST, PUT, etc.
  2. O caminho: o caminho do recurso a ser buscado, que é URL sem o domínio, o protocolo ou porta.
  3. A versão do protocolo, normalmente HTTP/1.1.
  4. Os cabeçalhos, uma sequência de tamanho indeterminado de pares chaves-valores separados por : ou às vezes por :.
  5. O corpo, um opcional em formato JSON.

A primeira linha da requisição segue o padrão <Método> <Caminho> HTTP/<Versão>\r\n. As linhas seguintes são os cabeçalhos, no padrão <Nome>: <Valor>\r\n. Após o último cabeçalho, se houver um corpo, é necessário um \r\n. Depois, vem o corpo, em formato JSON. Para encerrar a requisição, se tem \r\n\r\n. Uma requisição de exemplo seria:

GET / HTTP/1.1
Accept: application/json
User-Agent: IntelliJ HTTP Client/PyCharm 2024.3.3
Accept-Encoding: br, deflate, gzip, x-gzip

{
    "teste": 1
}

Considerando as especificações desse protocolo, podemos implementar um lógica que divide a requisição da seguinte forma:

  1. Quebrá-la em duas partes, corpo e cabeçalhos, usando como pivô a sequência \r\n\r\n.
  2. Ler a primeira linha dos cabeçalhos e separar método, caminho e versão através do espaço.
  3. Iterar sobre todas as linhas restantes dos cabeçalhos, adicionando-as a um dicionário.

Primeiro, vamos criar uma struct que armazena os dados de uma requisição:

const Request = struct {
    method: []const u8,
    path: []const u8,
    version: []const u8,
    headers: StringHashMap([]const u8),
    body: ?[]const u8,

    const Self = @This();

    fn init(allocator: mem.Allocator) Self {
        const map = StringHashMap([]const u8).init(allocator);
        return Self{
            .body = null,
            .headers = map,
            .method = undefined,
            .path = undefined,
            .version = undefined,
        };
    }

    fn deinit(self: *Self) void {
        self.headers.deinit();
        self.* = undefined;
    }

    fn printSelf(self: Self) void {
        print("{s} {s} {s}\n", .{ self.method, self.path, self.version });

        var iter = self.headers.iterator();
        while (iter.next()) |entry| {
            const key = entry.key_ptr.*;
            const value = entry.value_ptr.*;
            print("{s}: {s}\n", .{ key, value });
        }

        if (self.body) |body| {
            print("\n{s}\n", .{body});
        }
    }
};

São três métodos aqui: um que inicia a requisição com um mapa, um se limpa quando a requisição não for mais necessária e um método para debuggar. Repare que body é opcional, dado que uma requisição http não precisa ter um corpo.

Para processar o texto cru que vem da socket, vamos usar os métodos tokenizeSequence e tokenizeScalar. Ambos retornam um iterador, que nos retorna fatias de um buffer, delimitados por ou uma sequência, no caso do tokenizeSequence, ou por um char, no caso de tokenizeScalar.

fn parseRequest(allocator: mem.Allocator, request_raw: []u8) !Request {
    var request_iter = mem.tokenizeSequence(u8, request_raw, "\r\n\r\n");
    const raw_headers = request_iter.next() orelse "";
    var request = try parseHeaders(allocator, raw_headers);
    request.body = request_iter.next() orelse null;

    return request;
}

fn parseHeaders(allocator: mem.Allocator, raw_headers: []const u8) !Request {
    var request: Request = Request.init(allocator);

    var header_iter = mem.tokenizeSequence(u8, raw_headers, "\r\n");

    if (header_iter.next()) |first_line| {
        request.method, request.path, request.version = parseFirstLine(first_line);
    }

    while (header_iter.next()) |line| {
        const name, const value = parseHeader(line);
        try request.headers.put(name, value);
    }

    return request;
}

fn parseFirstLine(first_line: []const u8) [3][]const u8 {
    var first_line_iter = mem.tokenizeScalar(u8, first_line, ' ');

    const method = first_line_iter.next() orelse "";
    const path = first_line_iter.next() orelse "";
    const version = first_line_iter.next() orelse "";

    return .{ method, path, version };
}

fn parseHeader(line: []const u8) [2][]const u8 {
    var line_iter = mem.tokenizeSequence(u8, line, ": ");

    const name = line_iter.next() orelse "";
    const value = line_iter.next() orelse "";

    return .{ name, value };
}

parseRequest executa o primeiro passo, parseFirstLine o segundo e parseHeaders o terceiro.

Como agora o nosso código armazena informações na stack através do dicionário de cabeçalhos, temos que declarar um alocador, além de, obviamente, chamar as nossas novas funções. Nosso servidor agora se encontra assim:

const std = @import("std");
const print = std.debug.print;
const net = std.net;
const mem = std.mem;
const StringHashMap = std.StringHashMap;

const Request = struct {
    method: []const u8,
    path: []const u8,
    version: []const u8,
    headers: StringHashMap([]const u8),
    body: ?[]const u8,

    const Self = @This();

    fn init(allocator: mem.Allocator) Self {
        const map = StringHashMap([]const u8).init(allocator);
        return Self{
            .body = null,
            .headers = map,
            .method = undefined,
            .path = undefined,
            .version = undefined,
        };
    }

    fn deinit(self: *Self) void {
        self.headers.deinit();
        self.* = undefined;
    }

    fn printSelf(self: Self) void {
        print("{s} {s} {s}\n", .{ self.method, self.path, self.version });

        var iter = self.headers.iterator();
        while (iter.next()) |entry| {
            const key = entry.key_ptr.*;
            const value = entry.value_ptr.*;
            print("{s}: {s}\n", .{ key, value });
        }

        if (self.body) |body| {
            print("\n{s}\n", .{body});
        }
    }
};

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    const allocator = gpa.allocator();
    defer _ = gpa.deinit();

    print("Started server\n", .{});
    const loopback = try net.Ip4Address.parse("127.0.0.1", 2500);
    const local_address = net.Address{ .in = loopback };
    var server = try local_address.listen(.{ .reuse_address = true });
    print("Listening on {}\n", .{local_address});

    while (server.accept()) |connection| {
        std.debug.print("Accepted connection from: {}\n\n", .{connection.address});
        var received_total: [2048]u8 = undefined;
        var received_total_length: usize = 0;
        while (connection.stream.read(received_total[received_total_length..])) |temp_received_length| {
            if (temp_received_length == 0) break;
            received_total_length += temp_received_length;
            if (mem.containsAtLeast(u8, received_total[0..received_total_length], 1, "\r\n\r\n")) {
                break;
            }
        } else |read_err| {
            return read_err;
        }

        const request_raw = received_total[0..received_total_length];

        var request: Request = try parseRequest(allocator, request_raw);
        request.printSelf();
        request.deinit();

        const httpHead =
            "HTTP/1.1 200 OK \r\n" ++
            "Connection: close\r\n" ++
            "\r\n";

        _ = try connection.stream.write(httpHead);
        connection.stream.close();
    } else |err| {
        return err;
    }
}

fn parseRequest(allocator: mem.Allocator, request_raw: []u8) !Request {
    var request_iter = mem.tokenizeSequence(u8, request_raw, "\r\n\r\n");
    const raw_headers = request_iter.next() orelse "";
    var request = try parseHeaders(allocator, raw_headers);
    request.body = request_iter.next() orelse null;

    return request;
}

fn parseHeaders(allocator: mem.Allocator, raw_headers: []const u8) !Request {
    var request: Request = Request.init(allocator);

    var header_iter = mem.tokenizeSequence(u8, raw_headers, "\r\n");

    if (header_iter.next()) |first_line| {
        request.method, request.path, request.version = parseFirstLine(first_line);
    }

    while (header_iter.next()) |line| {
        const name, const value = parseHeader(line);
        try request.headers.put(name, value);
    }

    return request;
}

fn parseFirstLine(first_line: []const u8) [3][]const u8 {
    var first_line_iter = mem.tokenizeScalar(u8, first_line, ' ');

    const method = first_line_iter.next() orelse "";
    const path = first_line_iter.next() orelse "";
    const version = first_line_iter.next() orelse "";

    return .{ method, path, version };
}

fn parseHeader(line: []const u8) [2][]const u8 {
    var line_iter = mem.tokenizeSequence(u8, line, ": ");

    const name = line_iter.next() orelse "";
    const value = line_iter.next() orelse "";

    return .{ name, value };
}

Por agora acabamos. Obrigado!