diff --git a/MODULE.bazel b/MODULE.bazel index d4a466a..992d811 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -29,7 +29,7 @@ git_override( # LLVM Setup llvm = use_extension("@toolchains_llvm//toolchain/extensions:llvm.bzl", "llvm", dev_dependency = True) llvm.toolchain( - llvm_version = "17.0.6", + llvm_version = "21.1.6", ) use_repo(llvm, "llvm_toolchain") register_toolchains("@llvm_toolchain//:all", dev_dependency = True) @@ -40,4 +40,4 @@ git_override( module_name = "hedron_compile_commands", remote = "https://github.com/mikael-s-persson/bazel-compile-commands-extractor.git", commit = "f5fbd4cee671d8d908f37c83abaf70fba5928fc7", -) \ No newline at end of file +) diff --git a/README.md b/README.md index 881253d..faeb702 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,95 @@ # rest-api-helper -A simple helper aimed at making creation of REST APIs a bit nicer + +A simple helper library for C++ aimed at making the creation of REST APIs easier and cleaner. It is built on top of [cpp-httplib](https://github.com/yhirose/cpp-httplib) and [nlohmann/json](https://github.com/nlohmann/json). + +## Features + +- **Route Management**: Define routes and endpoints with a fluent API. +- **Auto-Documentation**: Automatically generates a JSON documentation endpoint for your API. +- **Parameter Descriptions**: Add descriptions for path parameters and request/response details. +- **Bazel Support**: Ready to be integrated into Bazel projects. + +## Installation + +This library is designed to be used with Bazel with Bzlmod enabled. + +### 1. Add dependency to `MODULE.bazel` + +Add the following to your `MODULE.bazel`: + +```python +# Assuming you have this in a registry or local override +bazel_dep(name = "rest_api_helper", version = "0.0.1") +``` + +### 2. Add dependency to your `BUILD` file + +In your `BUILD` or `BUILD.bazel` file, add the library to your target's `deps`: + +```python +cc_binary( + name = "my_app", + srcs = ["main.cpp"], + deps = [ + "@rest_api_helper//src:rest_api_helper", + ], +) +``` + +## Usage + +Here is a simple example of how to use the library: + +```cpp +#include +#include "src/rest_api.hpp" + +int main() +{ + httplib::Server server; + // Initialize the API helper with a base route "/api" + yuki::web::RestAPI api(server, "/api"); + + // Enable the documentation endpoint at "/api/docs" + api.add_docs_endpoint("docs"); + + // Add a route "/api/hello" + auto& hello_route = api.add_route("hello", "A simple hello route"); + + // Add a GET endpoint to the route + hello_route.add_endpoint( + yuki::web::HTTPMethod::HTTP_GET, + [](const httplib::Request&, httplib::Response& res) { + res.set_content("Hello, World!", "text/plain"); + }, + "Returns a hello message" + ); + + // Start the server + server.listen("0.0.0.0", 8080); + + return 0; +} +``` + +See `examples/basic_usage.cpp` for a more complete example. + +## Building and Testing + +To build the library: + +```bash +bazel build //src:rest_api_helper +``` + +To run the tests: + +```bash +bazel test //tests:unit_tests +``` + +## Dependencies + +- [cpp-httplib](https://github.com/yhirose/cpp-httplib) +- [nlohmann/json](https://github.com/nlohmann/json) +- [googletest](https://github.com/google/googletest) (for testing) diff --git a/examples/BUILD.bazel b/examples/BUILD.bazel new file mode 100644 index 0000000..6b474ed --- /dev/null +++ b/examples/BUILD.bazel @@ -0,0 +1,8 @@ +load("//build_utils:defs.bzl", "strict_cc_binary") + +strict_cc_binary( + name = "basic_usage", + srcs = ["basic_usage.cpp"], + deps = ["//src:rest_api_helper"], + visibility = ["//visibility:private"], +) diff --git a/src/main.cpp b/examples/basic_usage.cpp similarity index 97% rename from src/main.cpp rename to examples/basic_usage.cpp index cb8670f..1219e22 100644 --- a/src/main.cpp +++ b/examples/basic_usage.cpp @@ -1,6 +1,6 @@ #include -#include "src/hello.hpp" +#include "src/rest_api.hpp" int main() { diff --git a/src/BUILD.bazel b/src/BUILD.bazel index 2a09647..478484b 100644 --- a/src/BUILD.bazel +++ b/src/BUILD.bazel @@ -1,9 +1,9 @@ -load("//build_utils:defs.bzl", "strict_cc_binary", "strict_cc_library") +load("//build_utils:defs.bzl", "strict_cc_library") strict_cc_library( name = "rest_api_helper", - srcs = ["hello.cpp"], - hdrs = ["hello.hpp"], + srcs = ["rest_api.cpp"], + hdrs = ["rest_api.hpp"], visibility = ["//visibility:public"], deps = [ @@ -11,10 +11,3 @@ strict_cc_library( "@nlohmann_json//:json", ], ) - -strict_cc_binary( - name = "example_app", - srcs = ["main.cpp"], - deps = [":rest_api_helper"], - visibility = ["//visibility:private"], -) diff --git a/src/hello.cpp b/src/rest_api.cpp similarity index 98% rename from src/hello.cpp rename to src/rest_api.cpp index 35ab2b8..e60fee4 100644 --- a/src/hello.cpp +++ b/src/rest_api.cpp @@ -1,10 +1,12 @@ -#include "src/hello.hpp" +#include "src/rest_api.hpp" #include namespace yuki::web { +namespace +{ std::string http_method_to_string(HTTPMethod method) { switch (method) @@ -21,6 +23,7 @@ std::string http_method_to_string(HTTPMethod method) return "UNKNOWN"; } } +} // namespace RestAPI::Route::Route(httplib::Server& server, nlohmann::json& endpoints_documentation, diff --git a/src/hello.hpp b/src/rest_api.hpp similarity index 50% rename from src/hello.hpp rename to src/rest_api.hpp index 25159eb..019240e 100644 --- a/src/hello.hpp +++ b/src/rest_api.hpp @@ -10,6 +10,9 @@ namespace yuki::web { +/** + * @brief Enum representing supported HTTP methods. + */ enum class HTTPMethod { HTTP_GET, @@ -18,11 +21,23 @@ enum class HTTPMethod HTTP_PUT, }; -std::string http_method_to_string(HTTPMethod method); - +/** + * @brief A helper class to manage REST API routes and documentation. + * + * This class wraps an httplib::Server and provides a structured way to add routes + * and endpoints, automatically generating documentation for them. + * + * @note The user is responsible for managing the lifecycle of the httplib::Server instance, + * including starting the server (e.g., server.listen()) and configuring SSL/TLS if required. + */ class RestAPI { public: + /** + * @brief Represents a single route in the API (e.g., "/users"). + * + * A Route can have multiple endpoints (e.g., GET /users, POST /users). + */ class Route { public: @@ -32,6 +47,14 @@ class RestAPI Route& operator=(Route&&) = delete; ~Route() = default; + /** + * @brief Adds an endpoint to the route. + * + * @param method The HTTP method for the endpoint. + * @param handler The function to handle requests. + * @param description A description of what the endpoint does. + * @param parameters_descriptions A map of parameter names to their descriptions (optional). + */ void add_endpoint(HTTPMethod method, std::function handler, const std::string& description, @@ -49,6 +72,12 @@ class RestAPI friend class yuki::web::RestAPI; }; + /** + * @brief Construct a new RestAPI object. + * + * @param base_server The httplib::Server instance to attach routes to. + * @param base_api_route The base path for all routes (e.g., "/api/v1"). + */ RestAPI(httplib::Server& base_server, const std::string& base_api_route); RestAPI(const RestAPI&) = delete; RestAPI& operator=(const RestAPI&) = delete; @@ -56,11 +85,25 @@ class RestAPI RestAPI& operator=(RestAPI&&) = delete; ~RestAPI() = default; + /** + * @brief Adds a new route to the API. + * + * @param path The path for the route, relative to the base API route. + * @param description A description of the route resource. + * @param path_parameters_descriptions A map of path parameter names to their descriptions + * (optional). + * @return yuki::web::RestAPI::Route& A reference to the created Route object. + */ yuki::web::RestAPI::Route& add_route(const std::string& path, const std::string& description, const std::map& path_parameters_descriptions = {}); + /** + * @brief Adds a documentation endpoint that serves the generated API docs in JSON format. + * + * @param docs_path The path for the documentation endpoint, relative to the base API route. + */ void add_docs_endpoint(const std::string& docs_path); private: diff --git a/tests/BUILD.bazel b/tests/BUILD.bazel index 66ce573..564a6fa 100644 --- a/tests/BUILD.bazel +++ b/tests/BUILD.bazel @@ -2,7 +2,7 @@ load("//build_utils:defs.bzl", "strict_cc_test") strict_cc_test( name = "unit_tests", - srcs = ["hello_test.cpp"], + srcs = ["rest_api_test.cpp"], deps = [ "//src:rest_api_helper", "@googletest//:gtest", diff --git a/tests/hello_test.cpp b/tests/hello_test.cpp deleted file mode 100644 index f21b0cb..0000000 --- a/tests/hello_test.cpp +++ /dev/null @@ -1,3 +0,0 @@ - - -#include "gtest/gtest.h" diff --git a/tests/rest_api_test.cpp b/tests/rest_api_test.cpp new file mode 100644 index 0000000..5ef5783 --- /dev/null +++ b/tests/rest_api_test.cpp @@ -0,0 +1,128 @@ +#include "src/rest_api.hpp" + +#include +#include + +#include +#include +#include + +using namespace yuki::web; + +class RestAPITest : public ::testing::Test +{ + protected: + void SetUp() override + { + server_ = std::make_unique(); + api_ = std::make_unique(*server_, "/api"); + } + + void TearDown() override + { + if (server_thread_.joinable()) + { + server_->stop(); + server_thread_.join(); + } + } + + void StartServer() + { + // Note: Using a fixed port because dynamic port binding (bind_to_port(0)) + // was unreliable in the CI environment. + port_ = 12345; + server_thread_ = std::thread([this]() { server_->listen("127.0.0.1", port_); }); + + // Wait for server to start + int retries = 0; + while (!server_->is_running() && retries < 50) + { + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + retries++; + } + } + + std::unique_ptr server_; + std::unique_ptr api_; + std::thread server_thread_; + int port_; +}; + +TEST_F(RestAPITest, TestAddRouteAndEndpoint) +{ + auto& route = api_->add_route("test", "Test route"); + route.add_endpoint( + HTTPMethod::HTTP_GET, + [](const httplib::Request&, httplib::Response& res) { + res.set_content("hello", "text/plain"); + }, + "Get endpoint"); + + StartServer(); + + httplib::Client cli("127.0.0.1", port_); + auto res = cli.Get("/api/test"); + + ASSERT_TRUE(res); + EXPECT_EQ(res->status, 200); + EXPECT_EQ(res->body, "hello"); +} + +TEST_F(RestAPITest, TestDocsGeneration) +{ + auto& route = api_->add_route("test", "Test route"); + route.add_endpoint( + HTTPMethod::HTTP_GET, [](const httplib::Request&, httplib::Response&) {}, "Get endpoint"); + + api_->add_docs_endpoint("docs"); + + StartServer(); + + httplib::Client cli("127.0.0.1", port_); + auto res = cli.Get("/api/docs"); + + ASSERT_TRUE(res); + EXPECT_EQ(res->status, 200); + + auto json = nlohmann::json::parse(res->body); + EXPECT_TRUE(json.contains("/api/test")); + EXPECT_EQ(json["/api/test"]["description"], "Test route"); + EXPECT_TRUE(json["/api/test"].contains("GET")); + EXPECT_EQ(json["/api/test"]["GET"]["description"], "Get endpoint"); +} + +TEST_F(RestAPITest, TestDuplicateRouteThrows) +{ + api_->add_route("test", "Test route"); + EXPECT_THROW(api_->add_route("test", "Duplicate"), std::runtime_error); +} + +TEST_F(RestAPITest, TestDuplicateEndpointThrows) +{ + auto& route = api_->add_route("test", "Test route"); + route.add_endpoint( + HTTPMethod::HTTP_GET, [](const httplib::Request&, httplib::Response&) {}, "Get endpoint"); + EXPECT_THROW( + route.add_endpoint( + HTTPMethod::HTTP_GET, [](const httplib::Request&, httplib::Response&) {}, "Duplicate"), + std::runtime_error); +} + +TEST_F(RestAPITest, TestPathParametersInDocs) +{ + auto& route = api_->add_route("users/:id", "User route", { { "id", "User ID" } }); + route.add_endpoint( + HTTPMethod::HTTP_GET, [](const httplib::Request&, httplib::Response&) {}, "Get user"); + + api_->add_docs_endpoint("docs"); + StartServer(); + + httplib::Client cli("127.0.0.1", port_); + auto res = cli.Get("/api/docs"); + + ASSERT_TRUE(res); + auto json = nlohmann::json::parse(res->body); + EXPECT_TRUE(json["/api/users/:id"]["path_parameters"].contains("id")); + EXPECT_EQ(json["/api/users/:id"]["path_parameters"]["id"], "User ID"); +}