3
\$\begingroup\$

I was bored, so I decided to write a very basic HTTP Request/Response parser in C++.

Some Notes:

  • This isn't meant to be a complete implementation of the HTTP protocol
  • This isn't meant to be the fastest/smallest/best library out there for this kind of thing.
  • It's a lot of code, I don't expect anyone to read all of it, but any advice regarding "modern C++" (see below) is welcome.

I'm mainly looking for comments about my "moden C++" skills, since I'm always trying to improve these.

One thing I would love though, are tips for places where I could use some more "modern" features (think things like std::optional, std::variant, ... (These are all things I've heard of and used on very rare occasions, but I really want to see if I can use them more often))

Since I did this out of pure boredom, everything is in a single file, I'll split it up in a few sections for this post though:

(all code except main() is in the HTTP namespace, I won't include it in every bit of code)

HTTP Namespace member (for convenience):

namespace HTTP
{
    constexpr static std::string_view LINE_END = "\r\n";
    ...
}

HTTP Method Enum:

enum class Method
{
    GET,
    HEAD,
    POST,
    PUT,
    DELETE,
    TRACE,
    OPTIONS,
    CONNECT,
    PATCH
};

std::string to_string(Method method)
{
    switch(method)
    {
        case Method::GET:
            return "GET";
        case Method::HEAD:
            return "HEAD";
        case Method::POST:
            return "POST";
        case Method::PUT:
            return "PUT";
        case Method::DELETE:
            return "DELETE";
        case Method::TRACE:
            return "TRACE";
        case Method::OPTIONS:
            return "OPTIONS";
        case Method::CONNECT:
            return "CONNECT";
        case Method::PATCH:
            return "PATCH";
    }
}

Method method_from_string (const std::string& method) noexcept
{
    if (method == to_string(Method::GET))
    {
        return Method::GET;
    }
    else if (method == to_string(Method::HEAD))
    {
        return Method::HEAD;
    }
    else if (method == to_string(Method::POST))
    {
        return Method::POST;
    }
    else if (method == to_string(Method::PUT))
    {
        return Method::PUT;
    }
    else if (method == to_string(Method::DELETE))
    {
        return Method::DELETE;
    }
    else if (method == to_string(Method::TRACE))
    {
        return Method::TRACE;
    }
    else if (method == to_string(Method::OPTIONS))
    {
        return Method::OPTIONS;
    }
    else if (method == to_string(Method::CONNECT))
    {
        return Method::CONNECT;
    }
    else if (method == to_string(Method::PATCH))
    {
        return Method::PATCH;
    }
}

Enum to represent HTTP Version:

enum class Version
{
    HTTP_1_0,
    HTTP_1_1,
    HTTP_2_0
};

std::string to_string(Version version)
{
    switch(version)
    {
        case Version::HTTP_1_0:
            return "HTTP/1.0";

        case Version::HTTP_1_1:
            return "HTTP/1.1";

        case Version::HTTP_2_0:
            return "HTTP/2.0";
    }
}

Version version_from_string (const std::string& version) noexcept
{
    if (version == to_string(Version::HTTP_1_0))
    {
        return Version::HTTP_1_0;
    }
    else if (version == to_string(Version::HTTP_1_1))
    {
        return Version::HTTP_1_1;
    }
    else if (version == to_string(Version::HTTP_2_0))
    {
        return Version::HTTP_2_0;
    }
}

HTTP Header Class:

class Header
{
    private:
        std::string key;
        std::string value;

    public:
        Header (const std::string& key, const std::string& value) noexcept: key(key), value(value)
        {
        }

        void set_value (const std::string& value) noexcept
        {
            this->value = value;
        }

        const std::string& get_key() const noexcept
        {
            return this->key;
        }

        std::string serialize() const noexcept
        {
            std::string header;
            header += this->key;
            header += ": ";
            header += this->value;
            header += LINE_END;

            return header;
        }

        static Header deserialize(const std::string& header)
        {
            std::vector<std::string> segments = split(header, " ");

            const std::string key = segments[0].substr(0, segments[0].size() - 1);

            segments.erase(segments.begin());

            const std::string value = concat(segments, " ");

            return Header(key, value);
        }
};

HTTP Request Class:

class Request
{
    private:
        Version version;
        Method method;
        std::string resource;
        std::map<std::string, Header> headers;

    public:
        Request(Method method, const std::string& resource, const std::map<std::string, Header>& headers, Version version = Version::HTTP_1_1) noexcept: version(version), method(method), resource(resource), headers(headers)
        {
        }

        std::string serialize() const noexcept
        {
            std::string request;
            request += to_string(this->method);
            request += " ";
            request += this->resource;
            request += " ";
            request += to_string(this->version);
            request += LINE_END;

            for (const std::pair<const std::string, Header>& header : this->headers)
            {
                request += header.second.serialize();
            }

            request += LINE_END;
            return request;
        }

        static Request deserialize(const std::string& request)
        {
            std::vector<std::string> lines = split(request, std::string(LINE_END));

            if (lines.size() < 1)
            {
                throw std::runtime_error("HTTP Request ('" + std::string(request) + "') consisted of " + std::to_string(lines.size()) + " lines, should be >= 1.");
            }

            std::vector<std::string> segments = split(lines[0], " ");

            if (segments.size() != 3)
            {
                throw std::runtime_error("First line of HTTP request ('" + std::string(request) + "') consisted of " + std::to_string(segments.size()) + " space separated segments, should be 3.");
            }

            const Method method = method_from_string(segments[0]);
            const std::string resource = segments[1];
            const Version version = version_from_string(segments[2]);

            std::map<std::string, Header> headers;

            for (std::size_t i = 1; i < lines.size(); i++)
            {
                if (lines[i].size() > 0)
                {
                    const Header header = Header::deserialize(lines[i]);
                    headers.insert(std::make_pair(header.get_key(), header));
                }
            }

            return Request(method, resource, headers, version);
        }
};

HTTP Response class:

class Response
{
    private:
        int responseCode;
        Version version;
        std::map<std::string, Header> headers;
        std::string body;

    public:
        constexpr static int OK = 200;
        constexpr static int CREATED = 201;
        constexpr static int ACCEPTED = 202;
        constexpr static int NO_CONTENT = 203;
        constexpr static int BAD_REQUEST = 400;
        constexpr static int FORBIDDEN = 403;
        constexpr static int NOT_FOUND = 404;
        constexpr static int REQUEST_TIMEOUT = 408;
        constexpr static int INTERNAL_SERVER_ERROR = 500;
        constexpr static int BAD_GATEWAY = 502;
        constexpr static int SERVICE_UNAVAILABLE = 503;

        Response (int responseCode, Version version, const std::map<std::string, Header>& headers, const std::string& body) noexcept: responseCode(responseCode), headers(headers), body(body)
        {
        }

        int get_response_code() const noexcept
        {
            return this->responseCode;
        }

        const std::string& get_body() const noexcept
        {
            return this->body;
        }

        const std::map<std::string, Header> get_headers() const noexcept
        {
            return this->headers;
        }

        static Response deserialize(const std::string& response) noexcept
        {
            std::vector<std::string> segments = split(response, std::string(LINE_END) + std::string(LINE_END));

            std::string headerSegment = segments[0];
            segments.erase(segments.begin());

            std::string body = concat(segments);

            std::vector<std::string> headerLines = split(headerSegment, std::string(LINE_END));

            const std::string& responseCodeLine = headerLines[0];

            std::vector<std::string> responseCodeSegments = split(responseCodeLine, " ");

            Version version = version_from_string(responseCodeSegments[0]);
            int responseCode = std::stoi(responseCodeSegments[1]);

            headerLines.erase(headerLines.begin());

            std::map<std::string, Header> headers;

            for (const std::string& line : headerLines)
            {
                const Header header = Header::deserialize(line);
                headers.insert(std::make_pair(header.get_key(), header));
            }

            return Response(responseCode, version, headers, body);
        }
};

The code uses 2 string manipulation methods split() and concat(), they look like this:

std::vector<std::string> split(const std::string& str, const std::string& delim) noexcept
{
    std::vector<std::string> tokens = std::vector<std::string>();
    std::string strCopy = str;

    std::size_t pos = 0;
    std::string token;

    while ((pos = strCopy.find(delim)) != std::string::npos)
    {
        token = strCopy.substr(0, pos);
        strCopy.erase(0, pos + delim.length());

        tokens.push_back(token);
    }

    if (strCopy.length() > 0)
    {
        tokens.push_back(strCopy);
    }

    return tokens;
}

std::string concat(const std::vector<std::string>& strings, const std::string& delim = "") noexcept
{
    std::string result;

    for (std::size_t i = 0; i < strings.size(); i++)
    {
        result += strings[i];

        if ((i + 1) != strings.size())
        {
            result += delim;
        }
    }

    return result;
}

And here's a main to test it all:

int main (int argc, char* argv[])
{
    if (argc != 4)
    {
        std::cout << "USAGE: ./http.elf [HOST-NAME] [RESOURCE] [OUTPUT-FILE]" << std::endl;
        return -1;
    }

    std::string host (argv[1]);
    std::string resource (argv[2]);
    std::string outputFile (argv[3]);

    HTTP::Header hostHdr = HTTP::Header("Host", host);
    HTTP::Header dntHdr = HTTP::Header("DNT", "1");

    std::map<std::string, HTTP::Header> headers;
    headers.insert(std::make_pair(hostHdr.get_key(), hostHdr));
    headers.insert(std::make_pair(dntHdr.get_key(), dntHdr));

    HTTP::Request request (HTTP::Method::GET, resource, headers);

    std::string httpRequest = request.serialize();

    int socketFileDesc = Sockets::C::Client::connect(std::string(host), 80);

    int error = send(socketFileDesc, httpRequest.c_str(), httpRequest.size(), 0);

    if (error == -1)
    {
        throw std::runtime_error("Failed to send data to " + std::string(host) + ":80.");
    }

    char buffer [8192];

    int numBytes = recv(socketFileDesc, buffer, 8192, 0);

    if (numBytes == -1)
    {
        throw std::runtime_error("Failed to receive data from " + std::string(host) + ":80.");
    }

    close(socketFileDesc);

    HTTP::Response response = HTTP::Response::deserialize(std::string(buffer));

    std::ofstream filestream (outputFile);

    filestream << response.get_body();

    filestream.close();

    return 0;
}

The compiled program needs to be run like this: http.elf example.net / index.html

This should normally save the / page from example.net to index.html

I'm aware that the way I'm receiving the response isn't the best way (The 8192-character buffer might be a bit small to receive complex websites). I'll probably start working on a wrapper class for TCP sockets that I can use until C++20 (Which will hopefully include the results of the networking TS) which should handle this sort of thing much better.

The connect() function was written using Beej's guide to network programming, this code is out of the scope of the review, should you wish to test this code, you can find the complete file here: https://gist.github.com/ThomasCassimon/304122739a3c24cd65319a489f1e88a8

It compiles on my machine (x86_64 Linux, using clang 7) with the following command line: clang++ -std=c++17 http.cpp -o http.elf

I've tested this code by sending requests to my Raspberry Pi which is running an Apache Webserver with a very basic 'hello-world'-esque index page.

\$\endgroup\$
  • \$\begingroup\$ What else is in your HTTP namespace? \$\endgroup\$ – Mast Oct 16 '18 at 19:48
  • \$\begingroup\$ @Mast The LINE_END constant and all classes and enums shown in the code. \$\endgroup\$ – shmoo6000 Oct 16 '18 at 19:54
3
\$\begingroup\$

General Comments:

This does not compile for me:

if (method == to_string(Method::GET))
              ^^^^^^^^^

Is this supposed to be std::to_string? Or a home written method.

Either way that seems like a very inefficient method of getting the string.

Same comment for:

if (version == to_string(Version::HTTP_1_0)) 
               ^^^^^^^^^

Header

Your header class:

    Header (const std::string& key, const std::string& value) noexcept
        : key(key)
        , value(value)
    {}

That works fine. But it may be nice to have a move version of the constructor.

    Header (std::string&& key, std::string&& value) noexcept
        : key(std::move(key))
        , value(std::move(value))
    {}

This will make it optimally efficient and prevent extra copying.

Why do you have a deserialize static method?

    static Header deserialize(const std::string& header)

Why not make this another constructor? You should be able to construct a header from a line you read from the request.

This is a very expensive implementation of this operation. You can split to split this line into multiple words. Then you combine the multiple words inserting space into them (not sure that even preserves the original intent). The Key and value part are not required to be separated by a space (see rfc2616) only a colon is required. Leading and trailing white space around the value may be removed (but not space in the middle).

    static Header deserialize(const std::string& header)
    {
        std::vector<std::string> segments = split(header, " ");

        const std::string key = segments[0].substr(0, segments[0].size() - 1);

        segments.erase(segments.begin());

        const std::string value = concat(segments, " ");

        return Header(key, value);
    }

The simplest method is to use std::stringstream.

    static Header deserialize(const std::string& header)
    {
        std::stringstream headerStream(header);
        std::string       key;
        std::string       value;
        std::getline(headerStream, key, ':');
        std::getline(headerStream, value);

        // Erase remove idiom
        std::erase(std::remove_if(std::begin(value), std::end(value)), std::end(value), [](char c){return std::is_space(c);});

        return Header(key, value);
    }

Slightly harder but still better would be to do it manually using find.

    static Header deserialize(const std::string& header)
    {
        std::size_t  pos = header.find(':');
        if (pos == std::string::npos) {
            throw appropriate_exception;
        }
        std::string key(header.substr(0, pos));

        std::size_t  first = header.find_first_not_of(" \t", pos + 1);
        std::size_t  last  = header.find_last_not_of(" \t");
        std::string value(header.substr(first, last - first + 1));

        return Header(key, value);
    }

Request

Does the header not already have a key and and a value?

    std::map<std::string, Header> headers;

So this should be

    std::vector<Header> headers;

In the header I was willing to overlook that serialize used string concatenation because it was so short. But here in the request its getting long and bulky. You need to look at using a stream.

Personally I would pass in a stream and ask the object to serialize itself to the stream (rather than building a string). But you can serialize to a string stream convert that to a string and return it.

Again your usage of split() to get a list of tokens is horrible and unreadable. Please re-write to be maintainable.

Response

Now in response you do need a map to hold the headers. But you need a map of header to multiple values.

std::map<std::string, Header> headers;

This should be:

std::map<std::string, std::vector<Header>> headers;

The reason is that the HTTP headers can contain the same key multiple times (each time with a different value). The underlying code should be able to distinguish these different header values.

So for a given key you should potentially get multiple values back.

Again your usage of split() makes the code basically unreadable.

Utility functions

Much easier ways to write these functions:

std::vector<std::string> split(const std::string& str, const std::string& delim) noexcept
{
    std::stringstream        strstream(str);
    std::vector<std::string> result;

    std::string word;
    while(std::getline(str, word, delim)) {
        result.push_back(word);
    }
    return result;
}
std::string concat(const std::vector<std::string>& strings, const std::string& delim = "") noexcept
{
    if (strings.size() == 0) {
        return "";
    }
    if (strings.size() == 1) {
        return strings[0];
    }
    std::stringstream result;

    std::copy(std::begin(strings), std::end(strings) - 1, std::ostream_iterator(result, " "));
    result << strings.last();
    return result.str();
}
\$\endgroup\$
  • \$\begingroup\$ Thank you very much for your comments. I'll be sure to keep this in mind! The to_string() functions are actually the HTTP::to_string() functions. (Right below the enums or in the gist at lines 150 and 222) \$\endgroup\$ – shmoo6000 Oct 17 '18 at 16:41
2
\$\begingroup\$

I have a helper function to reduce the tedious and error-prone _from_string() functions, and to slightly improve their performance. We transform the enum and its to_string() to give us a nice map:

#include <iostream>
#include <map>
template<typename T, typename R>
std::map<R,T> create_reverse_map(R(*forward_func)(T), T first, T last)
{
    using int_t = std::underlying_type_t<T>;
    std::map<R,T> m;
    auto insert = [forward_func,&m](int i) {
        auto t = T(i);
        auto r = forward_func(t);
        auto p = m.insert({r, t});
        if (!p.second) {
            std::cerr << "Duplicate ignored:" << int_t(p.first->second) << "<=" << r << "=>" << i << '\n';
        }
    };

    for (int i = int_t(first);  i <= int_t(last);  ++i) {
            insert(i);
    }
    return m;
}

With this, we reduce the conversions from string to simply:

Method method_from_string(const std::string& method)
{
    std::string (*f)(Method) = to_string;
    static auto const m = create_reverse_map(f, Method::GET, Method::PATCH);
    auto it = m.find(method);
    return it == m.end() ? Method::GET : it->second;
}

Version version_from_string(const std::string& version)
{
    std::string (*f)(Version) = to_string;
    static auto const m = create_reverse_map(f, Version::HTTP_1_0, Version::HTTP_2_0);
    auto it = m.find(version);
    return it == m.end() ? Version::HTTP_1_0 : it->second;
}

The variable f is only needed because the name to_string is overloaded - if we give them distinct names (e.g. to_string<T>), then we can use those directly in the call to create_reverse_map.

It's probably possible to make a template version of from_string if we're willing to provide traits type for the enums, containing first, last and to_string members.

Unfortunately, it's no longer noexcept, because the static initializer inserts into the map. If that's important, move m to a (better named) variable at global scope.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.