Rakesh Vidyadharan Help

HTTP2 Framework

A simple framework for configuring and running a nghttp2-asio service. Uses the more powerful router and delegates all registered handlers to run on a separate worker thread pool. This prevents client requests from blocking the main server handling event loop.

A concept that defines the minimum expected interface for a user defined Response structure.

// // Created by Rakesh on 06/01/2025. // #pragma once #include <concepts> #include <span> #include <nghttp2/asio_http2_server.h> namespace spt::http2::framework { template <typename T> concept Response = requires( T t, std::span<const std::string> methods, std::span<const std::string> origins ) { /// Construct a response object using any relevant client request headers. requires std::constructible_from<T, const nghttp2::asio_http2::header_map&>; std::is_same<decltype(T::body), std::string>{}; std::is_same<decltype(T::filePath), std::string>{}; std::is_same<decltype(T::status), uint16_t>{}; /// Set internal state using supported HTTP method/verb and configured origins for server. { t.set( methods, origins ) }; }; }

A simple wrapper around a nghttp2::asio_http2::server::request object. Used to ensure that important data such as the request method, path, query and headers are available in the worker threads even if the client closes the connection while the request is being processed.

// // Created by Rakesh on 06/01/2025. // #pragma once #include <nghttp2/asio_http2_server.h> namespace spt::http2::framework { struct Request { explicit Request( const nghttp2::asio_http2::server::request& req ) : header{ req.header() }, method{ req.method() }, path{ req.uri().path }, query{ req.uri().raw_query }, remoteEndpoint{ req.remote_endpoint().address().to_string() } {} ~Request() = default; Request(Request&&) = default; Request& operator=(Request&&) = default; Request(const Request&) = delete; Request& operator=(const Request&) = delete; nghttp2::asio_http2::header_map header; std::string method; std::string path; std::string query; std::string remoteEndpoint; }; }

Simple structure used to configure the server. Most fields have a default value, but the origins field must be set with the origins the server will handle.

// // Created by Rakesh on 07/01/2025. // #pragma once #include <thread> #include <vector> #include <boost/asio/socket_base.hpp> namespace spt::http2::framework { struct Configuration { /// The HTTP method/verb's that are to be sent back in a OPTIONS response. Customise as appropriate. std::vector<std::string> corsMethods{ "DELETE", "GET", "OPTIONS", "PATCH", "POST", "PUT" }; /// The origins supported by this server instance. /// __Note:__ There is no default value for this. Users __must__ assign domains as appropriate. std::vector<std::string> origins; /// The hostname to bind to. Default is to bind on all local hostnames. std::string host{ "0.0.0.0" }; /// The number of server threads for handling requests. The `boost::asio::io_context` instance /// is `run` on the specified number of threads. std::size_t numberOfServerThreads{ std::thread::hardware_concurrency() }; /// The number of worker threads for handling requests. Client requests are routed /// to the configured handler function, which is run on a worker thread pool. This is /// done to offload processing from the server request handling event loop. Default is /// 2x number of CPU cores. std::size_t numberOfWorkerThreads{ 2 * std::thread::hardware_concurrency() }; /// The maximum size of payload a client can submit to an endpoint. If payload exceeds /// this size, server will respond with a `413` status. uint32_t maxPayloadSize{ 1024 * 1024 }; /// The maximum length of the queue of pending incoming connections at the socket level. /// Defaults to `boost::asio::socket_base::max_listen_connections` int16_t backlog{ boost::asio::socket_base::max_listen_connections }; /// The port to listen on. Default 9000. uint16_t port{ 9000 }; }; }

A server instance which can be configured with the desired endpoint handler functions to respond to client requests. The framework assumes the server will handle only text input (JSON, YAML, ...) as is common with API implementations. The user defined Response object conforming to the Response concept should be specified as the template type for the server instance.

// // Created by Rakesh on 07/01/2025. // #pragma once #include "configuration.hpp" #include "router.hpp" #include "stream.hpp" #include <charconv> #include <vector> #include <boost/asio/thread_pool.hpp> #include <boost/pfr/core_name.hpp> #include <fmt/format.h> #include <fmt/ranges.h> namespace spt::http2::framework { /** * HTTP2 server instance. Configure parameters using the `Configuration` structure. Add endpoint handlers * using the `addHandler` method. Start the server after setting up all the handlers. * * Keep the calling process alive using some standard strategy. * \code{.cpp} * #include <http2/framework/server.hpp> * * struct Response * { * Response( const nghttp2::asio_http2::header_map& headers ) * { * // Fetch any necessary request header values. * } * * void set( std::span<const std::string> methods, std::span<const std::string> origins ) * { * // Set response headers as appropriate using the desired methods to specify CORS headers as appropriate. * } * }; * * auto conf = spt::http2::framework::Configuration{}; * conf.port = 8080; * conf.origins = std::vector{ "https://dashboard.sptci.com", "https://admin.sptci.com", "https://app.sptci.com" }; * auto server = spt::http2::framework::Server<Response>{ conf }; * * // Add handlers similar to: * server.addHandler( "GET", "/some/path/{param}", []( const spt::http2::framework::RoutingRequest& rr, const auto& params ) * { * auto resp = Response( rr.req.header ); * resp.headers.emplace( "content-type", "application/json" ); * resp.body = boost::json::serialize( boost::json::object{ * { "code", 200 }, * { "cause", "ok" }, * { "parameter", params.at( "param" ) } * } ); * return resp; * } ); * * server.start(); * * // Run until signal to stop is received * auto ioc = boost::asio::io_context{}; * boost::asio::signal_set signals( ioc, SIGINT, SIGTERM ); * signals.async_wait( [&server](auto const&, int) { server.stop(); } ); * ioc.run(); * \endcode * @tparam Resp The response type that will be generated from the endpoint handlers. */ template <Response Resp> struct Server { /** * Create a server instance using specified configuration. Server implementation * expects only text data (JSON, YAML, ...) being submitted to endpoints. * @param config The server configuration object. */ explicit Server( const Configuration& config ) : configuration{ config }, pool{ config.numberOfWorkerThreads } { init(); } /// A call back function used to scan any data submitted to the service. Implement desired logic such /// as scanning for tags, scripts, invalid characters ... as appropriate. using Scanner = std::function<bool( std::string_view )>; /** * Create a server instance using specified configuration and input scanner function. Scanner * function should _validate_ the input data and either _approve_ or _reject_ the content. Server implementation * expects only text data (JSON, YAML, ...) being submitted to endpoints. Server will send a `400` response * if the function returns `false`. * @param config The server configuration object. * @param scanner Function to invoke when client sends payload in request (assume text data). If function * returns `false`, a `413` response is sent to client. */ explicit Server( const Configuration& config, Scanner&& scanner ) : configuration{ config }, pool{ config.numberOfWorkerThreads }, scanner{ std::move( scanner ) } { init(); } ~Server() { stop(); } /** * * @param method The HTTP method/verb for which the handler is being registered. * @param path The HTTP resource path for which the handler is being registered. * @param handler The handler function to which requests will be routed. * @param ref Optional reference to associate with the path when outputting the YAML for all handlers. */ void addHandler( std::string_view method, std::string_view path, typename Router<Resp>::Handler&& handler, std::string_view ref = {} ) { router.add( method, path, std::move( handler ), ref ); } /** * Start the server. Invoke this after setting up the endpoint handlers. */ void start() { #ifdef HAS_NANO_LOG LOG_INFO << "Starting server on " << configuration.host << ":" << configuration.port; #if BOOST_VERSION > 108600 auto obj = boost::json::object{}; boost::pfr::for_each_field_with_name( configuration, [&obj](std::string_view name, const auto& value) { obj.emplace( name, fmt::format( "{}", value ) ); } ); LOG_INFO << boost::json::serialize( obj ); #endif #endif boost::system::error_code ec; if ( server.listen_and_serve( ec, configuration.host, std::to_string( configuration.port ), true ) ) { #ifdef HAS_NANO_LOG LOG_CRIT << "error: " << ec.message(); #endif throw std::runtime_error( ec.message() ); } } /** * Stop the server. Stops the worker pool and server and joins on all outstanding threads. */ void stop() { #ifdef HAS_NANO_LOG LOG_INFO << "Stopping server on " << configuration.host << ":" << configuration.port; #endif server.stop(); pool.stop(); server.join(); pool.join(); } private: void init() { server.backlog( configuration.backlog ); server.num_threads( configuration.numberOfServerThreads ); server.handle( "/", [this](const nghttp2::asio_http2::server::request& req, const nghttp2::asio_http2::server::response& res) { handle( req, res ); }); } void handlePayload( const nghttp2::asio_http2::server::request& req, const nghttp2::asio_http2::server::response& res ) { auto body = std::make_shared<std::string>(); const auto error = []( uint16_t code, std::string_view msg, const nghttp2::asio_http2::server::response& res ) { res.write_head( code, { { "content-type", { "application/json; charset=utf-8", false } } } ); res.end( boost::json::serialize( boost::json::object{ { "code", code }, { "cause", msg } } ) ); }; auto [pathMatches, methodMatches] = router.canRoute( req.method(), req.uri().path ); if ( !pathMatches ) return error( 404, "Not Found", res ); if ( !methodMatches ) return error( 405, "Method Not Allowed", res ); auto iter = req.header().find( "content-length"s ); if ( iter == req.header().end() ) iter = req.header().find( "Content-Length"s ); if ( iter == req.header().end() ) { body->reserve( 2048 ); } else { uint32_t length{}; auto [ptr, ec] { std::from_chars( iter->second.value.data(), iter->second.value.data() + iter->second.value.size(), length ) }; if ( ec == std::errc() ) { if ( length > configuration.maxPayloadSize ) return error( 413, "Payload Too Large", res ); body->reserve( length ); } else { #ifdef HAS_NANO_LOG LOG_WARN << "Invalid content-length: " << iter->second.value; #endif body->reserve( 2048 ); } } req.on_data([body, &req, &res, &error, this](const uint8_t* chars, std::size_t size) { if ( size ) { body->append( reinterpret_cast<const char*>( chars ), size ); return; } if ( body->size() > configuration.maxPayloadSize ) return error( 413, "Payload Too Large", res ); if ( scanner && !scanner( *body ) ) return error( 400, "Prohibited input", res ); auto stream = std::make_shared<Stream<Resp>>( req, res, router, body ); res.on_close( [stream]([[maybe_unused]] uint32_t errorCode) { #ifdef HAS_NANO_LOG if ( errorCode ) LOG_INFO << "Client closed connection with error " << errorCode; #endif stream->close( true ); } ); boost::asio::post( pool, [stream, this]{ stream->process( pool, configuration ); } ); }); } void handle( const nghttp2::asio_http2::server::request& req, const nghttp2::asio_http2::server::response& res ) { if ( req.method() == "OPTIONS" ) return cors( req, res, configuration ); if ( req.method() == "POST" || req.method() == "PUT" || req.method() == "PATCH" ) { return handlePayload( req, res ); } auto stream = std::make_shared<Stream<Resp>>( req, res, router ); res.on_close( [stream]([[maybe_unused]] uint32_t errorCode) { #ifdef HAS_NANO_LOG if ( errorCode ) LOG_INFO << "Client closed connection with error " << errorCode; #endif stream->close( true ); } ); #ifdef HAS_NANO_LOG LOG_DEBUG << "Enqueueing " << req.method() << " request for " << req.uri().path; #endif boost::asio::post( pool, [stream, this]{ stream->process( pool, configuration ); } ); } Configuration configuration; boost::asio::thread_pool pool; nghttp2::asio_http2::server::http2 server; Router<Resp> router; Scanner scanner; }; }

Simple `Response` structure that conforms to the `concept` as used in the test suite.

struct Response { Response( const nghttp2::asio_http2::header_map& headers ) { auto iter = headers.find( "origin" ); if ( iter == std::cend( headers ) ) iter = headers.find( "Origin" ); if ( iter == std::cend( headers ) ) return; origin = iter->second.value; } ~Response() = default; Response(Response&&) = default; Response& operator=(Response&&) = default; Response(const Response&) = delete; Response& operator=(const Response&) = delete; void set( std::span<const std::string> methods, std::span<const std::string> origins ) { headers = nghttp2::asio_http2::header_map{ { "Access-Control-Allow-Methods", { std::format( "{:n:}", methods ), false } }, { "Access-Control-Allow-Headers", { "*, authorization", false } }, { "content-type", { "application/json; charset=utf-8", false } }, { "content-length", { std::to_string( body.size() ), false } } }; if ( compressed ) { headers.emplace( "content-encoding", nghttp2::asio_http2::header_value{ "gzip", false } ); } if ( origin.empty() ) return; const auto iter = std::ranges::find( origins, origin ); if ( iter != std::ranges::end( origins ) ) { headers.emplace( "Access-Control-Allow-Origin", nghttp2::asio_http2::header_value{ origin, false } ); headers.emplace( "Vary", nghttp2::asio_http2::header_value{ "Origin", false } ); } else LOG_WARN << "Origin " << origin << " not allowed"; } nghttp2::asio_http2::header_map headers; std::string body{ "{}" }; std::string filePath; std::string origin; uint16_t status{ 200 }; bool compressed{ false }; };

Usage

The standard workflow for creating a server instance is as follows:

  • Define the desired Response structure that conforms to the spt::http2::framework::Response concept.

    • The structure should be constructible from a nghttp2::asio_http2::header_map. Note that the headers specified are the client request headers, and not the response headers. The CTOR may use the client headers as appropriate for generating the response.

    • Implement the set method that accepts as input std::span<std::string> instances that define the HTTP methods/verbs the endpoint supports, and the origins the server serves.

  • Create a spt::http2::framework::Configuration instance.

    • Set the origins field as appropriate.

    • Change other fields as appropriate.

  • Create a server instance with the Response type and the spt::http2::framework::Configuration instance.

    • Register the desired endpoint handler functions.

    • Start the server.

  • Block the main thread until a stop/kill signal is received.

  • Stop the server when a signal is received.

Additional customisation

A couple of additional extension features are available.

Scanner

A callback function can be registered when creating the Server instance. This function will be called with the raw data submitted by clients as payload. The function can scan the data and return false if it detects any harmful content (tags, scripts, sql, ...). The callback has signature std::function<bool( std::string_view )>.

Extra Response Processing

Additional logic can be injected into the response processing workflow by specialising the void extraProcess<Response>( const Request& req, Response& resp, boost::asio::thread_pool& pool ) function. This can be used to execute additional logic before committing the response. Use to publish metrics, custom logs etc. related to the request/response cycle.

namespace spt::http2::framework { template <> void extraProcess( const Request& req, ptest::Response& resp, boost::asio::thread_pool& pool ) { auto str = std::format( "Extra processing for {} to {} using {}.", req.method, req.path, typeid( resp ).name() ); boost::asio::post( pool, [str] { ++ptest::counter; LOG_INFO << str; } ); } }

Header Only

The library can be used header only if so desired. Implement the statusMessage and cors functions are desired. The s3-proxy server uses this model and does not link to the framework library.

std::string_view spt::http2::framework::statusMessage( uint16_t status ); void spt::http2::framework::cors( const nghttp2::asio_http2::server::request& req, const nghttp2::asio_http2::server::response& res, const Configuration& configuration );

Example

Simple example server to illustrate full use of the framework. The attached CMakeLists.txt file manually links to the framework and nghttp2_asio, since I have been unsuccessful in getting the configuration for the nghttp2_asio library right.

cmake_minimum_required(VERSION 3.31) set(CMAKE_CXX_STANDARD 23) set(CMAKE_CXX_STANDARD_REQUIRED ON) if (APPLE) list(APPEND CMAKE_PREFIX_PATH "/usr/local/boost") list(APPEND CMAKE_PREFIX_PATH "/usr/local/spt") list(APPEND CMAKE_PREFIX_PATH "/usr/local/mongo") list(APPEND CMAKE_PREFIX_PATH "/usr/local/nghttp2") include_directories( /usr/local/boost/include /usr/local/mongo/include /usr/local/nghttp2/include /usr/local/spt/include /opt/homebrew/opt/libnghttp2/include ) link_directories( /usr/local/nghttp2/lib /opt/homebrew/opt/libnghttp2/lib ) find_package(bsoncxx REQUIRED) else() list(APPEND CMAKE_PREFIX_PATH "/opt/spt") list(APPEND CMAKE_PREFIX_PATH "/opt/local") include_directories(/opt/local/include /opt/spt/include) link_directories(/opt/local/lib /opt/spt/lib) endif (APPLE) set(Boost_USE_STATIC_LIBS ON) set(Boost_USE_STATIC_RUNTIME ON) set(Boost_USE_MULTITHREADED ON) find_package(Boost 1.86.0 REQUIRED json) find_package(OpenSSL 1.0.1) find_package(NanoLog REQUIRED COMPONENTS nanolog) find_package(Nghttp2Asio REQUIRED) find_package(Http2Framework) include_directories( ${OPENSSL_INCLUDE_DIR} ) add_executable(server server.cpp) target_link_libraries(server PRIVATE Boost::json http2_framework nghttp2_asio nghttp2 nanolog::nanolog ${OPENSSL_LIBRARIES})
#include <charconv> #include <boost/asio/signal_set.hpp> #include <http2/framework/server.hpp> #include <log/NanoLog.hpp> namespace { namespace presponse { struct Response { Response( const nghttp2::asio_http2::header_map& headers ) { auto iter = headers.find( "origin" ); if ( iter == std::cend( headers ) ) iter = headers.find( "Origin" ); if ( iter == std::cend( headers ) ) return; origin = iter->second.value; } ~Response() = default; Response(Response&&) = default; Response& operator=(Response&&) = default; Response(const Response&) = delete; Response& operator=(const Response&) = delete; void set( std::span<const std::string> methods, std::span<const std::string> origins ) { headers = nghttp2::asio_http2::header_map{ { "Access-Control-Allow-Methods", { std::format( "{:n:}", methods ), false } }, { "Access-Control-Allow-Headers", { "*, authorization", false } }, { "content-type", { "application/json; charset=utf-8", false } }, { "content-length", { std::to_string( body.size() ), false } } }; if ( compressed ) { headers.emplace( "content-encoding", nghttp2::asio_http2::header_value{ "gzip", false } ); } if ( origin.empty() ) return; const auto iter = std::ranges::find( origins, origin ); if ( iter != std::ranges::end( origins ) ) { headers.emplace( "Access-Control-Allow-Origin", nghttp2::asio_http2::header_value{ origin, false } ); headers.emplace( "Vary", nghttp2::asio_http2::header_value{ "Origin", false } ); } else LOG_WARN << "Origin " << origin << " not allowed"; } nghttp2::asio_http2::header_map headers; std::string body{ "{}" }; std::string entity; std::string correlationId; std::string filePath; std::string origin; uint16_t status{ 200 }; bool compressed{ false }; }; } } int main() { using namespace spt::http2::framework; nanolog::set_log_level( nanolog::LogLevel::DEBUG ); nanolog::initialize( nanolog::GuaranteedLogger(), "/tmp/", "nghttp2-asio-example", true ); auto configuration = Configuration{}; configuration.port = 3000; configuration.origins = {"http://localhost:3000"}; configuration.corsMethods = {"GET", "OPTIONS"}; auto server = Server<presponse::Response>{ configuration }; server.addHandler( "GET", "/", []( const RoutingRequest& rr, const auto& ) { auto resp = presponse::Response{ rr.req.header }; resp.headers.emplace( "content-type", "application/json; charset=utf-8" ); resp.body = boost::json::serialize( boost::json::object{ { "status", 200 }, { "message", "ok" } } ); resp.headers.emplace( "content-length", std::to_string( resp.body.size() ) ); return resp; } ); server.addHandler( "GET", "/*", []( const RoutingRequest& rr, const auto& params ) { auto resp = presponse::Response{ rr.req.header }; resp.headers.emplace( "content-type", "application/json; charset=utf-8" ); resp.body = boost::json::serialize( boost::json::object{ { "status", 200 }, { "message", "ok" }, { "resource", params.at( "_wildcard_" ) } } ); resp.headers.emplace( "content-length", std::to_string( resp.body.size() ) ); return resp; } ); LOG_INFO << "Starting HTTP/2 server on localhost:3000"; server.start(); LOG_INFO << "Test with curl --http2-prior-knowledge http://localhost:3000/"; LOG_INFO << "Test with curl --http2-prior-knowledge http://localhost:3000/some/resource.html"; LOG_INFO << "CTRL+C to exit"; auto ioc = boost::asio::io_context{}; boost::asio::signal_set signals( ioc, SIGINT, SIGTERM ); signals.async_wait( [&server](auto const&, int ) { server.stop(); }); ioc.run(); }
Last modified: 26 January 2025