A smiling cloud that seems happy

Create a minimal C++ HTTP service with Oat++ and Bazel

Published: 2024-07-23

This blog post provides step-by-step instructions on how to build a minimal C++ HTTP service using the Oat++ framework. We will jump through the hoops necessary to build this project with the Bazel build tool, which is required because Oat++ is not built with Bazel and uses CMake instead.

We're going to ignore the CMake build and tell Bazel to compile the project itself. This is harder to maintain in the long run and we will have to validate that the project builds every time we update our Oat++ library version. Oat++ makes our work a little easier because it doesn't have external dependencies or special build steps.

I might find a better way to do this in the future.

The code in this post is available at cstroe/oatpp-minimal-http.

Installing Bazel

I used the bazelbuild/bazelisk CLI to run Bazel. Download it, extract it to a directory on your path, and then symlink the bazelisk CLI to bazel.

In my example, let's say $HOME/Bin is where I like to put executable programs on my path:

cd $HOME/Bin
wget https://github.com/bazelbuild/bazelisk/releases/download/v1.20.0/bazelisk-linux-amd64
ln -s ./bazelisk-linux-amd64 ./bazel

You can run bazel and have Bazelisk set itself up (requires Internet).

Initial repository

To start off, we can manually create a new directory for our project versioned with git:

mkdir minimal_http
cd minimal_http
git init .

NOTE: Make sure you install git if you don't have it.

Bazel repository setup

Bazel requires at least one specially named file at the root directory of your repository. For our repository, we'll start with creating a MODULE.bazel file, so that we can easily bring in some of our dependencies from the Bazel Central Registry.

touch MODULE.bazel

Content for MODULE.bazel:

module(
    name = "minimal-http",
)

bazel_dep(name = "hermetic_cc_toolchain", version = "3.1.0")

In this case, we depend on uber/hermetic_cc_toolchain so that we don't have to install a C++ compiler on our system and also to make our builds more reproducible. The downside to using hermetic_cc_toolchain is that it currently only supports C17.

Depending on Oat++

Now we'll use a file named WORKSPACE which will contain Bazel commands for declaring our dependency on Oat++.

touch WORKSPACE

Start with this content in WORKSPACE:

load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")

http_archive(
    name = "oatpp",
    build_file = "@//deps/oatpp.BUILD",
    sha256 = "eb6e217187ebe3e42382130163830e94eb2b7e067187f6024e8c745889f012c5",
    strip_prefix = "oatpp-1.3.0-latest/src",
    url = "https://github.com/oatpp/oatpp/archive/refs/tags/1.3.0-latest.zip",
)

The first line loads the http_archive build rule. The next block runs the http_archive build rule to download the Oat++ version 1.3.0 source code.

Explanation of the http_archive build rule arguments

ArgumentPurpose
namedefines a short name which becomes the repository name of this dependency
urlwhere to download the archive
sha256the sha256sum of the archive for verification
strip_prefixextracts only the files in the specified sub-directory from the archive
build_filepoints to a Bazel build file that will define build rules for the oatpp repository. This is how we will tell Bazel to build this dependency.

NOTE: The value of build_file is a path to a file. It uses a special prefix @// which matches our current repository. Then the path in the repository is deps/oatpp.BUILD.

Directory for dependency build files

We also want a directory to keep our build files for our dependencies. Let's call it deps.

mkdir deps
touch deps/BUILD

NOTE: Bazel requires a special file named BUILD in every directory in order to recognize it as a Bazel package. Without this, Bazel will complain that it can't find the files in that directory.

Now, let's create a new file deps/oatpp.BUILD in our repository:

cc_library(
    name = "main",
    srcs = glob([
        "oatpp/**/*.cpp",
    ]),
    hdrs = glob([
        "oatpp/**/*.hpp",
    ]),
    copts = [
        "-Iexternal/oatpp"
    ],
    visibility = ["//visibility:public"],
)

Here we're declaring the entire Oat++ dependency as a single C++ library. This is not how projects are normally built with Bazel, since it means longer build times. If Oat++ were to be converted to use Bazel, the build steps would be many cc_library build rules, allowing us to only depend on the C++ libraries we need for our code, and leading to smaller builds.

We also have to add a special include flag to the compiler using copts so that our external dependency headers and code are able to be imported. With Bazel builds this is not required.

Building Oat++

At this point, we can run Bazel to build our Oat++ dependency.

bazel build @oatpp//:main

Web app

Finally, let's write our code for our web app. First, create a package:

mkdir webapp
cd webapp
touch BUILD

Then populate the following files:

BUILD

cc_library(
    name = "static_controller",
    hdrs = ["StaticController.hpp"],
    deps = ["@oatpp//:main"],
)

cc_library(
    name = "status_dto",
    hdrs = ["StatusDto.hpp"],
    copts = ["-Iexternal/oatpp"],
    deps = ["@oatpp//:main"],
)

cc_library(
    name = "app_component",
    hdrs = ["AppComponent.hpp"],
    deps = ["@oatpp//:main"],
)

cc_library(
    name = "error_handler",
    srcs = ["ErrorHandler.cpp"],
    hdrs = ["ErrorHandler.hpp"],
    copts = ["-Iexternal/oatpp"],
    deps = [
        ":status_dto",
        "@oatpp//:main",
    ],
)

cc_binary(
    name = "webapp",
    srcs = ["App.cpp"],
    copts = ["-Iexternal/oatpp"],
    deps = [
        ":app_component",
        ":error_handler",
        ":static_controller",
        "@oatpp//:main",
    ],
)

App.cpp

#include "AppComponent.hpp"

#include "StaticController.hpp"

#include "oatpp/network/Server.hpp"

#include <iostream>

void run() {

  AppComponent components; // Create scope Environment components

  /* Get router component */
  OATPP_COMPONENT(std::shared_ptr<oatpp::web::server::HttpRouter>, router);

  oatpp::web::server::api::Endpoints docEndpoints;

  // docEndpoints.append(router->addController(UserController::createShared())->getEndpoints());

  // router->addController(oatpp::swagger::Controller::createShared(docEndpoints));
  router->addController(StaticController::createShared());

  /* Get connection handler component */
  OATPP_COMPONENT(std::shared_ptr<oatpp::network::ConnectionHandler>, connectionHandler);

  /* Get connection provider component */
  OATPP_COMPONENT(std::shared_ptr<oatpp::network::ServerConnectionProvider>, connectionProvider);

  /* create server */
  oatpp::network::Server server(connectionProvider,
                                connectionHandler);

  OATPP_LOGD("Server", "Running on port %s...", connectionProvider->getProperty("port").toString()->c_str());

  server.run();
}

/**
 *  main
 */
int main(int argc, const char * argv[]) {

  oatpp::base::Environment::init();

  run();

  /* Print how many objects were created during app running, and what have left-probably leaked */
  /* Disable object counting for release builds using '-D OATPP_DISABLE_ENV_OBJECT_COUNTERS' flag for better performance */
  std::cout << "\nEnvironment:\n";
  std::cout << "objectsCount = " << oatpp::base::Environment::getObjectsCount() << "\n";
  std::cout << "objectsCreated = " << oatpp::base::Environment::getObjectsCreated() << "\n\n";

  oatpp::base::Environment::destroy();

  return 0;
}

AppComponent.hpp

#ifndef AppComponent_hpp
#define AppComponent_hpp

#include <memory>

#include "ErrorHandler.hpp"

#include "oatpp/web/server/HttpConnectionHandler.hpp"
#include "oatpp/web/server/HttpRouter.hpp"
#include "oatpp/network/tcp/server/ConnectionProvider.hpp"

#include "oatpp/parser/json/mapping/ObjectMapper.hpp"

#include "oatpp/core/macro/component.hpp"

/**
 *  Class which creates and holds Application components and registers components in oatpp::base::Environment
 *  Order of components initialization is from top to bottom
 */
class AppComponent {
public:

  /**
   * Create ObjectMapper component to serialize/deserialize DTOs in Controller's API
   */
  OATPP_CREATE_COMPONENT(std::shared_ptr<oatpp::data::mapping::ObjectMapper>, apiObjectMapper)([] {
    auto objectMapper = oatpp::parser::json::mapping::ObjectMapper::createShared();
    objectMapper->getDeserializer()->getConfig()->allowUnknownFields = false;
    return objectMapper;
  }());

  /**
   *  Create ConnectionProvider component which listens on the port
   */
  OATPP_CREATE_COMPONENT(std::shared_ptr<oatpp::network::ServerConnectionProvider>, serverConnectionProvider)([] {
    return oatpp::network::tcp::server::ConnectionProvider::createShared({"0.0.0.0", 8000, oatpp::network::Address::IP_4});
  }());

  /**
   *  Create Router component
   */
  OATPP_CREATE_COMPONENT(std::shared_ptr<oatpp::web::server::HttpRouter>, httpRouter)([] {
    return oatpp::web::server::HttpRouter::createShared();
  }());

  /**
   *  Create ConnectionHandler component which uses Router component to route requests
   */
  OATPP_CREATE_COMPONENT(std::shared_ptr<oatpp::network::ConnectionHandler>, serverConnectionHandler)([] {

    OATPP_COMPONENT(std::shared_ptr<oatpp::web::server::HttpRouter>, router); // get Router component
    OATPP_COMPONENT(std::shared_ptr<oatpp::data::mapping::ObjectMapper>, objectMapper); // get ObjectMapper component

    auto connectionHandler = oatpp::web::server::HttpConnectionHandler::createShared(router);
    connectionHandler->setErrorHandler(std::make_shared<ErrorHandler>(objectMapper));
    return connectionHandler;

  }());

};

#endif /* AppComponent_hpp */

ErrorHandler.cpp

#include "ErrorHandler.hpp"

#include <memory>

ErrorHandler::ErrorHandler(const std::shared_ptr<oatpp::data::mapping::ObjectMapper>& objectMapper)
  : m_objectMapper(objectMapper)
{}

std::shared_ptr<ErrorHandler::OutgoingResponse>
ErrorHandler::handleError(const Status& status, const oatpp::String& message, const Headers& headers) {

    auto error = StatusDto::createShared();
    error->status = "ERROR";
    error->code = status.code;
    error->message = message;

    auto response = ResponseFactory::createResponse(status, error, m_objectMapper);

    for(const auto& pair : headers.getAll()) {
        response->putHeader(pair.first.toString(), pair.second.toString());
    }

    return response;

}

ErrorHandler.hpp

#ifndef CRUD_ERRORHANDLER_HPP
#define CRUD_ERRORHANDLER_HPP

#include <memory>

#include "webapp/StatusDto.hpp"

#include "oatpp/web/server/handler/ErrorHandler.hpp"
#include "oatpp/web/protocol/http/outgoing/ResponseFactory.hpp"

class ErrorHandler : public oatpp::web::server::handler::ErrorHandler {
private:
    typedef oatpp::web::protocol::http::outgoing::Response OutgoingResponse;
    typedef oatpp::web::protocol::http::Status Status;
    typedef oatpp::web::protocol::http::outgoing::ResponseFactory ResponseFactory;
private:
    std::shared_ptr<oatpp::data::mapping::ObjectMapper> m_objectMapper;
public:

    ErrorHandler(const std::shared_ptr<oatpp::data::mapping::ObjectMapper>& objectMapper);

    std::shared_ptr<OutgoingResponse>
    handleError(const Status& status, const oatpp::String& message, const Headers& headers) override;

};


#endif //CRUD_ERRORHANDLER_HPP

StaticController.hpp

#ifndef CRUD_STATICCONTROLLER_HPP
#define CRUD_STATICCONTROLLER_HPP

#include "oatpp/web/server/api/ApiController.hpp"
#include "oatpp/parser/json/mapping/ObjectMapper.hpp"
#include "oatpp/core/macro/codegen.hpp"
#include "oatpp/core/macro/component.hpp"

#include OATPP_CODEGEN_BEGIN(ApiController) //<- Begin Codegen

class StaticController : public oatpp::web::server::api::ApiController {
public:
    StaticController(const std::shared_ptr<ObjectMapper>& objectMapper)
      : oatpp::web::server::api::ApiController(objectMapper)
    {}
public:

    static std::shared_ptr<StaticController> createShared(
      OATPP_COMPONENT(std::shared_ptr<ObjectMapper>, objectMapper) // Inject objectMapper component here as default parameter
    ){
        return std::make_shared<StaticController>(objectMapper);
    }

    ENDPOINT("GET", "/", root) {
        const char* html =
          "<html lang='en'>"
          "  <head>"
          "    <meta charset=utf-8/>"
          "  </head>"
          "  <body>"
          "    <p>Hello CRUD example project!</p>"
          "  </body>"
          "</html>";
        auto response = createResponse(Status::CODE_200, html);
        response->putHeader(Header::CONTENT_TYPE, "text/html");
        return response;
    }

};

#include OATPP_CODEGEN_END(ApiController) //<- End Codegen

#endif //CRUD_STATICCONTROLLER_HPP

StatusDto.hpp

#ifndef CRUD_STATUSDTO_HPP
#define CRUD_STATUSDTO_HPP

#include "oatpp/core/macro/codegen.hpp"
#include "oatpp/core/Types.hpp"

#include OATPP_CODEGEN_BEGIN(DTO)

class StatusDto : public oatpp::DTO {

  DTO_INIT(StatusDto, DTO)

  DTO_FIELD_INFO(status) {
    info->description = "Short status text";
  }
  DTO_FIELD(String, status);

  DTO_FIELD_INFO(code) {
    info->description = "Status code";
  }
  DTO_FIELD(Int32, code);

  DTO_FIELD_INFO(message) {
    info->description = "Verbose message";
  }
  DTO_FIELD(String, message);

};

#include OATPP_CODEGEN_END(DTO)

#endif //CRUD_STATUSDTO_HPP

Running the app

Now that we've created all the code, we can run our app.

You can either run this command from anywhere in the repository:

bazel run //webapp

Or, change into the webapp directory and run this:

cd webapp
bazel run :webapp

You should see something like:

% bazel run //webapp
INFO: Analyzed target //webapp:webapp (0 packages loaded, 0 targets configured).
INFO: Found 1 target...
Target //webapp:webapp up-to-date:
  bazel-bin/webapp/webapp
INFO: Elapsed time: 0.092s, Critical Path: 0.00s
INFO: 1 process: 1 internal.
INFO: Build completed successfully, 1 total action
INFO: Running command line: bazel-bin/webapp/webapp
 D |2024-07-23 21:22:45 1721787765486655| Server:Running on port 8000...

Browse to http://localhost:8000, or check it from the command line with cURL:

% curl localhost:8000
<html lang='en'>  <head>    <meta charset=utf-8/>  </head>  <body>    <p>Hello CRUD example project!</p>  </body></html>%

Save your work

To commit your work, first create an appropriate .gitignore at the root of the repository:

/bazel-*

Then make your initial commit:

git add .
git commit -m "Initial commit"